diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fd2a2be4e..27ee19bc1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Breaking Changes + +- SDK-provided `Skill` objects now carry stable catalog identity, provenance, captured content, digest, and version fields. Use `createSkill()` to construct custom skills. + ### Added +- Added a unified skill catalog shared by interactive selection, prompts, commands, SDK and RPC consumers, and frozen evolution inputs. - Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)). ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 9ce210850..2a3a0af77 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -166,7 +166,7 @@ Standard editing keybindings for delete word, undo, etc. See [docs/keybindings.m ### Commands -Type `/` in the editor to trigger commands. [Extensions](#extensions) can register custom commands, [skills](#skills) are available as `/skill:name`, and [prompt templates](#prompt-templates) expand via `/templatename`. +Type `/` in the editor to trigger commands. [Extensions](#extensions) can register custom commands, [skills](#skills) are referenced through `/skills`, and [prompt templates](#prompt-templates) expand via `/templatename`. | Command | Description | |---------|-------------| @@ -175,6 +175,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/model` | Switch models | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | | `/settings` | Thinking level, theme, message delivery, transport | +| [`/skills`](docs/skills.md#skill-commands) | Search and reference one or more skills in the editor | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | | `/name ` | Set session display name | @@ -352,7 +353,7 @@ Place in `~/.diffex/agent/prompts/`, `.diffex/prompts/`, or a [Diffex package](# ### Skills -On-demand capability packages following the [Agent Skills standard](https://agentskills.io). Invoke via `/skill:name` or let the agent load them automatically. +On-demand capability packages following the [Agent Skills standard](https://agentskills.io). Use `/skills` to choose installed workspace, installed global, or evolved skills and insert one or more versioned references into a task, or let the agent load skills automatically. ```markdown @@ -434,7 +435,7 @@ diffex update npm:@foo/diffex-tools # update one package diffex config # enable/disable extensions, skills, prompts, themes ``` -Packages install to `~/.diffex/agent/git/` (git) or `~/.diffex/agent/npm/` (npm). Use `-l` for project-local installs (`.diffex/git/`, `.diffex/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `diffex update --extensions` and `diffex update --all`, so use `diffex install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. +Interactive package installs ask whether to use global storage (`~/.diffex/agent/git/`, `~/.diffex/agent/npm/`) or workspace storage (`.diffex/git/`, `.diffex/npm/`) and show the trust implications. Non-interactive installs default to global; use `-l` to select workspace scope explicitly. Git `@ref` values are pinned tags or commits; pinned packages are skipped by `diffex update --extensions` and `diffex update --all`, so use `diffex install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. Create a package by adding the compatibility `diffex` key to `package.json`: diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 7c4298071..07561b6b7 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -286,7 +286,7 @@ user sends prompt ──────────────────── │ │ ├─► (extension commands checked first, bypass if found) │ ├─► input (can intercept, transform, or handle) │ - ├─► (skill/template expansion if not handled) │ + ├─► (skill-reference/template expansion if not handled) │ ├─► before_agent_start (can inject message, modify system prompt) ├─► agent_start │ ├─► message_start / message_update / message_end │ @@ -883,13 +883,13 @@ diffex.on("user_bash", (event, ctx) => { #### input -Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded. +Fired when user input is received, after extension commands are checked but before explicit skill references and prompt templates are expanded. The event sees the raw input text, so selected skill references and `/template` have not yet expanded. **Processing order:** 1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped 2. `input` event fires - can intercept, transform, or handle -3. If not handled: skill commands (`/skill:name`) expanded to skill content -4. If not handled: prompt templates (`/template`) expanded to template content +3. If not handled: prompt templates (`/template`) expand to template content +4. If not handled: explicit references inserted through `/skills` expand to captured skill content 5. Agent processing begins (`before_agent_start`, etc.) ```typescript @@ -914,11 +914,6 @@ diffex.on("input", async (event, ctx) => { // Route by source: skip processing for extension-injected messages if (event.source === "extension") return { action: "continue" }; - // Intercept skill commands before expansion - if (event.text.startsWith("/skill:")) { - // Could transform, block, or let pass through - } - return { action: "continue" }; // Default: pass through to expansion }); ``` @@ -1528,8 +1523,8 @@ diffex.registerCommand("deploy", { ### diffex.getCommands() -Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands. -The list matches the RPC `get_commands` ordering: extensions first, then templates, then skills. +Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands and prompt templates. +The list matches the RPC `get_commands` ordering: extensions first, then templates. ```typescript const commands = diffex.getCommands(); @@ -1543,7 +1538,7 @@ Each entry has this shape: { name: string; // Invokable command name without the leading slash. May be suffixed like "review:1" description?: string; - source: "extension" | "prompt" | "skill"; + source: "extension" | "prompt"; sourceInfo: { path: string; source: string; diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 2b483e724..dc6e01ad6 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -171,6 +171,14 @@ This routing remains configurable through the ordinary action bindings. For exam | `app.tree.filter.cycleForward` | `ctrl+o` | Cycle tree filter forward | | `app.tree.filter.cycleBackward` | `shift+ctrl+o` | Cycle tree filter backward | +### Skills Selector + +Used inside the multi-select skill picker opened via `/skills`. + +| Keybinding id | Default | Description | +|--------|---------|-------------| +| `app.skills.toggle` | `space` | Toggle the current skill without closing the selector | + ### Scoped Models Selector Used inside the scoped models selector (opened via `/scoped-models`). diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 353599cca..094ecb781 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -40,7 +40,9 @@ diffex update --extension npm:@foo/bar These commands manage Diffex packages and `diffex update` can update the Diffex CLI installation. To uninstall Diffex itself, see [Quickstart](quickstart.md#uninstall). -By default, `install` and `remove` write to user settings (`~/.diffex/agent/settings.json`). Use `-l` to write to project settings (`.diffex/settings.json`) instead. Project settings can be shared with your team, and Diffex installs any missing packages automatically on startup after the project is trusted. +In an interactive terminal, `install` asks whether to install globally or for the current workspace and shows the settings and package-storage destinations plus the security implications. Workspace installation requires project trust; cancellation, an untrusted workspace, or an unwritable destination never falls back to global installation. In non-interactive environments, `install` retains its global default. Pass `-l` to choose workspace scope explicitly without a scope prompt. + +`remove` writes to user settings (`~/.diffex/agent/settings.json`) by default; use `-l` for project settings (`.diffex/settings.json`). Project settings can be shared with your team, and Diffex installs any missing packages automatically on startup after the project is trusted. To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only: diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index a95b218de..ebb9f5d73 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -66,7 +66,7 @@ If the agent is streaming and no `streamingBehavior` is specified, the command r **Extension commands**: If the message is an extension command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `diffex.sendMessage()`. -**Input expansion**: Skill commands (`/skill:name`) and prompt templates (`/template`) are expanded before sending/queueing. +**Input expansion**: Prompt templates (`/template`) and explicit versioned skill references are expanded before sending or queueing. Response: ```json @@ -83,7 +83,7 @@ The `images` field is optional. Each image uses `ImageContent` format: `{"type": #### steer -Queue a steering message while the agent is running. It is delivered after the current assistant turn finishes executing its tool calls, before the next LLM call. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead). +Queue a steering message while the agent is running. It is delivered after the current assistant turn finishes executing its tool calls, before the next LLM call. Prompt templates and explicit skill references are expanded. Extension commands are not allowed (use `prompt` instead). ```json {"type": "steer", "message": "Stop and do this instead"} @@ -105,7 +105,7 @@ See [set_steering_mode](#set_steering_mode) for controlling how steering message #### follow_up -Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead). +Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. Prompt templates and explicit skill references are expanded. Extension commands are not allowed (use `prompt` instead). ```json {"type": "follow_up", "message": "After you're done, also do this"} @@ -798,7 +798,7 @@ The current session name is available via `get_state` in the `sessionName` field #### get_commands -Get available commands (extension commands, prompt templates, and skills). These can be invoked via the `prompt` command by prefixing with `/`. +Get available extension commands and prompt templates. These can be invoked via the `prompt` command by prefixing with `/`. ```json {"type": "get_commands"} @@ -813,8 +813,7 @@ Response: "data": { "commands": [ {"name": "session-name", "description": "Set or clear session name", "source": "extension", "path": "/home/user/.diffex/agent/extensions/session.ts"}, - {"name": "fix-tests", "description": "Fix failing tests", "source": "prompt", "location": "project", "path": "/home/user/myproject/.diffex/agent/prompts/fix-tests.md"}, - {"name": "skill:brave-search", "description": "Web search via Brave API", "source": "skill", "location": "user", "path": "/home/user/.diffex/agent/skills/brave-search/SKILL.md"} + {"name": "fix-tests", "description": "Fix failing tests", "source": "prompt", "location": "project", "path": "/home/user/myproject/.diffex/agent/prompts/fix-tests.md"} ] } } @@ -826,7 +825,6 @@ Each command has: - `source`: What kind of command: - `"extension"`: Registered via `diffex.registerCommand()` in an extension - `"prompt"`: Loaded from a prompt template `.md` file - - `"skill"`: Loaded from a skill directory (name is prefixed with `skill:`) - `location`: Where it was loaded from (optional, not present for extensions): - `"user"`: User-level (`~/.diffex/agent/`) - `"project"`: Project-level (`./.diffex/agent/`) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a76f13e97..03d137bf8 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -970,12 +970,29 @@ const loader = new DefaultResourceLoader({ await loader.reload(); const extensions = loader.getExtensions(); -const skills = loader.getSkills(); +const skillCatalog = loader.getSkills(); const prompts = loader.getPrompts(); const themes = loader.getThemes(); const contextFiles = loader.getAgentsFiles().agentsFiles; ``` +`getSkills()` returns the session's unified `SkillCatalog`. Every skill includes its stable `skillId`, `sourceKind`, `scope`, captured `content`, `contentDigest`, and `version` in addition to its model-visible name, description, and location. Catalog diagnostics retain invalid, disabled, and colliding entries that were not admitted. + +Use `createSkill()` for SDK-provided skills so the same identity and version rules apply: + +```typescript +import { createSkill, createSyntheticSourceInfo } from "@earendil-works/diffex-coding-agent"; + +const skill = createSkill({ + name: "review", + description: "Reviews repository changes.", + filePath: "/virtual/review/SKILL.md", + baseDir: "/virtual/review", + sourceInfo: createSyntheticSourceInfo("/virtual/review/SKILL.md", { source: "sdk" }), + content: "# Review\n\nReview the requested changes.", +}); +``` + ## Return Value `createAgentSession()` returns: diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index ecc27c812..4ea88222a 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -243,7 +243,6 @@ Paths in `~/.diffex/agent/settings.json` resolve relative to `~/.diffex/agent`. | `skills` | string[] | `[]` | Local skill file paths or directories | | `prompts` | string[] | `[]` | Local prompt template paths or directories | | `themes` | string[] | `[]` | Local theme file paths or directories | -| `enableSkillCommands` | boolean | `true` | Register skills as `/skill:name` commands | Arrays support glob patterns and exclusions. Use `!pattern` to exclude. Use `+path` to force-include an exact path and `-path` to force-exclude an exact path. diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index 65642318a..5a86edd56 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -63,31 +63,19 @@ For project-level Claude Code skills, add to `.diffex/settings.json`: ## How Skills Work -1. At startup, Diffex scans skill locations and extracts names and descriptions -2. The system prompt includes available skills in XML format per the [specification](https://agentskills.io/integrate-skills) -3. When a task matches, the agent uses `read` to load the full SKILL.md (models don't always do this; use prompting or `/skill:name` to force it) -4. The agent follows the instructions, using relative paths to reference scripts and assets +1. At startup, Diffex scans skill locations and builds one catalog for the session. Each entry has a stable source identity, source kind, scope, content digest, version, and the full content captured at load time. +2. The system prompt includes available skills in XML format per the [specification](https://agentskills.io/integrate-skills). +3. When a task matches, the agent uses `read` to load the full SKILL.md (models don't always do this; use `/skills` to reference it explicitly). +4. Explicit references use the catalog's captured version, so a file change does not alter a request that has already been admitted. Reload resources to catalog a changed installed skill. +5. The agent follows the instructions, using relative paths to reference scripts and assets. -This is progressive disclosure: only descriptions are always in context, full instructions load on-demand. +This is progressive disclosure: only names, descriptions, and locations are always in context; full instructions load on-demand. The TUI, system prompt, SDK, and evolution snapshots all read from the same catalog. ## Skill Commands -Skills register as `/skill:name` commands: +Use `/skills` in interactive mode to choose installed workspace, installed global, or evolved skills, then search and select one or more skills within that category. Selected skills appear in the editor as purple skill-name tokens while retaining versioned references internally, so you can add the task before submitting. Each reference loads that exact reviewed skill content for one request, including queued steering and follow-up messages. Skills not selected remain progressively disclosed by name, description, and location. -```bash -/skill:brave-search # Load and execute the skill -/skill:pdf-tools extract # Load skill with arguments -``` - -Arguments after the command are appended to the skill content as `User: `. - -Toggle skill commands via `/settings` in interactive mode or in `settings.json`: - -```json -{ - "enableSkillCommands": true -} -``` +To install a package containing skills from an interactive terminal, run `diffex install ` and choose `Install globally` or `Install for this workspace`. The prompt shows both destinations and the trust implications. Workspace installation requires project trust and never falls back to global installation. Non-interactive installs retain the global default; pass `-l` to select workspace scope explicitly. ## Skill Structure @@ -146,7 +134,7 @@ Per the [Agent Skills specification](https://agentskills.io/specification#frontm | `compatibility` | No | Max 500 chars. Environment requirements. | | `metadata` | No | Arbitrary key-value mapping. | | `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental). | -| `disable-model-invocation` | No | When `true`, skill is hidden from system prompt. Users must use `/skill:name`. | +| `disable-model-invocation` | No | When `true`, skill is hidden from the system prompt. Users must reference it explicitly through `/skills`. | ### Name Rules diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index f0b7f5fb2..bda485fb4 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -32,7 +32,7 @@ See [Keybindings](keybindings.md) for all shortcuts and customization. ## Slash Commands -Type `/` in the editor to open command completion. Extensions can register custom commands, skills are available as `/skill:name`, and prompt templates expand via `/templatename`. +Type `/` in the editor to open command completion. Extensions can register custom commands, skills are referenced through `/skills`, and prompt templates expand via `/templatename`. | Command | Description | |---------|-------------| @@ -41,6 +41,7 @@ Type `/` in the editor to open command completion. Extensions can register custo | `/model` | Switch models | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | | `/settings` | Thinking level, theme, message delivery, transport | +| `/skills` | Browse installed workspace, installed global, and evolved skills | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | | `/name ` | Set session display name | diff --git a/packages/coding-agent/examples/extensions/commands.ts b/packages/coding-agent/examples/extensions/commands.ts index aad87e58f..abae561c1 100644 --- a/packages/coding-agent/examples/extensions/commands.ts +++ b/packages/coding-agent/examples/extensions/commands.ts @@ -16,13 +16,13 @@ export default function commandsExtension(diffex: ExtensionAPI) { diffex.registerCommand("commands", { description: "List available slash commands", getArgumentCompletions: (prefix) => { - const sources = ["extension", "prompt", "skill"]; + const sources = ["extension", "prompt"]; const filtered = sources.filter((s) => s.startsWith(prefix)); return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null; }, handler: async (args, ctx) => { const commands = diffex.getCommands(); - const sourceFilter = args.trim() as "extension" | "prompt" | "skill" | ""; + const sourceFilter = args.trim() as "extension" | "prompt" | ""; // Filter by source if specified const filtered = sourceFilter ? commands.filter((c) => c.source === sourceFilter) : commands; @@ -39,10 +39,9 @@ export default function commandsExtension(diffex: ExtensionAPI) { }; const items: string[] = []; - const sources: Array<{ key: "extension" | "prompt" | "skill"; label: string }> = [ + const sources: Array<{ key: "extension" | "prompt"; label: string }> = [ { key: "extension", label: "Extensions" }, { key: "prompt", label: "Prompts" }, - { key: "skill", label: "Skills" }, ]; for (const { key, label } of sources) { diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts index 9a0944b61..fc5ec89f1 100644 --- a/packages/coding-agent/examples/sdk/04-skills.ts +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -7,22 +7,22 @@ import { createAgentSession, + createSkill, createSyntheticSourceInfo, DefaultResourceLoader, getAgentDir, SessionManager, - type Skill, } from "@earendil-works/diffex-coding-agent"; // Or define custom skills inline -const customSkill: Skill = { +const customSkill = createSkill({ name: "my-skill", description: "Custom project instructions", filePath: "/virtual/SKILL.md", baseDir: "/virtual", sourceInfo: createSyntheticSourceInfo("/virtual/SKILL.md", { source: "sdk" }), - disableModelInvocation: false, -}; + content: "# My Skill\n\nFollow the custom project instructions.", +}); const loader = new DefaultResourceLoader({ cwd: process.cwd(), diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index bbff3d6ac..3ac3ac921 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,7 +13,7 @@ * Modes use this class and add their own I/O layer on top. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import { type Agent, @@ -56,7 +56,6 @@ import type { HarnessVersionList, } from "@earendil-works/diffex-harness-state"; import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; -import { stripFrontmatter } from "../utils/frontmatter.ts"; import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; @@ -113,12 +112,19 @@ import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.t import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts"; import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts"; import type { SettingsManager } from "./settings-manager.ts"; +import { + createSkillReferenceSnapshot, + formatSkillReferenceSnapshot, + type Skill, + type SkillReference, + type SkillReferenceSnapshot, +} from "./skills.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; import type { SubagentController } from "./subagents/controller.ts"; import type { SubagentBatchResult, SubagentDeliveryBatch, SubagentSummary } from "./subagents/types.ts"; import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts"; -import { startCodingAgentSpan } from "./telemetry.ts"; +import { type CodingAgentTelemetrySpan, startCodingAgentSpan } from "./telemetry.ts"; import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; import { createAllToolDefinitions } from "./tools/index.ts"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts"; @@ -136,21 +142,61 @@ export interface ParsedSkillBlock { userMessage: string | undefined; } +export interface ParsedSkillInvocation { + skills: ParsedSkillBlock[]; + userMessage: string | undefined; +} + +function unescapeXmlAttribute(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/&/g, "&"); +} + +export function parseSkillInvocation(text: string): ParsedSkillInvocation | null { + const skills: ParsedSkillBlock[] = []; + let remaining = text; + while (remaining.startsWith("\n]+)>\n([\s\S]*?)\n<\/skill>(?:\n\n|$)/); + if (!match) return null; + const attributes = new Map(); + for (const attribute of match[1].matchAll(/([a-z-]+)="([^"]*)"/g)) { + attributes.set(attribute[1], unescapeXmlAttribute(attribute[2])); + } + const name = attributes.get("name"); + const location = attributes.get("location"); + if (!name || !location) return null; + skills.push({ name, location, content: match[2], userMessage: undefined }); + remaining = remaining.slice(match[0].length); + } + if (skills.length === 0) return null; + const userMessage = remaining.trim() || undefined; + return { + skills: skills.map((skill) => ({ ...skill, userMessage })), + userMessage, + }; +} + /** - * Parse a skill block from message text. - * Returns null if the text doesn't contain a skill block. + * Parse one skill block from message text. + * Returns null for ordinary text and multi-skill invocations. */ export function parseSkillBlock(text: string): ParsedSkillBlock | null { - const match = text.match(/^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); - if (!match) return null; - return { - name: match[1], - location: match[2], - content: match[3], - userMessage: match[4]?.trim() || undefined, - }; + const invocation = parseSkillInvocation(text); + return invocation?.skills.length === 1 ? invocation.skills[0] : null; +} + +interface SkillExpansion { + text: string; + references: SkillReferenceSnapshot[]; } +const SKILL_REFERENCE_PATTERN = + //g; + /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = | Exclude @@ -360,6 +406,7 @@ export class AgentSession { /** Messages queued to be included with the next user prompt as context ("asides"). */ private _pendingNextTurnMessages: CustomMessage[] = []; private _pendingCurrentPlanRestoration = false; + private readonly _skillReferenceSnapshots = new Map(); // Compaction state private _compactionAbortController: AbortController | undefined = undefined; @@ -1298,6 +1345,45 @@ export class AgentSession { return this._resourceLoader.getPrompts().prompts; } + private _skillReferenceKey(skillId: string, version: string): string { + return `${skillId}\0${version}`; + } + + private _snapshotSkill(skill: Skill): SkillReferenceSnapshot { + const snapshot = createSkillReferenceSnapshot(skill); + this._skillReferenceSnapshots.set(this._skillReferenceKey(snapshot.skillId, snapshot.version), snapshot); + return snapshot; + } + + /** Snapshot every currently available skill for explicit selection. */ + createSkillReferences(): SkillReference[] { + const references: SkillReference[] = []; + for (const skill of this._resourceLoader.getSkills().skills) { + try { + const snapshot = this._snapshotSkill(skill); + references.push({ + skillId: snapshot.skillId, + version: snapshot.version, + contentDigest: snapshot.contentDigest, + name: snapshot.name, + description: snapshot.description, + sourceKind: snapshot.sourceKind, + scope: snapshot.scope, + ...(snapshot.harnessRevision === undefined ? {} : { harnessRevision: snapshot.harnessRevision }), + reference: snapshot.reference, + group: snapshot.group, + }); + } catch (error) { + this._extensionRunner.emitError({ + extensionPath: skill.filePath, + event: "skill_reference", + error: error instanceof Error ? error.message : String(error), + }); + } + } + return references; + } + private _normalizePromptSnippet(text: string | undefined): string | undefined { if (!text) return undefined; const oneLine = text @@ -1370,7 +1456,9 @@ export class AgentSession { private _traceOperation( kind: "prompt" | "compaction" | "navigation" | "abort" | "queue" | "bash" | "export", - callback: (telemetryContext: TelemetryContext) => Result | Promise, + callback: ( + telemetryContext: CodingAgentTelemetrySpan<"diffex.coding_agent.operation">, + ) => Result | Promise, ): Promise { const operationId = uuidv7(); const taskLineageId = kind === "prompt" ? operationId : undefined; @@ -1593,10 +1681,14 @@ export class AgentSession { * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text: string, options?: PromptOptions): Promise { - return this._traceOperation("prompt", () => this._prompt(text, options)); + return this._traceOperation("prompt", (operationSpan) => this._prompt(text, options, operationSpan)); } - private async _prompt(text: string, options?: PromptOptions): Promise { + private async _prompt( + text: string, + options: PromptOptions | undefined, + operationSpan: CodingAgentTelemetrySpan<"diffex.coding_agent.operation">, + ): Promise { if (this._harnessSwitchInProgress) { throw new Error("Cannot submit a prompt while a harness version switch is in progress."); } @@ -1642,11 +1734,15 @@ export class AgentSession { } } - // Expand skill commands (/skill:name args) and prompt templates (/template args) + // Expand prompt templates and explicit skill references. let expandedText = currentText; + let referencedSkills: SkillReferenceSnapshot[] = []; if (expandPromptTemplates) { - expandedText = this._expandSkillCommand(expandedText); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); + const explicitReferences = this._expandSkillReferences(expandedText); + expandedText = explicitReferences.text; + referencedSkills = explicitReferences.references; + this._recordSkillReferences(operationSpan, referencedSkills); } // If streaming, queue via steer() or followUp() based on option @@ -1657,9 +1753,9 @@ export class AgentSession { ); } if (options.streamingBehavior === "followUp") { - await this._queueFollowUp(expandedText, currentImages); + await this._queueFollowUp(expandedText, currentImages, referencedSkills); } else { - await this._queueSteer(expandedText, currentImages); + await this._queueSteer(expandedText, currentImages, referencedSkills); } preflightResult?.(true); return; @@ -1789,42 +1885,45 @@ export class AgentSession { } } - /** - * Expand skill commands (/skill:name args) to their full content. - * Returns the expanded text, or the original text if not a skill command or skill not found. - * Emits errors via extension runner if file read fails. - */ - private _expandSkillCommand(text: string): string { - if (!text.startsWith("/skill:")) return text; - - const spaceIndex = text.indexOf(" "); - const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); - const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); - - const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName); - if (!skill) return text; // Unknown skill, pass through + private _recordSkillReferences( + operationSpan: CodingAgentTelemetrySpan<"diffex.coding_agent.operation">, + references: readonly SkillReferenceSnapshot[], + ): void { + if (references.length === 0) return; + operationSpan.setAttributes({ + "diffex.skill.references": references.map((reference) => `${reference.skillId}@${reference.version}`), + }); + } - try { - const content = readFileSync(skill.filePath, "utf-8"); - const body = stripFrontmatter(content).trim(); - const skillBlock = `\nReferences are relative to ${skill.baseDir}.\n\n${body}\n`; - return args ? `${skillBlock}\n\n${args}` : skillBlock; - } catch (err) { - // Emit error like extension commands do - this._extensionRunner.emitError({ - extensionPath: skill.filePath, - event: "skill_expansion", - error: err instanceof Error ? err.message : String(err), - }); - return text; // Return original on error - } + private _expandSkillReferences(text: string): SkillExpansion { + const references: SkillReferenceSnapshot[] = []; + const seen = new Set(); + const remaining = text.replace(SKILL_REFERENCE_PATTERN, (_match, skillId: string, version: string) => { + const key = this._skillReferenceKey(skillId, version); + const snapshot = this._skillReferenceSnapshots.get(key); + if (!snapshot) { + throw new Error(`Skill reference ${skillId}@${version} is not available in this session.`); + } + if (!seen.has(key)) { + seen.add(key); + references.push(snapshot); + } + return ""; + }); + if (references.length === 0) return { text, references }; + const userText = remaining.trim(); + const skillBlocks = references.map(formatSkillReferenceSnapshot).join("\n\n"); + return { + text: userText ? `${skillBlocks}\n\n${userText}` : skillBlocks, + references, + }; } /** * Queue a steering message while the agent is running. * Delivered after the current assistant turn finishes executing its tool calls, * before the next LLM call. - * Expands skill commands and prompt templates. Errors on extension commands. + * Expands prompt templates and explicit skill references. Errors on extension commands. * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ @@ -1834,17 +1933,15 @@ export class AgentSession { this._throwIfExtensionCommand(text); } - // Expand skill commands and prompt templates - let expandedText = this._expandSkillCommand(text); - expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); - - await this._queueSteer(expandedText, images); + // Expand prompt templates and explicit skill references. + const explicitReferences = this._expandSkillReferences(expandPromptTemplate(text, [...this.promptTemplates])); + await this._queueSteer(explicitReferences.text, images, explicitReferences.references); } /** * Queue a follow-up message to be processed after the agent finishes. * Delivered only when agent has no more tool calls or steering messages. - * Expands skill commands and prompt templates. Errors on extension commands. + * Expands prompt templates and explicit skill references. Errors on extension commands. * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ @@ -1854,18 +1951,23 @@ export class AgentSession { this._throwIfExtensionCommand(text); } - // Expand skill commands and prompt templates - let expandedText = this._expandSkillCommand(text); - expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); - - await this._queueFollowUp(expandedText, images); + // Expand prompt templates and explicit skill references. + const explicitReferences = this._expandSkillReferences(expandPromptTemplate(text, [...this.promptTemplates])); + await this._queueFollowUp(explicitReferences.text, images, explicitReferences.references); } /** * Internal: Queue a steering message (already expanded, no extension command check). */ - private async _queueSteer(text: string, images?: ImageContent[]): Promise { - await this._traceOperation("queue", () => this._enqueueSteer(text, images)); + private async _queueSteer( + text: string, + images?: ImageContent[], + references: readonly SkillReferenceSnapshot[] = [], + ): Promise { + await this._traceOperation("queue", (operationSpan) => { + this._recordSkillReferences(operationSpan, references); + return this._enqueueSteer(text, images); + }); } private async _enqueueSteer(text: string, images?: ImageContent[]): Promise { @@ -1885,8 +1987,15 @@ export class AgentSession { /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ - private async _queueFollowUp(text: string, images?: ImageContent[]): Promise { - await this._traceOperation("queue", () => this._enqueueFollowUp(text, images)); + private async _queueFollowUp( + text: string, + images?: ImageContent[], + references: readonly SkillReferenceSnapshot[] = [], + ): Promise { + await this._traceOperation("queue", (operationSpan) => { + this._recordSkillReferences(operationSpan, references); + return this._enqueueFollowUp(text, images); + }); } private async _enqueueFollowUp(text: string, images?: ImageContent[]): Promise { @@ -2891,14 +3000,7 @@ export class AgentSession { sourceInfo: template.sourceInfo, })); - const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({ - name: `skill:${skill.name}`, - description: skill.description, - source: "skill", - sourceInfo: skill.sourceInfo, - })); - - return [...extensionCommands, ...templates, ...skills]; + return [...extensionCommands, ...templates]; }; runner.bindCore( diff --git a/packages/coding-agent/src/core/evolution-runtime.ts b/packages/coding-agent/src/core/evolution-runtime.ts index 649ec1087..72a6d0e9d 100644 --- a/packages/coding-agent/src/core/evolution-runtime.ts +++ b/packages/coding-agent/src/core/evolution-runtime.ts @@ -32,6 +32,7 @@ import { type EvolutionProposalDetail, type EvolutionRollbackAction, type EvolutionScope, + type EvolutionSkillSnapshotSource, type EvolutionSnapshotSource, type EvolutionStartResult, EvolutionTargetAdapterRegistry, @@ -54,6 +55,7 @@ import { FileEvolutionFailureReporter } from "./evolution-failure-reporter.ts"; import { INVOCATION_TRACE_LOCK_STALE_MS, type InvocationTrace } from "./invocation-trace.ts"; import type { ModelRuntime } from "./model-runtime.ts"; import type { ResourceLoader } from "./resource-loader.ts"; +import type { Skill } from "./skills.ts"; const execFileAsync = promisify(execFile); const EVOLUTION_DIRECTORY_NAME = "evolution"; @@ -664,6 +666,24 @@ export class TraceEvidenceCatalog implements EvolutionEvidenceCatalog { } } +export function createEvolutionSkillSnapshots(skills: readonly Skill[]): readonly EvolutionSkillSnapshotSource[] { + return skills.map((skill) => ({ + skillId: skill.skillId, + name: skill.name, + description: skill.description, + sourceKind: skill.sourceKind, + scope: skill.scope, + source: skill.sourceInfo.source, + version: skill.version, + contentDigest: skill.contentDigest, + ...(skill.harnessRevision === undefined ? {} : { harnessRevision: skill.harnessRevision }), + location: skill.filePath, + baseDir: skill.baseDir, + disableModelInvocation: skill.disableModelInvocation, + content: skill.content, + })); +} + class CodingAgentSnapshotSource implements EvolutionSnapshotSource { readonly #cwd: string; readonly #directoryId: string; @@ -713,6 +733,7 @@ class CodingAgentSnapshotSource implements EvolutionSnapshotSource { content: file.content, contentDigest: computeEvolutionContentDigest(file.content), })); + const skills = createEvolutionSkillSnapshots(this.#resourceLoader.getSkills().skills); return { repository: { repositoryId: scope.repositoryId, @@ -722,6 +743,7 @@ class CodingAgentSnapshotSource implements EvolutionSnapshotSource { }, harness, instructions, + skills, modelConfiguration: { provider: model.provider, model: model.id, diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index cfdd16120..c2adfaaba 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -313,20 +313,31 @@ return ''; } - /** - * Parse a skill block from message text. - * Returns null if the text doesn't contain a skill block. - * Matches the format: \n...\n\n\nuser message - */ + /** Parse leading expanded skill blocks separately from the user's request. */ + function parseSkillInvocation(text) { + const skills = []; + let remaining = text; + while (remaining.startsWith('\n]+)>\n([\s\S]*?)\n<\/skill>(?:\n\n|$)/); + if (!match) return null; + const attributes = new Map(); + for (const attribute of match[1].matchAll(/([a-z-]+)="([^"]*)"/g)) { + attributes.set(attribute[1], attribute[2]); + } + const name = attributes.get('name'); + const location = attributes.get('location'); + if (!name || !location) return null; + skills.push({ name, location, content: match[2] }); + remaining = remaining.slice(match[0].length); + } + if (skills.length === 0) return null; + return { skills, userMessage: remaining.trim() || undefined }; + } + function parseSkillBlock(text) { - const match = text.match(/^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); - if (!match) return null; - return { - name: match[1], - location: match[2], - content: match[3], - userMessage: match[4]?.trim() || undefined, - }; + const invocation = parseSkillInvocation(text); + if (!invocation || invocation.skills.length !== 1) return null; + return { ...invocation.skills[0], userMessage: invocation.userMessage }; } function getSearchableText(entry, label) { @@ -645,11 +656,12 @@ const msg = entry.message; if (msg.role === 'user') { const rawContent = extractContent(msg.content); - const skillBlock = parseSkillBlock(rawContent); - if (skillBlock) { - let treeHtml = labelHtml + `skill: ${escapeHtml(skillBlock.name)}`; - if (skillBlock.userMessage) { - treeHtml += ` · user: ${escapeHtml(truncate(normalize(skillBlock.userMessage)))}`; + const skillInvocation = parseSkillInvocation(rawContent); + if (skillInvocation) { + const skillNames = skillInvocation.skills.map(skill => skill.name).join(', '); + let treeHtml = labelHtml + `skill: ${escapeHtml(skillNames)}`; + if (skillInvocation.userMessage) { + treeHtml += ` · user: ${escapeHtml(truncate(normalize(skillInvocation.userMessage)))}`; } return treeHtml; } @@ -1184,20 +1196,21 @@ const content = msg.content; const text = typeof content === 'string' ? content : content.filter(c => c.type === 'text').map(c => c.text).join('\n'); - const skillBlock = parseSkillBlock(text); + const skillInvocation = parseSkillInvocation(text); - if (skillBlock) { + if (skillInvocation) { // Collect images from content array const images = Array.isArray(content) ? content.filter(c => c.type === 'image') : []; - const hasUserContent = skillBlock.userMessage || images.length > 0; + const hasUserContent = skillInvocation.userMessage || images.length > 0; let html = `
${copyBtnHtml}${tsHtml}`; - // Skill invocation (collapsed by default, click to expand) - html += `
-
[skill] ${escapeHtml(skillBlock.name)}
-
${escapeHtml(skillBlock.name)} (click to expand)
-
${safeMarkedParse(skillBlock.content)}
-
`; + for (const skillBlock of skillInvocation.skills) { + html += `
+
[skill] ${escapeHtml(skillBlock.name)}
+
${escapeHtml(skillBlock.name)} (click to expand)
+
${safeMarkedParse(skillBlock.content)}
+
`; + } // User message (separate block if present) if (hasUserContent) { @@ -1209,8 +1222,8 @@ } html += '
'; } - if (skillBlock.userMessage) { - html += `
${safeMarkedParse(skillBlock.userMessage)}
`; + if (skillInvocation.userMessage) { + html += `
${safeMarkedParse(skillInvocation.userMessage)}
`; } html += ''; } diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 800f4482a..8cf4760a3 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -46,6 +46,7 @@ export interface AppKeybindings { "app.models.toggleProvider": true; "app.models.reorderUp": true; "app.models.reorderDown": true; + "app.skills.toggle": true; "app.tree.filter.default": true; "app.tree.filter.noTools": true; "app.tree.filter.userOnly": true; @@ -176,6 +177,10 @@ export const KEYBINDINGS = { defaultKeys: "alt+down", description: "Move model down in order", }, + "app.skills.toggle": { + defaultKeys: "space", + description: "Toggle skill selection", + }, "app.tree.filter.default": { defaultKeys: "ctrl+d", description: "Tree filter: default view", diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index a328ac01b..dc1962be2 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -21,8 +21,7 @@ import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from import type { PromptTemplate } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts"; import { SettingsManager } from "./settings-manager.ts"; -import type { Skill } from "./skills.ts"; -import { loadSkills } from "./skills.ts"; +import { createSkill, loadSkills, type SkillCatalog } from "./skills.ts"; import { createSourceInfo, type SourceInfo } from "./source-info.ts"; import { resetTimings } from "./timings.ts"; @@ -38,7 +37,7 @@ export interface ResourceLoaderReloadOptions { export interface ResourceLoader { getExtensions(): LoadExtensionsResult; - getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; + getSkills(): SkillCatalog; getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> }; @@ -174,10 +173,7 @@ export interface DefaultResourceLoaderOptions { systemPrompt?: string; appendSystemPrompt?: string[]; extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; - skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { - skills: Skill[]; - diagnostics: ResourceDiagnostic[]; - }; + skillsOverride?: (base: SkillCatalog) => SkillCatalog; promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[]; @@ -213,10 +209,7 @@ export class DefaultResourceLoader implements ResourceLoader { private systemPromptSource?: string; private appendSystemPromptSource?: string[]; private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; - private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { - skills: Skill[]; - diagnostics: ResourceDiagnostic[]; - }; + private skillsOverride?: (base: SkillCatalog) => SkillCatalog; private promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[]; @@ -232,8 +225,8 @@ export class DefaultResourceLoader implements ResourceLoader { private appendSystemPromptOverride?: (base: string[]) => string[]; private extensionsResult: LoadExtensionsResult; - private skills: Skill[]; - private skillDiagnostics: ResourceDiagnostic[]; + private skillCatalog: SkillCatalog; + private disabledSkillDiagnostics: ResourceDiagnostic[]; private prompts: PromptTemplate[]; private promptDiagnostics: ResourceDiagnostic[]; private themes: Theme[]; @@ -284,8 +277,8 @@ export class DefaultResourceLoader implements ResourceLoader { this.appendSystemPromptOverride = options.appendSystemPromptOverride; this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; - this.skills = []; - this.skillDiagnostics = []; + this.skillCatalog = { skills: [], diagnostics: [] }; + this.disabledSkillDiagnostics = []; this.prompts = []; this.promptDiagnostics = []; this.themes = []; @@ -307,8 +300,8 @@ export class DefaultResourceLoader implements ResourceLoader { return this.extensionsResult; } - getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } { - return { skills: this.skills, diagnostics: this.skillDiagnostics }; + getSkills(): SkillCatalog { + return this.skillCatalog; } getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } { @@ -438,6 +431,13 @@ export class DefaultResourceLoader implements ResourceLoader { getEnabledResources(resources).map((r) => r.path); const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); const enabledSkillResources = getEnabledResources(resolvedPaths.skills); + this.disabledSkillDiagnostics = resolvedPaths.skills + .filter((resource) => !resource.enabled) + .map((resource) => ({ + type: "warning", + message: "skill disabled by configuration", + path: resource.path, + })); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); const enabledThemes = getEnabledPaths(resolvedPaths.themes); @@ -485,8 +485,12 @@ export class DefaultResourceLoader implements ResourceLoader { for (const p of this.additionalSkillPaths) { if (isLocalPath(p)) { const resolved = this.resolveResourcePath(p); - if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) { - this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved }); + if (!existsSync(resolved) && !this.skillCatalog.diagnostics.some((d) => d.path === resolved)) { + this.skillCatalog.diagnostics.push({ + type: "error", + message: "Skill path does not exist", + path: resolved, + }); } } } @@ -681,7 +685,7 @@ export class DefaultResourceLoader implements ResourceLoader { } private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map): void { - let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; + let skillsResult: SkillCatalog; if (this.noSkills && skillPaths.length === 0) { skillsResult = { skills: [], diagnostics: [] }; } else { @@ -692,15 +696,34 @@ export class DefaultResourceLoader implements ResourceLoader { includeDefaults: false, }); } + skillsResult = { + skills: skillsResult.skills, + diagnostics: [...this.disabledSkillDiagnostics, ...skillsResult.diagnostics], + }; const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; - this.skills = resolvedSkills.skills.map((skill) => ({ - ...skill, - sourceInfo: - this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ?? - skill.sourceInfo ?? - this.getDefaultSourceInfoForPath(skill.filePath), - })); - this.skillDiagnostics = resolvedSkills.diagnostics; + this.skillCatalog = { + skills: resolvedSkills.skills.map((skill) => { + const sourceInfo = + this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ?? + skill.sourceInfo ?? + this.getDefaultSourceInfoForPath(skill.filePath); + return createSkill({ + name: skill.name, + description: skill.description, + filePath: skill.filePath, + baseDir: skill.baseDir, + sourceInfo, + content: skill.content, + sourceKind: skill.sourceKind, + ...(skill.sourceKind === "evolved" ? { scope: skill.scope, skillId: skill.skillId } : {}), + contentDigest: skill.contentDigest, + version: skill.version, + ...(skill.harnessRevision === undefined ? {} : { harnessRevision: skill.harnessRevision }), + disableModelInvocation: skill.disableModelInvocation, + }); + }), + diagnostics: resolvedSkills.diagnostics, + }; } private updatePromptsFromPaths(promptPaths: string[], metadataByPath?: Map): void { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index a0d06e4de..1ec9f80a3 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -119,7 +119,6 @@ export interface Settings { skills?: string[]; // Array of local skill file paths or directories prompts?: string[]; // Array of local prompt template paths or directories themes?: string[]; // Array of local theme file paths or directories - enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands terminal?: TerminalSettings; images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) @@ -408,19 +407,14 @@ export class SettingsManager { settings.skills !== null && !Array.isArray(settings.skills) ) { - const skillsSettings = settings.skills as { - enableSkillCommands?: boolean; - customDirectories?: unknown; - }; - if (skillsSettings.enableSkillCommands !== undefined && settings.enableSkillCommands === undefined) { - settings.enableSkillCommands = skillsSettings.enableSkillCommands; - } + const skillsSettings = settings.skills as { customDirectories?: unknown }; if (Array.isArray(skillsSettings.customDirectories) && skillsSettings.customDirectories.length > 0) { settings.skills = skillsSettings.customDirectories; } else { delete settings.skills; } } + delete settings.enableSkillCommands; // Migrate retry.maxDelayMs -> retry.provider.maxRetryDelayMs if ( @@ -1067,16 +1061,6 @@ export class SettingsManager { }); } - getEnableSkillCommands(): boolean { - return this.settings.enableSkillCommands ?? true; - } - - setEnableSkillCommands(enabled: boolean): void { - this.globalSettings.enableSkillCommands = enabled; - this.markModified("enableSkillCommands"); - this.save(); - } - getThinkingBudgets(): ThinkingBudgetsSettings | undefined { return this.settings.thinkingBudgets; } diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index c104c8564..5787869fc 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import ignore from "ignore"; import { basename, dirname, join, relative, resolve, sep } from "path"; @@ -71,20 +72,158 @@ export interface SkillFrontmatter { [key: string]: unknown; } +export type SkillSourceKind = "evolved" | "installed"; + +export type SkillScope = "global" | "workspace"; + export interface Skill { + skillId: string; name: string; description: string; filePath: string; baseDir: string; sourceInfo: SourceInfo; + sourceKind: SkillSourceKind; + scope: SkillScope; + content: string; + contentDigest: string; + version: string; + harnessRevision?: string; disableModelInvocation: boolean; } -export interface LoadSkillsResult { +export interface SkillCatalog { skills: Skill[]; diagnostics: ResourceDiagnostic[]; } +export type LoadSkillsResult = SkillCatalog; + +export interface CreateSkillOptions { + name: string; + description: string; + filePath: string; + baseDir: string; + sourceInfo: SourceInfo; + content: string; + sourceKind?: SkillSourceKind; + scope?: SkillScope; + skillId?: string; + contentDigest?: string; + version?: string; + harnessRevision?: string; + disableModelInvocation?: boolean; +} + +export type SkillReferenceGroup = "evolved-workspace" | "installed-workspace" | "installed-global"; + +export interface SkillReference { + skillId: string; + version: string; + contentDigest: string; + name: string; + description: string; + sourceKind: SkillSourceKind; + scope: SkillScope; + harnessRevision?: string; + reference: string; + group: SkillReferenceGroup; +} + +export interface SkillReferenceSnapshot extends SkillReference { + location: string; + baseDir: string; + sourceInfo: SourceInfo; + content: string; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function createSkill(options: CreateSkillOptions): Skill { + const sourceKind = options.sourceKind ?? "installed"; + const scope = options.scope ?? (options.sourceInfo.scope === "project" ? "workspace" : "global"); + const contentDigest = options.contentDigest ?? `sha256:${sha256(options.content)}`; + const skillId = + options.skillId ?? + `skill:${sha256( + JSON.stringify([ + canonicalizePath(options.filePath), + options.sourceInfo.source, + options.sourceInfo.scope, + options.sourceInfo.origin, + ]), + )}`; + return { + skillId, + name: options.name, + description: options.description, + filePath: options.filePath, + baseDir: options.baseDir, + sourceInfo: options.sourceInfo, + sourceKind, + scope, + content: options.content, + contentDigest, + version: options.version ?? contentDigest, + ...(options.harnessRevision === undefined ? {} : { harnessRevision: options.harnessRevision }), + disableModelInvocation: options.disableModelInvocation ?? false, + }; +} + +export function createSkillReferenceSnapshot(skill: Skill): SkillReferenceSnapshot { + const reference = ``; + return { + skillId: skill.skillId, + version: skill.version, + contentDigest: skill.contentDigest, + name: skill.name, + description: skill.description, + sourceKind: skill.sourceKind, + scope: skill.scope, + ...(skill.harnessRevision === undefined ? {} : { harnessRevision: skill.harnessRevision }), + reference, + group: + skill.sourceKind === "evolved" + ? "evolved-workspace" + : skill.scope === "workspace" + ? "installed-workspace" + : "installed-global", + location: skill.filePath, + baseDir: skill.baseDir, + sourceInfo: skill.sourceInfo, + content: skill.content, + }; +} + +export function formatSkillReferenceSnapshot(snapshot: SkillReferenceSnapshot): string { + const attributes = [ + `name="${escapeXmlAttribute(snapshot.name)}"`, + `location="${escapeXmlAttribute(snapshot.location)}"`, + `id="${snapshot.skillId}"`, + `version="${snapshot.version}"`, + `digest="${snapshot.contentDigest}"`, + `source-kind="${snapshot.sourceKind}"`, + `source="${escapeXmlAttribute(snapshot.sourceInfo.source)}"`, + `scope="${snapshot.scope}"`, + ...(snapshot.harnessRevision === undefined + ? [] + : [`harness-revision="${escapeXmlAttribute(snapshot.harnessRevision)}"`]), + `base="${escapeXmlAttribute(snapshot.baseDir)}"`, + ].join(" "); + return `\nReferences are relative to ${snapshot.baseDir}.\n\n${snapshot.content.trim()}\n`; +} + /** * Validate skill name per Agent Skills spec. * Returns array of validation error messages (empty if valid). @@ -307,14 +446,15 @@ function loadSkillFromFile( } return { - skill: { + skill: createSkill({ name, description: frontmatter.description, filePath, baseDir: skillDir, sourceInfo: createSkillSourceInfo(filePath, skillDir, source), + content: rawContent, disableModelInvocation: frontmatter["disable-model-invocation"] === true, - }, + }), diagnostics, }; } catch (error) { @@ -330,7 +470,7 @@ function loadSkillFromFile( * See: https://agentskills.io/integrate-skills * * Skills with disableModelInvocation=true are excluded from the prompt - * (they can only be invoked explicitly via /skill:name commands). + * and can only be referenced explicitly through the skill selector. */ export function formatSkillsForPrompt(skills: Skill[]): string { const visibleSkills = skills.filter((s) => !s.disableModelInvocation); diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 1a221ab0a..c2d75c790 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -1,7 +1,7 @@ import { APP_TITLE } from "../config.ts"; import type { SourceInfo } from "./source-info.ts"; -export type SlashCommandSource = "extension" | "prompt" | "skill"; +export type SlashCommandSource = "extension" | "prompt"; export interface SlashCommandInfo { name: string; @@ -18,6 +18,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, + { name: "skills", description: "Reference skills in the editor" }, { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts index 429eb0fd3..ed3982f0f 100644 --- a/packages/coding-agent/src/core/telemetry.ts +++ b/packages/coding-agent/src/core/telemetry.ts @@ -23,7 +23,7 @@ export function isInstallTelemetryEnabled( } export const CODING_AGENT_TELEMETRY_SCHEMA = { - version: 2, + version: 3, spans: { "diffex.coding_agent.diagnostic": { description: "One runtime diagnostic surfaced to the process user", @@ -81,6 +81,11 @@ export const CODING_AGENT_TELEMETRY_SCHEMA = { values: ["completed", "queued", "handled", "declined", "failed", "aborted"], description: "Terminal operation outcome", }, + "diffex.skill.references": { + type: "string[]", + cardinality: "high", + description: "Exact skill id and version pairs explicitly loaded for the operation", + }, }, status: { default: "ok", errorWhen: "The operation fails or is aborted" }, }, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 645ddb15a..265a14411 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -19,8 +19,10 @@ export { type AgentSessionEventListener, type ModelCycleResult, type ParsedSkillBlock, + type ParsedSkillInvocation, type PromptOptions, parseSkillBlock, + parseSkillInvocation, type SessionStats, } from "./core/agent-session.ts"; export { readStoredCredential } from "./core/auth-storage.ts"; @@ -266,13 +268,20 @@ export { } from "./core/settings-manager.ts"; // Skills export { + type CreateSkillOptions, + createSkill, formatSkillsForPrompt, type LoadSkillsFromDirOptions, type LoadSkillsResult, loadSkills, loadSkillsFromDir, type Skill, + type SkillCatalog, type SkillFrontmatter, + type SkillReference, + type SkillReferenceGroup, + type SkillScope, + type SkillSourceKind, } from "./core/skills.ts"; export { createSyntheticSourceInfo } from "./core/source-info.ts"; export type { SubagentStatus, SubagentSummary } from "./core/subagents/types.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 215e9952d..fabeaca94 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -28,6 +28,7 @@ export { SessionSelectorComponent } from "./session-selector.ts"; export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.ts"; export { ShowImagesSelectorComponent } from "./show-images-selector.ts"; export { SkillInvocationMessageComponent } from "./skill-invocation-message.ts"; +export { SkillSelectorComponent } from "./skill-selector.ts"; export { SubagentsComponent } from "./subagents.ts"; export { ThemeSelectorComponent } from "./theme-selector.ts"; export { ThinkingSelectorComponent } from "./thinking-selector.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index f0eb3111f..2bc3eb588 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -61,7 +61,6 @@ export interface SettingsConfig { imageWidthCells: number; autoResizeImages: boolean; blockImages: boolean; - enableSkillCommands: boolean; steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; transport: Transport; @@ -97,7 +96,6 @@ export interface SettingsCallbacks { onImageWidthCellsChange: (width: number) => void; onAutoResizeImagesChange: (enabled: boolean) => void; onBlockImagesChange: (blocked: boolean) => void; - onEnableSkillCommandsChange: (enabled: boolean) => void; onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; @@ -691,19 +689,9 @@ export class SettingsSelectorComponent extends Container { values: ["true", "false"], }); - // Skill commands toggle (insert after block-images) + // Hardware cursor toggle (insert after block-images) const blockImagesIndex = items.findIndex((item) => item.id === "block-images"); items.splice(blockImagesIndex + 1, 0, { - id: "skill-commands", - label: "Skill commands", - description: "Register skills as /skill:name commands", - currentValue: config.enableSkillCommands ? "true" : "false", - values: ["true", "false"], - }); - - // Hardware cursor toggle (insert after skill-commands) - const skillCommandsIndex = items.findIndex((item) => item.id === "skill-commands"); - items.splice(skillCommandsIndex + 1, 0, { id: "show-hardware-cursor", label: "Show hardware cursor", description: "Show the terminal cursor while still positioning it for IME support", @@ -785,9 +773,6 @@ export class SettingsSelectorComponent extends Container { case "block-images": callbacks.onBlockImagesChange(newValue === "true"); break; - case "skill-commands": - callbacks.onEnableSkillCommandsChange(newValue === "true"); - break; case "steering-mode": callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time"); break; diff --git a/packages/coding-agent/src/modes/interactive/components/skill-selector.ts b/packages/coding-agent/src/modes/interactive/components/skill-selector.ts new file mode 100644 index 000000000..39b4a304d --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/skill-selector.ts @@ -0,0 +1,264 @@ +import { + Container, + type Focusable, + fuzzyFilter, + getKeybindings, + Input, + SELECTED_LIST_PREFIX, + Spacer, + TruncatedText, + UNSELECTED_LIST_PREFIX, +} from "@earendil-works/diffex-tui"; +import type { SkillReference, SkillReferenceGroup } from "../../../core/skills.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +const GROUP_ORDER = ["installed-workspace", "installed-global", "evolved-workspace"] as const; + +const GROUP_LABELS: Record = { + "installed-workspace": "Installed Workspace Skills", + "installed-global": "Installed Global Skills", + "evolved-workspace": "Evolved Skills", +}; + +function referenceKey(reference: SkillReference): string { + return `${reference.skillId}\0${reference.version}`; +} + +function orderReferences(references: readonly SkillReference[]): SkillReference[] { + return GROUP_ORDER.flatMap((group) => references.filter((reference) => reference.group === group)); +} + +export class SkillSelectorComponent extends Container implements Focusable { + private readonly searchInput = new Input(); + private readonly listContainer = new Container(); + private readonly allReferences: SkillReference[]; + private readonly groups: SkillReferenceGroup[]; + private filteredReferences: SkillReference[] = []; + private readonly selectedKeys = new Set(); + private selectedGroupIndex = 0; + private selectedIndex = 0; + private activeGroup: SkillReferenceGroup | undefined; + private readonly onSelectCallback: (references: SkillReference[]) => void; + private readonly onCancelCallback: () => void; + private _focused = false; + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value && this.activeGroup !== undefined; + } + + constructor( + references: readonly SkillReference[], + onSelect: (references: SkillReference[]) => void, + onCancel: () => void, + ) { + super(); + this.allReferences = orderReferences(references); + this.groups = [...GROUP_ORDER]; + this.onSelectCallback = onSelect; + this.onCancelCallback = onCancel; + this.rebuild(); + } + + private rebuild(): void { + this.clear(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + if (this.activeGroup === undefined) this.buildGroupView(); + else this.buildSkillView(); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + private buildGroupView(): void { + this.searchInput.focused = false; + this.addChild(new TruncatedText(theme.fg("accent", theme.bold("Reference skills:")), 1, 0)); + this.addChild(new TruncatedText(theme.fg("muted", "Choose where the skill is installed."), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(this.listContainer); + this.updateGroupList(); + this.addChild(new Spacer(1)); + this.addChild( + new TruncatedText( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "open") + + " " + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + } + + private buildSkillView(): void { + this.searchInput.focused = this._focused; + this.addChild(new TruncatedText(theme.fg("accent", theme.bold(GROUP_LABELS[this.activeGroup!])), 1, 0)); + this.addChild(new TruncatedText(theme.fg("muted", "Select a skill to reference in the editor."), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(this.searchInput); + this.addChild(new Spacer(1)); + this.addChild(this.listContainer); + this.updateSkillList(); + this.addChild(new Spacer(1)); + this.addChild( + new TruncatedText( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("app.skills.toggle", "toggle") + + " " + + keyHint("tui.select.confirm", "insert") + + " " + + keyHint("tui.select.cancel", "back"), + 1, + 0, + ), + ); + } + + private updateGroupList(): void { + this.listContainer.clear(); + for (let index = 0; index < this.groups.length; index += 1) { + const group = this.groups[index]!; + const focused = index === this.selectedGroupIndex; + const prefix = focused ? SELECTED_LIST_PREFIX : UNSELECTED_LIST_PREFIX; + const label = focused ? theme.fg("selectionAccent", GROUP_LABELS[group]) : GROUP_LABELS[group]; + const references = this.allReferences.filter((reference) => reference.group === group); + const selectedCount = references.filter((reference) => this.selectedKeys.has(referenceKey(reference))).length; + const selection = selectedCount > 0 ? theme.fg("success", ` · ${selectedCount} selected`) : ""; + this.listContainer.addChild(new TruncatedText(`${prefix}${label}${selection}`, 1, 0)); + this.listContainer.addChild( + new TruncatedText( + theme.fg("muted", ` ${references.length} skill${references.length === 1 ? "" : "s"}`), + 1, + 0, + ), + ); + } + } + + private filterReferences(query: string): void { + const groupReferences = this.allReferences.filter((reference) => reference.group === this.activeGroup); + this.filteredReferences = query + ? fuzzyFilter(groupReferences, query, (reference) => `${reference.name} ${reference.description}`) + : groupReferences; + this.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.filteredReferences.length - 1)); + this.updateSkillList(); + } + + private openCurrentGroup(): void { + const group = this.groups[this.selectedGroupIndex]; + if (!group) return; + this.activeGroup = group; + this.selectedIndex = 0; + this.searchInput.setValue(""); + this.filteredReferences = this.allReferences.filter((reference) => reference.group === group); + this.rebuild(); + } + + private closeCurrentGroup(): void { + this.activeGroup = undefined; + this.rebuild(); + } + + private toggleCurrent(): void { + const current = this.filteredReferences[this.selectedIndex]; + if (!current) return; + const key = referenceKey(current); + if (this.selectedKeys.has(key)) this.selectedKeys.delete(key); + else this.selectedKeys.add(key); + this.updateSkillList(); + } + + private applySelection(): void { + const selected = this.allReferences.filter((reference) => this.selectedKeys.has(referenceKey(reference))); + if (selected.length > 0) { + this.onSelectCallback(selected); + return; + } + const current = this.filteredReferences[this.selectedIndex]; + if (current) this.onSelectCallback([current]); + } + + private updateSkillList(): void { + this.listContainer.clear(); + const maxVisible = 5; + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredReferences.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, this.filteredReferences.length); + + for (let index = startIndex; index < endIndex; index += 1) { + const reference = this.filteredReferences[index]; + if (!reference) continue; + const focused = index === this.selectedIndex; + const prefix = focused ? SELECTED_LIST_PREFIX : UNSELECTED_LIST_PREFIX; + const selected = this.selectedKeys.has(referenceKey(reference)); + const selection = selected ? theme.fg("success", " ✓") : ""; + const name = focused ? theme.fg("selectionAccent", reference.name) : reference.name; + this.listContainer.addChild(new TruncatedText(`${prefix}${name}${selection}`, 1, 0)); + this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${reference.description}`), 1, 0)); + } + + if (startIndex > 0 || endIndex < this.filteredReferences.length) { + this.listContainer.addChild( + new TruncatedText( + theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredReferences.length})`), + 1, + 0, + ), + ); + } + if (this.filteredReferences.length === 0) { + this.listContainer.addChild(new TruncatedText(theme.fg("muted", " No matching skills"), 1, 0)); + } + } + + handleInput(keyData: string): void { + const keybindings = getKeybindings(); + if (this.activeGroup === undefined) { + if (keybindings.matches(keyData, "tui.select.up")) { + this.selectedGroupIndex = Math.max(0, this.selectedGroupIndex - 1); + this.updateGroupList(); + } else if (keybindings.matches(keyData, "tui.select.down")) { + this.selectedGroupIndex = Math.min(this.groups.length - 1, this.selectedGroupIndex + 1); + this.updateGroupList(); + } else if (keybindings.matches(keyData, "tui.select.confirm")) { + this.openCurrentGroup(); + } else if (keybindings.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } + return; + } + + if (keybindings.matches(keyData, "tui.select.up")) { + if (this.filteredReferences.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.updateSkillList(); + } else if (keybindings.matches(keyData, "tui.select.down")) { + if (this.filteredReferences.length === 0) return; + this.selectedIndex = Math.min(this.filteredReferences.length - 1, this.selectedIndex + 1); + this.updateSkillList(); + } else if (keybindings.matches(keyData, "app.skills.toggle")) { + this.toggleCurrent(); + } else if (keybindings.matches(keyData, "tui.select.confirm")) { + this.applySelection(); + } else if (keybindings.matches(keyData, "tui.select.cancel")) { + this.closeCurrentGroup(); + } else { + this.searchInput.handleInput(keyData); + this.filterReferences(this.searchInput.getValue()); + } + } + + getSearchInput(): Input { + return this.searchInput; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5157ba381..afed74e7a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -14,6 +14,7 @@ import type { AutocompleteItem, AutocompleteProvider, EditorComponent, + EditorTextPart, Keybinding, KeyId, MarkdownTheme, @@ -57,7 +58,7 @@ import { getShareViewerUrl, VERSION, } from "../../config.ts"; -import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; +import { type AgentSession, type AgentSessionEvent, parseSkillInvocation } from "../../core/agent-session.ts"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; import { CACHE_TTL_MS, @@ -94,6 +95,7 @@ import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts"; import type { TuiMode } from "../../core/settings-manager.ts"; +import type { SkillReference } from "../../core/skills.ts"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; @@ -157,6 +159,7 @@ import { ScopedModelsSelectorComponent } from "./components/scoped-models-select import { SessionSelectorComponent } from "./components/session-selector.ts"; import { SettingsSelectorComponent } from "./components/settings-selector.ts"; import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; +import { SkillSelectorComponent } from "./components/skill-selector.ts"; import { BranchSummaryStatusIndicator, CompactionStatusIndicator, @@ -490,9 +493,6 @@ export class InteractiveMode { theme, }); - // Skill commands: command name -> skill file path - private skillCommands = new Map(); - // Agent subscription unsubscribe function private unsubscribe?: () => void; private signalCleanupHandlers: Array<() => void> = []; @@ -610,6 +610,7 @@ export class InteractiveMode { concealedInputPrefix: "!", placeholder: "Ask Diffex to do something for you.", placeholderStyle: (text) => theme.fg("muted", text), + textTokenStyle: (text) => `\x1b[38;2;196;114;214m${text}\x1b[39m`, }); this.editor = this.defaultEditor; this.editorContainer = new Container(); @@ -756,22 +757,8 @@ export class InteractiveMode { getArgumentCompletions: cmd.getArgumentCompletions, })); - // Build skill commands from session.skills (if enabled) - this.skillCommands.clear(); - const skillCommandList: SlashCommand[] = []; - if (this.settingsManager.getEnableSkillCommands()) { - for (const skill of this.session.resourceLoader.getSkills().skills) { - const commandName = `skill:${skill.name}`; - this.skillCommands.set(commandName, skill.filePath); - skillCommandList.push({ - name: commandName, - description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo), - }); - } - } - return new CombinedAutocompleteProvider( - [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], + [...slashCommands, ...templateCommands, ...extensionCommands], this.sessionManager.getCwd(), this.fdPath, ); @@ -2605,8 +2592,8 @@ export class InteractiveMode { private setCustomEditorComponent(factory: EditorFactory | undefined): void { this.editorComponentFactory = factory; - // Save text from current editor before switching - const currentText = this.editor.getText(); + // Save expanded text so editor-specific tokens do not leak across implementations. + const currentText = this.editor.getExpandedText?.() ?? this.editor.getText(); this.disposeActiveSelector(); this.editorContainer.clear(); @@ -2922,6 +2909,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/skills") { + this.editor.setText(""); + this.showSkillsSelector(); + return; + } if (text === "/scoped-models") { this.editor.setText(""); await this.showModelsSelector(); @@ -3569,20 +3561,20 @@ export class InteractiveMode { if (this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } - const skillBlock = parseSkillBlock(textContent); - if (skillBlock) { - // Render skill block (collapsible) - const component = new SkillInvocationMessageComponent( - skillBlock, - this.getMarkdownThemeWithSettings(), - ); - component.setExpanded(this.toolOutputExpanded); - this.chatContainer.addChild(component); - // Render user message separately if present - if (skillBlock.userMessage) { + const skillInvocation = parseSkillInvocation(textContent); + if (skillInvocation) { + for (const skillBlock of skillInvocation.skills) { + const component = new SkillInvocationMessageComponent( + skillBlock, + this.getMarkdownThemeWithSettings(), + ); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + } + if (skillInvocation.userMessage) { this.chatContainer.addChild(new Spacer(1)); const userComponent = new UserMessageComponent( - skillBlock.userMessage, + skillInvocation.userMessage, this.getMarkdownThemeWithSettings(), this.outputPad, this.getMarkdownTransformers(), @@ -4445,6 +4437,41 @@ export class InteractiveMode { this.ui.requestRender(); } + private showSkillsSelector(): void { + const references = this.session.createSkillReferences(); + if (references.length === 0) { + this.showStatus("No skills available"); + return; + } + this.showSelector((done) => { + const selector = new SkillSelectorComponent( + references, + (selected: SkillReference[]) => { + done(); + const editorText = this.editor.getExpandedText?.() ?? this.editor.getText(); + if (this.editor.setTextWithTokens) { + const parts: EditorTextPart[] = []; + for (const [index, reference] of selected.entries()) { + if (index > 0) parts.push("\n"); + parts.push({ value: reference.reference, displayText: reference.name }); + } + if (editorText.trim()) parts.push("\n\n", editorText); + this.editor.setTextWithTokens(parts); + } else { + const referenceText = selected.map((reference) => reference.reference).join("\n"); + this.editor.setText([referenceText, editorText].filter((value) => value.trim()).join("\n\n")); + } + this.ui.requestRender(); + }, + () => { + done(); + this.ui.requestRender(); + }, + ); + return { component: selector, focus: selector }; + }); + } + private showSettingsSelector(): void { this.showSelector((done) => { let selector: SettingsSelectorComponent | undefined; @@ -4455,7 +4482,6 @@ export class InteractiveMode { imageWidthCells: this.settingsManager.getImageWidthCells(), autoResizeImages: this.settingsManager.getImageAutoResize(), blockImages: this.settingsManager.getBlockImages(), - enableSkillCommands: this.settingsManager.getEnableSkillCommands(), steeringMode: this.session.steeringMode, followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), @@ -4510,10 +4536,6 @@ export class InteractiveMode { onBlockImagesChange: (blocked) => { this.settingsManager.setBlockImages(blocked); }, - onEnableSkillCommandsChange: (enabled) => { - this.settingsManager.setEnableSkillCommands(enabled); - this.setupAutocompleteProvider(); - }, onSteeringModeChange: (mode) => { this.session.setSteeringMode(mode); }, diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 50dddf783..30f37b164 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -438,7 +438,7 @@ export class RpcClient { } /** - * Get available commands (extension commands, prompt templates, skills). + * Get available extension commands and prompt templates. */ async getCommands(): Promise { const response = await this.send({ type: "get_commands" }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 1b5d8a656..b1c07e14c 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -696,15 +696,6 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise chalk.underline(text), }; +type PackageInstallScope = "global" | "workspace"; + interface PackageCommandOptions { command: PackageCommand; source?: string; updateTarget?: UpdateTarget; showExtensionsSkippedNote: boolean; - local: boolean; + local: boolean | undefined; force: boolean; projectTrustOverride?: boolean; help: boolean; @@ -198,7 +201,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined return undefined; } - let local = false; + let local: boolean | undefined; let force = false; let projectTrustOverride: boolean | undefined; let help = false; @@ -537,6 +540,11 @@ function prepareWindowsNpmSelfUpdate(): void { export interface PackageCommandRuntimeOptions { extensionFactories?: InlineExtension[]; + selectInstallScope?: (input: { + source: string; + cwd: string; + agentDir: string; + }) => Promise; } interface CommandSettingsResult { @@ -548,6 +556,23 @@ function getCommandAppMode(): AppMode { return process.stdin.isTTY && process.stdout.isTTY ? "interactive" : "print"; } +async function selectInteractiveInstallScope(input: { + source: string; + cwd: string; + agentDir: string; +}): Promise { + const workspaceDir = join(input.cwd, CONFIG_DIR_NAME); + const settingsManager = SettingsManager.create(input.cwd, input.agentDir, { projectTrusted: false }); + return showStartupSelector( + settingsManager, + `Install ${input.source}\n\nGlobal destination: ${join(input.agentDir, "settings.json")} and package storage under ${input.agentDir}.\nWorkspace destination: ${join(workspaceDir, "settings.json")} and package storage under ${workspaceDir}. This requires project trust and may be shared with collaborators.\n\nPackages can execute extensions and skills can instruct the model to run commands. Review the package before installing.`, + [ + { label: "Install globally", value: "global" }, + { label: "Install for this workspace", value: "workspace" }, + ], + ); +} + function reportProjectTrustWarnings(warnings: readonly string[]): void { for (const warning of warnings) { console.error(chalk.yellow(`Warning: ${warning}`)); @@ -738,7 +763,21 @@ export async function handlePackageCommand( const cwd = process.cwd(); const agentDir = getAgentDir(); - const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; + let local = options.local ?? false; + if (options.command === "install" && options.local === undefined) { + const selectInstallScope = + runtimeOptions.selectInstallScope ?? + (getCommandAppMode() === "interactive" ? selectInteractiveInstallScope : undefined); + if (selectInstallScope) { + const scope = await selectInstallScope({ source: source!, cwd, agentDir }); + if (scope === undefined) { + console.error(chalk.yellow("Installation cancelled.")); + return true; + } + local = scope === "workspace"; + } + } + const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && local; const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ cwd, agentDir, @@ -765,13 +804,23 @@ export async function handlePackageCommand( try { switch (options.command) { - case "install": - await packageManager.installAndPersist(source!, { local: options.local }); + case "install": { + await packageManager.installAndPersist(source!, { local }); + await settingsManager.flush(); + const persistenceErrors = settingsManager.drainErrors(); + if (persistenceErrors.length > 0) { + throw new Error( + `Could not persist the ${local ? "workspace" : "global"} installation: ${persistenceErrors + .map(({ error }) => error.message) + .join("; ")}`, + ); + } console.log(chalk.green(`Installed ${source}`)); return true; + } case "remove": { - const removed = await packageManager.removeAndPersist(source!, { local: options.local }); + const removed = await packageManager.removeAndPersist(source!, { local }); if (!removed) { console.error(chalk.red(`No matching package found for ${source}`)); process.exitCode = 1; diff --git a/packages/coding-agent/test/evolution-runtime.test.ts b/packages/coding-agent/test/evolution-runtime.test.ts index c61a8c5c1..5cc66d1ab 100644 --- a/packages/coding-agent/test/evolution-runtime.test.ts +++ b/packages/coding-agent/test/evolution-runtime.test.ts @@ -15,12 +15,15 @@ import { CodingAgentEvolutionModelExecutor, type CodingAgentEvolutionRuntime, createCodingAgentEvolutionRuntime, + createEvolutionSkillSnapshots, EvolutionRuntimeCoordinator, resolveEvolutionDirectoryIdentity, TraceEvidenceCatalog, } from "../src/core/evolution-runtime.ts"; import type { InvocationTrace } from "../src/core/invocation-trace.ts"; import type { ModelRuntime } from "../src/core/model-runtime.ts"; +import { createSkill } from "../src/core/skills.ts"; +import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; import { createHarness, type Harness } from "./suite/harness.ts"; function materializedInvocation(traceId: string, sessionPath: string): MaterializedInvocation { @@ -95,6 +98,38 @@ describe("coding-agent evolution runtime", () => { expect(renamed.rootPath).toBe(realpathSync(renamedPath)); }); + it("captures compact skill metadata with its immutable catalog content", () => { + const filePath = "/workspace/.diffex/skills/review/SKILL.md"; + const skill = createSkill({ + name: "review", + description: "Review changes", + filePath, + baseDir: "/workspace/.diffex/skills/review", + sourceInfo: createSyntheticSourceInfo(filePath, { + source: "local", + scope: "project", + }), + content: "Review the frozen revision.", + }); + + expect(createEvolutionSkillSnapshots([skill])).toEqual([ + { + skillId: skill.skillId, + name: "review", + description: "Review changes", + sourceKind: "installed", + scope: "workspace", + source: "local", + version: skill.version, + contentDigest: skill.contentDigest, + location: filePath, + baseDir: skill.baseDir, + disableModelInvocation: false, + content: "Review the frozen revision.", + }, + ]); + }); + it("shares one global control scope across working directories", async () => { const agentDir = join(tmpdir(), `diffex-evolution-runtime-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(agentDir, { recursive: true }); diff --git a/packages/coding-agent/test/export-html-skill-block.test.ts b/packages/coding-agent/test/export-html-skill-block.test.ts index ae89decf5..1f683f91d 100644 --- a/packages/coding-agent/test/export-html-skill-block.test.ts +++ b/packages/coding-agent/test/export-html-skill-block.test.ts @@ -9,8 +9,8 @@ describe("export HTML skill block rendering", () => { // \n...\n\n\nactual prompt // The export renderer must detect that wrapper and render only the user-visible prompt, // not the Pi-generated ... XML tags. - expect(templateJs).toMatch(/parseSkillBlock/); - expect(templateJs).toMatch(/skillBlock\.userMessage/); + expect(templateJs).toMatch(/parseSkillInvocation/); + expect(templateJs).toMatch(/skillInvocation\.userMessage/); }); it("renders skill invocation and user message as separate sibling blocks", () => { @@ -26,6 +26,10 @@ describe("export HTML skill block rendering", () => { expect(templateJs).toMatch(/hasUserContent/); }); + it("renders each selected skill as a separate invocation", () => { + expect(templateJs).toMatch(/for \(const skillBlock of skillInvocation\.skills\)/); + }); + it("renders skill content as markdown, not raw text", () => { // The skill block body is markdown (from the SKILL.md file). // It should be rendered through safeMarkedParse, not escaped as raw text. diff --git a/packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md b/packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md index a8ec9d35d..28e6c83cf 100644 --- a/packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md +++ b/packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md @@ -6,4 +6,4 @@ disable-model-invocation: true # Manual Only Skill -This skill can only be invoked via /skill:disable-model-invocation. +This skill can only be referenced explicitly through /skills. diff --git a/packages/coding-agent/test/interactive-mode-skills.test.ts b/packages/coding-agent/test/interactive-mode-skills.test.ts new file mode 100644 index 000000000..dc95a2f5d --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-skills.test.ts @@ -0,0 +1,127 @@ +import { setKeybindings } from "@earendil-works/diffex-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import type { SkillReference } from "../src/core/skills.ts"; +import type { SkillSelectorComponent } from "../src/modes/interactive/components/skill-selector.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +const references: SkillReference[] = [ + { + skillId: `skill:${"a".repeat(64)}`, + version: `sha256:${"1".repeat(64)}`, + contentDigest: `sha256:${"1".repeat(64)}`, + name: "duplicate", + description: "General workflow", + sourceKind: "installed", + scope: "workspace", + reference: "", + group: "installed-workspace", + }, + { + skillId: `skill:${"b".repeat(64)}`, + version: `sha256:${"2".repeat(64)}`, + contentDigest: `sha256:${"2".repeat(64)}`, + name: "duplicate", + description: "Special workflow", + sourceKind: "installed", + scope: "global", + reference: "", + group: "installed-global", + }, +]; + +interface SkillsModeFixture { + session: { createSkillReferences: ReturnType }; + editor: { + getExpandedText: ReturnType; + getText: ReturnType; + setText: ReturnType; + setTextWithTokens: ReturnType; + }; + ui: { requestRender: ReturnType }; + showSelector: ( + create: (done: () => void) => { component: SkillSelectorComponent; focus: SkillSelectorComponent }, + ) => void; + showStatus: ReturnType; +} + +const showSkillsSelector = Reflect.get(InteractiveMode.prototype, "showSkillsSelector") as ( + this: SkillsModeFixture, +) => void; + +function createFixture(available: SkillReference[] = references): { + fixture: SkillsModeFixture; + getSelector: () => SkillSelectorComponent; + done: ReturnType; +} { + let selector: SkillSelectorComponent | undefined; + const done = vi.fn(); + const fixture: SkillsModeFixture = { + session: { createSkillReferences: vi.fn(() => available) }, + editor: { + getExpandedText: vi.fn(() => "existing draft"), + getText: vi.fn(() => "existing draft"), + setText: vi.fn(), + setTextWithTokens: vi.fn(), + }, + ui: { requestRender: vi.fn() }, + showSelector: (create) => { + selector = create(done).component; + }, + showStatus: vi.fn(), + }; + return { + fixture, + getSelector: () => { + if (!selector) throw new Error("Skill selector was not shown"); + return selector; + }, + done, + }; +} + +describe("InteractiveMode skill selector", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("inserts the selected stable reference before the existing draft", () => { + const { fixture, getSelector, done } = createFixture(); + showSkillsSelector.call(fixture); + + getSelector().handleInput("\u001b[B"); + getSelector().handleInput("\r"); + getSelector().handleInput("Special"); + getSelector().handleInput("\r"); + + expect(done).toHaveBeenCalledOnce(); + expect(fixture.editor.setTextWithTokens).toHaveBeenCalledWith([ + { value: "", displayText: "duplicate" }, + "\n\n", + "existing draft", + ]); + }); + + it("cancels without changing the editor", () => { + const { fixture, getSelector, done } = createFixture(); + showSkillsSelector.call(fixture); + + getSelector().handleInput("\u001b"); + + expect(done).toHaveBeenCalledOnce(); + expect(fixture.editor.setText).not.toHaveBeenCalled(); + expect(fixture.editor.setTextWithTokens).not.toHaveBeenCalled(); + }); + + it("reports when no skills are available", () => { + const { fixture } = createFixture([]); + showSkillsSelector.call(fixture); + + expect(fixture.showStatus).toHaveBeenCalledWith("No skills available"); + }); +}); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 6fe13ba41..3cc7da09f 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -104,6 +104,53 @@ describe("package commands", () => { rmSync(tempDir, { recursive: true, force: true }); }); + it("asks interactive installs to choose global scope", async () => { + const selectInstallScope = vi.fn(async () => "global" as const); + + expect(await handlePackageCommand(["install", packageDir], { selectInstallScope })).toBe(true); + + expect(selectInstallScope).toHaveBeenCalledWith({ source: packageDir, cwd: realpathSync(projectDir), agentDir }); + const settings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")) as { + packages?: string[]; + }; + expect(settings.packages).toHaveLength(1); + expect(existsSync(join(projectDir, ".diffex", "settings.json"))).toBe(false); + }); + + it("installs into the selected workspace without falling back globally", async () => { + const selectInstallScope = vi.fn(async () => "workspace" as const); + + expect(await handlePackageCommand(["install", packageDir, "--approve"], { selectInstallScope })).toBe(true); + + expect(selectInstallScope).toHaveBeenCalledOnce(); + const settings = JSON.parse(readFileSync(join(projectDir, ".diffex", "settings.json"), "utf-8")) as { + packages?: string[]; + }; + expect(settings.packages).toHaveLength(1); + expect(existsSync(join(agentDir, "settings.json"))).toBe(false); + }); + + it("cancels an interactive install without writing either scope", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const selectInstallScope = vi.fn(async () => undefined); + + expect(await handlePackageCommand(["install", packageDir], { selectInstallScope })).toBe(true); + + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("Installation cancelled."); + expect(existsSync(join(agentDir, "settings.json"))).toBe(false); + expect(existsSync(join(projectDir, ".diffex", "settings.json"))).toBe(false); + }); + + it("does not ask for scope when the workspace flag is explicit", async () => { + const selectInstallScope = vi.fn(async () => "global" as const); + + expect(await handlePackageCommand(["install", "-l", "--approve", packageDir], { selectInstallScope })).toBe(true); + + expect(selectInstallScope).not.toHaveBeenCalled(); + expect(existsSync(join(projectDir, ".diffex", "settings.json"))).toBe(true); + expect(existsSync(join(agentDir, "settings.json"))).toBe(false); + }); + it("should persist global relative local package paths relative to settings.json", async () => { const relativePkgDir = join(projectDir, "packages", "local-package"); mkdirSync(relativePkgDir, { recursive: true }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 02728799b..c74d62c15 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -8,7 +8,7 @@ import { ExtensionRunner } from "../src/core/extensions/runner.ts"; import { DefaultResourceLoader, loadProjectContextFiles } from "../src/core/resource-loader.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; -import type { Skill } from "../src/core/skills.ts"; +import { createSkill } from "../src/core/skills.ts"; import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; import { createModelRegistry } from "./model-runtime-test-utils.ts"; @@ -56,7 +56,15 @@ Skill content here.`, await loader.reload(); const { skills } = loader.getSkills(); - expect(skills.some((s) => s.name === "test-skill")).toBe(true); + const skill = skills.find((candidate) => candidate.name === "test-skill"); + expect(skill).toMatchObject({ + sourceKind: "installed", + scope: "global", + version: expect.stringMatching(/^sha256:/), + contentDigest: expect.stringMatching(/^sha256:/), + }); + expect(skill?.skillId).toMatch(/^skill:/); + expect(skill?.content).toContain("Skill content here."); }); it("should ignore extra markdown files in auto-discovered skill dirs", async () => { @@ -335,12 +343,17 @@ Content`, await loader.reload(); const { extensions } = loader.getExtensions(); - const { skills } = loader.getSkills(); + const { skills, diagnostics: skillDiagnostics } = loader.getSkills(); const { prompts } = loader.getPrompts(); const { themes } = loader.getThemes(); expect(extensions.some((e) => e.path.endsWith("disabled.ts"))).toBe(false); expect(skills.some((s) => s.name === "skip-skill")).toBe(false); + expect(skillDiagnostics).toContainEqual({ + type: "warning", + message: "skill disabled by configuration", + path: join(skillDir, "SKILL.md"), + }); expect(prompts.some((p) => p.name === "skip")).toBe(false); expect(themes.some((t) => t.sourcePath?.endsWith("skip.json"))).toBe(false); }); @@ -856,14 +869,14 @@ Content`, describe("override functions", () => { it("should apply skillsOverride", async () => { - const injectedSkill: Skill = { + const injectedSkill = createSkill({ name: "injected", description: "Injected skill", filePath: "/fake/path", baseDir: "/fake", sourceInfo: createSyntheticSourceInfo("/fake/path", { source: "custom" }), - disableModelInvocation: false, - }; + content: "# Injected skill", + }); const loader = new DefaultResourceLoader({ cwd, agentDir, diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index 007a51404..94839cb94 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -6,6 +6,7 @@ import { createExtensionRuntime } from "../src/core/extensions/loader.ts"; import type { ResourceLoader } from "../src/core/resource-loader.ts"; import { createAgentSession } from "../src/core/sdk.ts"; import { SessionManager } from "../src/core/session-manager.ts"; +import { createSkill } from "../src/core/skills.ts"; import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; describe("createAgentSession skills option", () => { @@ -77,14 +78,14 @@ This is a test skill. }); it("should use provided skills when resource loader supplies them", async () => { - const customSkill = { + const customSkill = createSkill({ name: "custom-skill", description: "A custom skill", filePath: "/fake/path/SKILL.md", baseDir: "/fake/path", sourceInfo: createSyntheticSourceInfo("/fake/path/SKILL.md", { source: "sdk" }), - disableModelInvocation: false, - }; + content: "# Custom Skill", + }); const resourceLoader: ResourceLoader = { getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }), diff --git a/packages/coding-agent/test/server/create-harness.test.ts b/packages/coding-agent/test/server/create-harness.test.ts index 4e37ceab4..fb498de2a 100644 --- a/packages/coding-agent/test/server/create-harness.test.ts +++ b/packages/coding-agent/test/server/create-harness.test.ts @@ -13,6 +13,7 @@ import { createModels } from "@earendil-works/diffex-ai"; import { getModel } from "@earendil-works/diffex-ai/compat"; import { Type } from "typebox"; import { describe, expect, test, vi } from "vitest"; +import { createSkill } from "../../src/core/skills.ts"; import { buildCodingAgentHarnessSystemPrompt, type CodingAgentHarnessTool, @@ -322,7 +323,7 @@ describe("coding-agent Harness construction", () => { systemPromptOptions: { contextFiles: [{ path: "/workspace/AGENTS.md", content: "Follow project policy." }], skills: [ - { + createSkill({ name: "review", description: "Review server changes", filePath: "/skills/review/SKILL.md", @@ -333,8 +334,8 @@ describe("coding-agent Harness construction", () => { scope: "temporary", origin: "top-level", }, - disableModelInvocation: false, - }, + content: "# Review server changes", + }), ], }, }); diff --git a/packages/coding-agent/test/skill-invocation-parsing.test.ts b/packages/coding-agent/test/skill-invocation-parsing.test.ts new file mode 100644 index 000000000..1569e1675 --- /dev/null +++ b/packages/coding-agent/test/skill-invocation-parsing.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillBlock, parseSkillInvocation } from "../src/core/agent-session.ts"; + +describe("skill invocation parsing", () => { + it("parses multiple versioned skill blocks and one trailing user request", () => { + const text = [ + '', + "First instructions.", + "", + "", + '', + "Second instructions.", + "", + "", + "Complete the task.", + ].join("\n"); + + const invocation = parseSkillInvocation(text); + + expect(invocation?.skills.map((skill) => skill.name)).toEqual(["first", "second"]); + expect(invocation?.skills.map((skill) => skill.content)).toEqual(["First instructions.", "Second instructions."]); + expect(invocation?.userMessage).toBe("Complete the task."); + expect(parseSkillBlock(text)).toBeNull(); + }); + + it("preserves the legacy single-skill parser", () => { + const text = '\nLegacy instructions.\n\n\nDo it.'; + + expect(parseSkillBlock(text)).toEqual({ + name: "legacy", + location: "/legacy/SKILL.md", + content: "Legacy instructions.", + userMessage: "Do it.", + }); + }); +}); diff --git a/packages/coding-agent/test/skill-selector.test.ts b/packages/coding-agent/test/skill-selector.test.ts new file mode 100644 index 000000000..a1ce4f7cf --- /dev/null +++ b/packages/coding-agent/test/skill-selector.test.ts @@ -0,0 +1,177 @@ +import { setKeybindings, visibleWidth } from "@earendil-works/diffex-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import type { SkillReference } from "../src/core/skills.ts"; +import { SkillSelectorComponent } from "../src/modes/interactive/components/skill-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +const references: SkillReference[] = [ + { + skillId: `skill:${"a".repeat(64)}`, + version: `sha256:${"1".repeat(64)}`, + contentDigest: `sha256:${"1".repeat(64)}`, + name: "workflow-evolved", + description: "General workflow", + sourceKind: "evolved", + scope: "workspace", + reference: "", + group: "evolved-workspace", + }, + { + skillId: `skill:${"b".repeat(64)}`, + version: `sha256:${"2".repeat(64)}`, + contentDigest: `sha256:${"2".repeat(64)}`, + name: "deploy", + description: "Special deployment workflow", + sourceKind: "installed", + scope: "workspace", + reference: "", + group: "installed-workspace", + }, + { + skillId: `skill:${"c".repeat(64)}`, + version: `sha256:${"3".repeat(64)}`, + contentDigest: `sha256:${"3".repeat(64)}`, + name: "review", + description: "Review code changes", + sourceKind: "installed", + scope: "global", + reference: "", + group: "installed-global", + }, +]; + +describe("SkillSelectorComponent", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("opens with skill source categories instead of individual skill commands", () => { + const selector = new SkillSelectorComponent( + references, + () => {}, + () => {}, + ); + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Installed Workspace Skills"); + expect(output).toContain("Installed Global Skills"); + expect(output).toContain("Evolved Skills"); + expect(output).not.toContain("Special deployment workflow"); + expect(output).not.toContain("Review code changes"); + }); + + it("opens a category and filters its skills by name or description", () => { + const selector = new SkillSelectorComponent( + references, + () => {}, + () => {}, + ); + + selector.handleInput("\r"); + selector.handleInput("deployment"); + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Installed Workspace Skills"); + expect(output).toContain("deploy"); + expect(output).toContain("Special deployment workflow"); + expect(output).not.toContain("Review code changes"); + }); + + it("returns to the category menu before cancelling", () => { + const onCancel = vi.fn(); + const selector = new SkillSelectorComponent(references, () => {}, onCancel); + + selector.handleInput("\r"); + selector.handleInput("\u001b"); + expect(stripAnsi(selector.render(120).join("\n"))).toContain("Choose where the skill is installed"); + expect(onCancel).not.toHaveBeenCalled(); + + selector.handleInput("\u001b"); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("keeps selections across categories and returns stable category order", () => { + const onSelect = vi.fn(); + const selector = new SkillSelectorComponent(references, onSelect, () => {}); + + selector.handleInput("\r"); + selector.handleInput(" "); + selector.handleInput("\u001b"); + selector.handleInput("\u001b[B"); + selector.handleInput("\r"); + selector.handleInput(" "); + selector.handleInput("\r"); + + expect(onSelect).toHaveBeenCalledWith([references[1], references[2]]); + }); + + it("inserts the current skill when no items were toggled", () => { + const onSelect = vi.fn(); + const selector = new SkillSelectorComponent(references, onSelect, () => {}); + + selector.handleInput("\u001b[B"); + selector.handleInput("\r"); + selector.handleInput("review"); + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith([references[2]]); + }); + + it("propagates focus to search only inside a category", () => { + const selector = new SkillSelectorComponent( + references, + () => {}, + () => {}, + ); + + selector.focused = true; + expect(selector.getSearchInput().focused).toBe(false); + selector.handleInput("\r"); + expect(selector.getSearchInput().focused).toBe(true); + }); + + it("fits category and skill views in a standard terminal", () => { + const manyReferences = Array.from( + { length: 12 }, + (_, index): SkillReference => ({ + skillId: `skill:${String(index).padStart(64, "0")}`, + version: `sha256:${String(index).padStart(64, "1")}`, + contentDigest: `sha256:${String(index).padStart(64, "1")}`, + name: `skill-${index}`, + description: `Description for skill ${index}`, + sourceKind: "installed", + scope: "workspace", + reference: ``, + group: "installed-workspace", + }), + ); + const selector = new SkillSelectorComponent( + manyReferences, + () => {}, + () => {}, + ); + + expect(selector.render(80).every((line) => visibleWidth(line) <= 80)).toBe(true); + selector.handleInput("\r"); + const lines = selector.render(80); + expect(lines.length).toBeLessThanOrEqual(24); + expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true); + }); + + it("renders an empty search result without applying", () => { + const onSelect = vi.fn(); + const selector = new SkillSelectorComponent(references, onSelect, () => {}); + + selector.handleInput("\r"); + selector.handleInput("missing"); + expect(stripAnsi(selector.render(100).join("\n"))).toContain("No matching skills"); + selector.handleInput("\r"); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 40318a3eb..c43942f84 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -2,7 +2,14 @@ import { homedir } from "os"; import { join, resolve } from "path"; import { describe, expect, it } from "vitest"; import type { ResourceDiagnostic } from "../src/core/diagnostics.ts"; -import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills.ts"; +import { + createSkill, + createSkillReferenceSnapshot, + formatSkillsForPrompt, + loadSkills, + loadSkillsFromDir, + type Skill, +} from "../src/core/skills.ts"; import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; const fixturesDir = resolve(__dirname, "fixtures/skills"); @@ -16,14 +23,15 @@ function createTestSkill(options: { disableModelInvocation?: boolean; source?: string; }): Skill { - return { + return createSkill({ name: options.name, description: options.description, filePath: options.filePath, baseDir: options.baseDir, sourceInfo: createSyntheticSourceInfo(options.filePath, { source: options.source ?? "test" }), - disableModelInvocation: options.disableModelInvocation ?? false, - }; + content: `# ${options.name}`, + disableModelInvocation: options.disableModelInvocation, + }); } describe("skills", () => { @@ -221,6 +229,73 @@ describe("skills", () => { }); }); + describe("skill references", () => { + it.each([ + ["project", "installed-workspace"], + ["user", "installed-global"], + ["temporary", "installed-global"], + ] as const)("groups %s skills as %s", (scope, expectedGroup) => { + const { skills } = loadSkillsFromDir({ + dir: join(fixturesDir, "valid-skill"), + source: "test", + }); + const loaded = skills[0]; + if (!loaded) throw new Error("Valid skill fixture was not loaded"); + const skill = createSkill({ + name: loaded.name, + description: loaded.description, + filePath: loaded.filePath, + baseDir: loaded.baseDir, + sourceInfo: createSyntheticSourceInfo(loaded.filePath, { source: "local", scope }), + content: loaded.content, + }); + + expect(createSkillReferenceSnapshot(skill).group).toBe(expectedGroup); + }); + + it("keeps source identity stable while content versions change", () => { + const base = { + name: "stable-skill", + description: "Stable identity", + filePath: "/workspace/skills/stable/SKILL.md", + baseDir: "/workspace/skills/stable", + sourceInfo: createSyntheticSourceInfo("/workspace/skills/stable/SKILL.md", { + source: "local", + scope: "project", + }), + }; + const first = createSkill({ ...base, content: "First revision" }); + const second = createSkill({ ...base, content: "Second revision" }); + + expect(second.skillId).toBe(first.skillId); + expect(second.version).not.toBe(first.version); + expect(first.version).toBe(first.contentDigest); + }); + + it("creates evolved workspace references from catalog metadata", () => { + const skill = createSkill({ + name: "evolved-skill", + description: "Evolved instructions", + filePath: "/harness/skills/evolved-skill/SKILL.md", + baseDir: "/harness/skills/evolved-skill", + sourceInfo: createSyntheticSourceInfo("/harness/skills/evolved-skill/SKILL.md", { + source: "harness", + scope: "project", + }), + content: "Evolved content", + sourceKind: "evolved", + harnessRevision: "sha256:harness", + }); + + expect(createSkillReferenceSnapshot(skill)).toMatchObject({ + group: "evolved-workspace", + sourceKind: "evolved", + scope: "workspace", + harnessRevision: "sha256:harness", + }); + }); + }); + describe("formatSkillsForPrompt", () => { it("should return empty string for no skills", () => { const result = formatSkillsForPrompt([]); diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts index bdc5c90fb..e51f00984 100644 --- a/packages/coding-agent/test/slash-commands.test.ts +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -25,6 +25,13 @@ describe("BUILTIN_SLASH_COMMANDS", () => { }); }); + it("includes the skill reference selector command", () => { + expect(BUILTIN_SLASH_COMMANDS).toContainEqual({ + name: "skills", + description: "Reference skills in the editor", + }); + }); + it("includes the evolution menu command", () => { expect(BUILTIN_SLASH_COMMANDS).toContainEqual({ name: "evolve", diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index 45f3a511a..ff40b812b 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -1,12 +1,13 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { AgentTool } from "@earendil-works/diffex-agent-core"; +import { type AgentTool, InMemoryTelemetryContext } from "@earendil-works/diffex-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/diffex-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import type { InputEvent } from "../../src/core/extensions/index.ts"; import type { PromptTemplate } from "../../src/core/prompt-templates.ts"; +import { createSkill } from "../../src/core/skills.ts"; import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; import { createTestResourceLoader } from "../utilities.ts"; import { createHarness, getMessageText, type Harness } from "./harness.ts"; @@ -144,51 +145,110 @@ describe("AgentSession prompt characterization", () => { expect(sawImage).toBe(true); }); - it("expands skill commands before sending the prompt", async () => { - const tempDir = join(tmpdir(), `pi-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`); + it("does not expand individual skill commands", async () => { + const harness = await createHarness(); + harnesses.push(harness); + let admittedPrompt = ""; + harness.setResponses([ + (context) => { + const user = context.messages.find((message) => message.role === "user"); + admittedPrompt = user ? getMessageText(user) : ""; + return fauxAssistantMessage("ok"); + }, + ]); + + await harness.session.prompt("/skill:test explain this"); + + expect(admittedPrompt).toBe("/skill:test explain this"); + }); + + it("loads exactly the explicitly referenced skill snapshot and traces its identity", async () => { + const tempDir = join(tmpdir(), `diffex-skill-reference-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tempDir, { recursive: true }); tempDirs.push(tempDir); - const skillPath = join(tempDir, "test-skill.md"); - writeFileSync(skillPath, "# Test Skill\n\nUse the skill body."); - + const selectedPath = join(tempDir, "selected.md"); + const unselectedPath = join(tempDir, "unselected.md"); + writeFileSync( + selectedPath, + "---\nname: selected\ndescription: Selected skill\n---\n\n# Selected\n\nOriginal instructions.", + ); + writeFileSync( + unselectedPath, + "---\nname: unselected\ndescription: Unselected skill\n---\n\n# Unselected\n\nPrivate instructions.", + ); + const sourceInfo = (filePath: string) => + createSyntheticSourceInfo(filePath, { + source: "local", + scope: "project", + origin: "top-level", + baseDir: tempDir, + }); const resourceLoader = { ...createTestResourceLoader(), getSkills: () => ({ skills: [ - { - name: "test", - description: "Test skill", - filePath: skillPath, - disableModelInvocation: false, + createSkill({ + name: "selected", + description: "Selected skill", + filePath: selectedPath, + baseDir: tempDir, + sourceInfo: sourceInfo(selectedPath), + content: + "---\nname: selected\ndescription: Selected skill\n---\n\n# Selected\n\nOriginal instructions.", + disableModelInvocation: true, + }), + createSkill({ + name: "unselected", + description: "Unselected skill", + filePath: unselectedPath, baseDir: tempDir, - sourceInfo: createSyntheticSourceInfo(skillPath, { - source: "local", - scope: "project", - origin: "top-level", - baseDir: tempDir, - }), - }, + sourceInfo: sourceInfo(unselectedPath), + content: + "---\nname: unselected\ndescription: Unselected skill\n---\n\n# Unselected\n\nPrivate instructions.", + }), ], diagnostics: [], }), }; - const harness = await createHarness({ resourceLoader }); + const telemetry = new InMemoryTelemetryContext(); + const harness = await createHarness({ resourceLoader, telemetryContext: telemetry }); harnesses.push(harness); - let expandedPrompt = ""; + const references = harness.session.createSkillReferences(); + const selected = references.find((reference) => reference.name === "selected"); + if (!selected) throw new Error("Selected skill reference was not created"); + expect(selected.group).toBe("installed-workspace"); + writeFileSync(selectedPath, "Changed after selection."); + let admittedPrompt = ""; + let admittedSystemPrompt = ""; harness.setResponses([ (context) => { const user = context.messages.find((message) => message.role === "user"); - expandedPrompt = user ? getMessageText(user) : ""; + admittedPrompt = user ? getMessageText(user) : ""; + admittedSystemPrompt = context.systemPrompt ?? ""; return fauxAssistantMessage("ok"); }, ]); - await harness.session.prompt("/skill:test explain this"); - - expect(expandedPrompt).toContain('selected
"); + expect(admittedSystemPrompt).toContain("unselected"); + const promptOperation = telemetry + .getSpans() + .find( + (span) => + span.name === "diffex.coding_agent.operation" && span.attributes["diffex.operation.kind"] === "prompt", + ); + expect(promptOperation?.attributes["diffex.skill.references"]).toEqual([ + `${selected.skillId}@${selected.version}`, + ]); }); it("expands prompt templates before sending the prompt", async () => { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index c490d4b05..181ce31b2 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1,8 +1,14 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AgentTool } from "@earendil-works/diffex-agent-core"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/diffex-ai"; -import type { ExtensionAPI } from "@earendil-works/diffex-coding-agent"; +import type { ExtensionAPI, ResourceLoader } from "@earendil-works/diffex-coding-agent"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; +import { createSkill } from "../../src/core/skills.ts"; +import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; +import { createTestResourceLoader } from "../utilities.ts"; import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.ts"; async function createWaitingHarness( @@ -11,6 +17,7 @@ async function createWaitingHarness( extensionFactories?: Harness["session"]["extensionRunner"] extends never ? never : Array<(pi: ExtensionAPI) => void>; + resourceLoader?: ResourceLoader; } = {}, ): Promise<{ harness: Harness; @@ -38,6 +45,7 @@ async function createWaitingHarness( const harness = await createHarness({ tools: [waitTool, ...(options.tools ?? [])], extensionFactories: options.extensionFactories, + resourceLoader: options.resourceLoader, }); const waitForToolStart = new Promise((resolve) => { @@ -59,11 +67,16 @@ async function createWaitingHarness( describe("AgentSession queue characterization", () => { const harnesses: Harness[] = []; + const tempDirs: string[] = []; afterEach(async () => { while (harnesses.length > 0) { await harnesses.pop()?.cleanup(); } + while (tempDirs.length > 0) { + const tempDir = tempDirs.pop(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + } }); it("dispatches extension commands immediately when prompted while idle", async () => { @@ -258,6 +271,60 @@ describe("AgentSession queue characterization", () => { expect(getAssistantTexts(harness)).toEqual(["", "original turn complete", "batched follow-up response"]); }); + it("pins explicit skill content when a follow-up is admitted", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "diffex-queued-skill-")); + tempDirs.push(tempDir); + const skillPath = join(tempDir, "SKILL.md"); + writeFileSync(skillPath, "---\nname: queued-skill\ndescription: Queue test\n---\n\nOriginal procedure."); + const resourceLoader = { + ...createTestResourceLoader(), + getSkills: () => ({ + skills: [ + createSkill({ + name: "queued-skill", + description: "Queue test", + filePath: skillPath, + baseDir: tempDir, + sourceInfo: createSyntheticSourceInfo(skillPath, { + source: "local", + scope: "project", + origin: "top-level", + baseDir: tempDir, + }), + content: "---\nname: queued-skill\ndescription: Queue test\n---\n\nOriginal procedure.", + }), + ], + diagnostics: [], + }), + }; + const waiting = await createWaitingHarness({ resourceLoader }); + const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; + harnesses.push(harness); + const [reference] = harness.session.createSkillReferences(); + if (!reference) throw new Error("Skill reference was not created"); + let admittedFollowUp = ""; + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("original turn complete"), + (context) => { + const users = context.messages.filter((message) => message.role === "user"); + admittedFollowUp = getMessageText(users[users.length - 1]); + return fauxAssistantMessage("follow-up complete"); + }, + ]); + + await waitForToolStart; + await harness.session.followUp(`${reference.reference}\n\nRun the queued task.`); + writeFileSync(skillPath, "Changed after queue admission."); + releaseToolExecution(); + await promptPromise; + + expect(admittedFollowUp).toContain("Original procedure."); + expect(admittedFollowUp).not.toContain("Changed after queue admission."); + expect(admittedFollowUp).toContain(`version="${reference.version}"`); + expect(admittedFollowUp).toContain("Run the queued task."); + }); + it("queues custom messages with deliverAs steer while streaming", async () => { const waiting = await createWaitingHarness(); const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; diff --git a/packages/evolution/src/contracts.ts b/packages/evolution/src/contracts.ts index 04f2989f3..f49bb1506 100644 --- a/packages/evolution/src/contracts.ts +++ b/packages/evolution/src/contracts.ts @@ -147,6 +147,53 @@ export interface EvolutionPromptVersions { readonly writer: string; } +export type EvolutionSkillSourceKind = "evolved" | "installed"; + +export type EvolutionSkillScope = "global" | "workspace"; + +export interface EvolutionSkillWorkspaceIdentity { + readonly repositoryId: string; + readonly directoryId?: string; +} + +export interface EvolutionSkillContentSnapshot { + readonly skillId: string; + readonly name: string; + readonly description: string; + readonly sourceKind: EvolutionSkillSourceKind; + readonly scope: EvolutionSkillScope; + readonly source: string; + readonly version: string; + readonly contentDigest: string; + readonly harnessRevision?: string; + readonly workspace: EvolutionSkillWorkspaceIdentity | null; + readonly location: string; + readonly baseDir: string; + readonly disableModelInvocation: boolean; + readonly content: string; +} + +export interface EvolutionSkillContentArtifact { + readonly artifactKind: "skill-content"; + readonly metadata: EvolutionArtifactMetadata; + readonly skill: EvolutionSkillContentSnapshot; +} + +export interface EvolutionSkillCatalogEntry { + readonly skillId: string; + readonly name: string; + readonly description: string; + readonly sourceKind: EvolutionSkillSourceKind; + readonly scope: EvolutionSkillScope; + readonly source: string; + readonly version: string; + readonly contentDigest: string; + readonly harnessRevision?: string; + readonly workspace: EvolutionSkillWorkspaceIdentity | null; + readonly disableModelInvocation: boolean; + readonly contentArtifact: EvolutionArtifactReference; +} + export interface EvolutionInputSnapshot { readonly metadata: EvolutionArtifactMetadata; readonly trigger: EvolutionTriggerKind; @@ -155,6 +202,7 @@ export interface EvolutionInputSnapshot { readonly expectedHarnessBaseVersion: HarnessVersion; readonly ingestionHighWaterCursor: number; readonly instructions: readonly InstructionDocumentSnapshot[]; + readonly skillCatalog: readonly EvolutionSkillCatalogEntry[]; readonly modelConfiguration: EvolutionModelIdentity; readonly pipelineVersion: string; readonly promptVersions: EvolutionPromptVersions; diff --git a/packages/evolution/src/control-plane.ts b/packages/evolution/src/control-plane.ts index ddd70860f..a8e1df990 100644 --- a/packages/evolution/src/control-plane.ts +++ b/packages/evolution/src/control-plane.ts @@ -12,6 +12,9 @@ import type { EvolutionJobStage, EvolutionLeaseToken, EvolutionScope, + EvolutionSkillCatalogEntry, + EvolutionSkillContentArtifact, + EvolutionSkillContentSnapshot, EvolutionStageCheckpoint, JobCompletedLedgerEvent, JobCreatedLedgerEvent, @@ -39,7 +42,11 @@ import type { EvolutionStageDirective, EvolutionStageExecutor, } from "./ports.ts"; -import { computeEvolutionContentDigest, createEvolutionArtifactMetadata } from "./provenance.ts"; +import { + computeEvolutionContentDigest, + computeEvolutionTextDigest, + createEvolutionArtifactMetadata, +} from "./provenance.ts"; import { type AutomaticEvolutionEligibilityReason, EVOLUTION_TOKEN_ESTIMATOR_VERSION, @@ -554,6 +561,85 @@ export class EvolutionControlPlane implements EvolutionJobRunner { } const createdAt = this.#dependencies.clock.now(); + const skillCatalog: EvolutionSkillCatalogEntry[] = []; + const skillArtifactIds: string[] = []; + const seenSkillVersions = new Set(); + for (const source of frozen.skills) { + const identityKey = `${source.skillId}\0${source.version}`; + if ( + source.skillId.length === 0 || + source.name.length === 0 || + source.description.length === 0 || + source.source.length === 0 || + source.version.length === 0 || + source.location.length === 0 || + source.baseDir.length === 0 || + (source.sourceKind !== "evolved" && source.sourceKind !== "installed") || + (source.scope !== "global" && source.scope !== "workspace") || + (source.sourceKind === "evolved" && source.scope !== "workspace") || + seenSkillVersions.has(identityKey) || + computeEvolutionTextDigest(source.content) !== source.contentDigest + ) { + throw new EvolutionExecutionError(`Frozen skill catalog entry is invalid for "${source.name}"`, { + reasonCode: "snapshot-invalid", + retryable: false, + }); + } + seenSkillVersions.add(identityKey); + const workspace = + source.scope === "workspace" + ? { + repositoryId: frozen.repository.repositoryId, + ...(frozen.repository.directoryId === undefined + ? {} + : { directoryId: frozen.repository.directoryId }), + } + : null; + const skill: EvolutionSkillContentSnapshot = { + skillId: source.skillId, + name: source.name, + description: source.description, + sourceKind: source.sourceKind, + scope: source.scope, + source: source.source, + version: source.version, + contentDigest: source.contentDigest, + ...(source.harnessRevision === undefined ? {} : { harnessRevision: source.harnessRevision }), + workspace, + location: source.location, + baseDir: source.baseDir, + disableModelInvocation: source.disableModelInvocation, + content: source.content, + }; + const artifactId = stableId("skill-content", { + skillId: source.skillId, + version: source.version, + contentDigest: source.contentDigest, + createdAt, + }); + const artifactContent = { artifactKind: "skill-content" as const, skill }; + const artifact: EvolutionSkillContentArtifact = { + metadata: this.#metadata(artifactId, [], request.scope, createdAt, artifactContent), + ...artifactContent, + }; + const contentArtifact = await this.#dependencies.artifacts.put(artifact); + skillArtifactIds.push(artifactId); + skillCatalog.push({ + skillId: skill.skillId, + name: skill.name, + description: skill.description, + sourceKind: skill.sourceKind, + scope: skill.scope, + source: skill.source, + version: skill.version, + contentDigest: skill.contentDigest, + ...(skill.harnessRevision === undefined ? {} : { harnessRevision: skill.harnessRevision }), + workspace, + disableModelInvocation: skill.disableModelInvocation, + contentArtifact, + }); + } + const snapshotId = this.#dependencies.ids.nextId("input-snapshot"); const snapshotContent = { trigger: request.trigger, @@ -562,6 +648,7 @@ export class EvolutionControlPlane implements EvolutionJobRunner { expectedHarnessBaseVersion: frozen.harness.harnessVersion, ingestionHighWaterCursor, instructions: frozen.instructions, + skillCatalog, modelConfiguration: frozen.modelConfiguration, pipelineVersion: frozen.pipelineVersion, promptVersions: frozen.promptVersions, @@ -581,7 +668,7 @@ export class EvolutionControlPlane implements EvolutionJobRunner { const snapshot: EvolutionInputSnapshot = { metadata: this.#metadata( snapshotId, - materializations.map((item) => item.materializationId), + [...materializations.map((item) => item.materializationId), ...skillArtifactIds], request.scope, createdAt, snapshotContent, diff --git a/packages/evolution/src/index.ts b/packages/evolution/src/index.ts index d064f93b6..eef932a0d 100644 --- a/packages/evolution/src/index.ts +++ b/packages/evolution/src/index.ts @@ -68,6 +68,12 @@ export { type EvolutionReferenceNormalization, type EvolutionRepositorySnapshot, type EvolutionScope, + type EvolutionSkillCatalogEntry, + type EvolutionSkillContentArtifact, + type EvolutionSkillContentSnapshot, + type EvolutionSkillScope, + type EvolutionSkillSourceKind, + type EvolutionSkillWorkspaceIdentity, type EvolutionStageCheckpoint, type EvolutionTarget, type EvolutionTokenMeasurement, @@ -193,6 +199,7 @@ export type { EvolutionModelResponse, EvolutionModelTool, EvolutionProgressEvent, + EvolutionSkillSnapshotSource, EvolutionSnapshotContext, EvolutionSnapshotSource, EvolutionStageDirective, @@ -222,6 +229,7 @@ export { export { type CreateEvolutionArtifactMetadataOptions, computeEvolutionContentDigest, + computeEvolutionTextDigest, createEvolutionArtifactMetadata, serializeEvolutionValue, } from "./provenance.ts"; diff --git a/packages/evolution/src/ports.ts b/packages/evolution/src/ports.ts index c712f0b92..5913507b8 100644 --- a/packages/evolution/src/ports.ts +++ b/packages/evolution/src/ports.ts @@ -17,6 +17,8 @@ import type { EvolutionProposal, EvolutionRepositorySnapshot, EvolutionScope, + EvolutionSkillContentArtifact, + EvolutionSkillContentSnapshot, EvolutionStageCheckpoint, EvolutionTriggerKind, HarnessActivationReceipt, @@ -39,6 +41,7 @@ export type EvolutionArtifact = | EvidenceCorpusManifest | EvolutionInputSnapshot | EvolutionProposal + | EvolutionSkillContentArtifact | HarnessActivationReceipt | InstructionSnapshot | Lesson @@ -293,10 +296,13 @@ export interface EvolutionEvidenceCatalog { excludedThroughCursor?(scope: EvolutionScope, afterCursor: number, throughCursor: number): Promise; } +export type EvolutionSkillSnapshotSource = Omit; + export interface EvolutionSnapshotContext { readonly repository: EvolutionRepositorySnapshot; readonly harness: ImmutableHarnessSnapshot; readonly instructions: readonly InstructionDocumentSnapshot[]; + readonly skills: readonly EvolutionSkillSnapshotSource[]; readonly modelConfiguration: EvolutionModelIdentity; readonly pipelineVersion: string; readonly promptVersions: EvolutionPromptVersions; diff --git a/packages/evolution/src/provenance.ts b/packages/evolution/src/provenance.ts index a44bd4429..ac006bad0 100644 --- a/packages/evolution/src/provenance.ts +++ b/packages/evolution/src/provenance.ts @@ -29,6 +29,10 @@ export function computeEvolutionContentDigest(value: unknown): string { return `${CONTENT_DIGEST_PREFIX}${createHash("sha256").update(canonical).digest("hex")}`; } +export function computeEvolutionTextDigest(value: string): string { + return `${CONTENT_DIGEST_PREFIX}${createHash("sha256").update(value).digest("hex")}`; +} + export function serializeEvolutionValue(value: unknown): string { return JSON.stringify(canonicalize(value, "$")); } diff --git a/packages/evolution/test/control-plane.test.ts b/packages/evolution/test/control-plane.test.ts index cd9799a18..63bcf9c16 100644 --- a/packages/evolution/test/control-plane.test.ts +++ b/packages/evolution/test/control-plane.test.ts @@ -7,6 +7,7 @@ import { AUTOMATIC_EVOLUTION_COOLDOWN_MS, AUTOMATIC_EVOLUTION_TOKEN_THRESHOLD, computeEvolutionContentDigest, + computeEvolutionTextDigest, createEvolutionArtifactMetadata, EVOLUTION_TOKEN_ESTIMATOR_VERSION, type EvolutionArtifactProducer, @@ -19,6 +20,7 @@ import { EvolutionPipeline, type EvolutionProgressEvent, type EvolutionScope, + type EvolutionSkillSnapshotSource, type EvolutionSnapshotContext, type EvolutionStageExecutionContext, type EvolutionStageExecutionResult, @@ -112,7 +114,7 @@ function automaticMaterializations(): readonly SuccessfulMaterialization[] { }); } -function snapshotContext(): EvolutionSnapshotContext { +function snapshotContext(skills: readonly EvolutionSkillSnapshotSource[] = []): EvolutionSnapshotContext { const memory = "# Evolution memory\n"; const instruction = "Preserve exact provenance.\n"; return { @@ -132,6 +134,7 @@ function snapshotContext(): EvolutionSnapshotContext { contentDigest: computeEvolutionContentDigest(instruction), }, ], + skills, modelConfiguration: { provider: "faux", model: "fixture", @@ -214,6 +217,7 @@ async function controlPlaneFixture( materialization(3), ], highWaterCursor?: number, + capturedSnapshot: EvolutionSnapshotContext = snapshotContext(), ): Promise> { const root = await mkdtemp(join(tmpdir(), "diffex-evolution-control-")); temporaryDirectories.push(root); @@ -255,7 +259,7 @@ async function controlPlaneFixture( }, snapshots: { async capture() { - return snapshotContext(); + return capturedSnapshot; }, }, executor, @@ -473,6 +477,79 @@ describe("durable evolution control plane", () => { }); }); + it("freezes skill metadata and full content as immutable referenced artifacts", async () => { + const content = "---\nname: review\ndescription: Review changes\n---\n\nReview carefully."; + const skill: EvolutionSkillSnapshotSource = { + skillId: `skill:${"a".repeat(64)}`, + name: "review", + description: "Review changes", + sourceKind: "installed", + scope: "workspace", + source: "local", + version: computeEvolutionTextDigest(content), + contentDigest: computeEvolutionTextDigest(content), + location: "/repo/.diffex/skills/review/SKILL.md", + baseDir: "/repo/.diffex/skills/review", + disableModelInvocation: false, + content, + }; + const fixture = await controlPlaneFixture( + new RecordingExecutor(), + [materialization(1)], + undefined, + snapshotContext([skill]), + ); + + const created = await createManualJob(fixture); + const input = await fixture.persistence.get(created.inputSnapshot); + if (!("skillCatalog" in input)) throw new Error("Expected an evolution input snapshot"); + expect(input.skillCatalog).toHaveLength(1); + expect(input.skillCatalog[0]).toMatchObject({ + skillId: skill.skillId, + sourceKind: "installed", + scope: "workspace", + workspace: { repositoryId: "diffex" }, + contentDigest: skill.contentDigest, + }); + const contentArtifact = await fixture.persistence.get(input.skillCatalog[0]!.contentArtifact); + if (!("artifactKind" in contentArtifact) || contentArtifact.artifactKind !== "skill-content") { + throw new Error("Expected a frozen skill content artifact"); + } + expect(contentArtifact.skill).toMatchObject({ + skillId: skill.skillId, + content, + location: skill.location, + workspace: { repositoryId: "diffex" }, + }); + }); + + it("rejects skill content that does not match its frozen digest", async () => { + const skill: EvolutionSkillSnapshotSource = { + skillId: `skill:${"b".repeat(64)}`, + name: "review", + description: "Review changes", + sourceKind: "installed", + scope: "global", + source: "local", + version: `sha256:${"c".repeat(64)}`, + contentDigest: `sha256:${"d".repeat(64)}`, + location: "/skills/review/SKILL.md", + baseDir: "/skills/review", + disableModelInvocation: false, + content: "Review carefully.", + }; + const fixture = await controlPlaneFixture( + new RecordingExecutor(), + [materialization(1)], + undefined, + snapshotContext([skill]), + ); + + await expect(fixture.controlPlane.createJob({ scope, trigger: "manual" })).rejects.toThrow( + 'Frozen skill catalog entry is invalid for "review"', + ); + }); + it("cooperatively cancels work owned through another persistence instance", async () => { let executionStarted: (() => void) | undefined; const started = new Promise((resolve) => { diff --git a/packages/evolution/test/control-service.test.ts b/packages/evolution/test/control-service.test.ts index 0412a0f99..5f9c5599a 100644 --- a/packages/evolution/test/control-service.test.ts +++ b/packages/evolution/test/control-service.test.ts @@ -127,6 +127,7 @@ async function serviceFixture( activeMemoryItems: [], }, instructions: [], + skills: [], modelConfiguration: { provider: "faux", model: "fixture", diff --git a/packages/evolution/test/curator.test.ts b/packages/evolution/test/curator.test.ts index 709fd532a..6be9e86c4 100644 --- a/packages/evolution/test/curator.test.ts +++ b/packages/evolution/test/curator.test.ts @@ -159,6 +159,7 @@ function fixtureInput(): EvolutionInputSnapshot { contentDigest: computeEvolutionContentDigest(secondInstruction), }, ], + skillCatalog: [], modelConfiguration: { provider: "faux", model: "fixture", diff --git a/packages/evolution/test/proposal-ranking.test.ts b/packages/evolution/test/proposal-ranking.test.ts index 55f0718f9..7050012f9 100644 --- a/packages/evolution/test/proposal-ranking.test.ts +++ b/packages/evolution/test/proposal-ranking.test.ts @@ -240,6 +240,7 @@ function fixture(evidenceFixtures: readonly EvidenceFixture[]) { contentDigest: computeEvolutionContentDigest(instructionContent), }, ], + skillCatalog: [], modelConfiguration: { provider: "faux", model: "fixture", configurationDigest: MODEL_DIGEST }, pipelineVersion: "pipeline-v1", promptVersions: { diff --git a/packages/evolution/test/reflector.test.ts b/packages/evolution/test/reflector.test.ts index aff83da90..4512e0eb4 100644 --- a/packages/evolution/test/reflector.test.ts +++ b/packages/evolution/test/reflector.test.ts @@ -150,6 +150,7 @@ function fixtureInput(evidenceCount = 6): { expectedHarnessBaseVersion: HARNESS_VERSION, ingestionHighWaterCursor: 1, instructions: [], + skillCatalog: [], modelConfiguration: { provider: "faux", model: "fixture", configurationDigest: MODEL_DIGEST }, pipelineVersion: "pipeline-v1", promptVersions: { diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 59af4bd68..5244494fd 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -31,62 +31,45 @@ function isPasteMarker(segment: string): boolean { /** * A segmenter that wraps Intl.Segmenter and merges graphemes that fall - * within paste markers into single atomic segments. This makes cursor - * movement, deletion, word-wrap, etc. treat paste markers as single units. - * - * Only markers whose numeric ID exists in `validIds` are merged. + * within paste markers or registered text tokens into single atomic segments. */ function segmentWithMarkers( text: string, baseSegmenter: Intl.Segmenter, - validIds: Set, + validPasteIds: Set, + textTokenMarkers: Iterable, ): Iterable { - // Fast path: no paste markers in the text or no valid IDs. - if (validIds.size === 0 || !text.includes("[paste #")) { - return baseSegmenter.segment(text); - } - - // Find all marker spans with valid IDs. const markers: Array<{ start: number; end: number }> = []; - for (const m of text.matchAll(PASTE_MARKER_REGEX)) { - const id = Number.parseInt(m[1]!, 10); - if (!validIds.has(id)) continue; - markers.push({ start: m.index, end: m.index + m[0].length }); + if (validPasteIds.size > 0 && text.includes("[paste #")) { + for (const match of text.matchAll(PASTE_MARKER_REGEX)) { + const id = Number.parseInt(match[1]!, 10); + if (!validPasteIds.has(id)) continue; + markers.push({ start: match.index, end: match.index + match[0].length }); + } } - if (markers.length === 0) { - return baseSegmenter.segment(text); + for (const marker of textTokenMarkers) { + let start = text.indexOf(marker); + while (start !== -1) { + markers.push({ start, end: start + marker.length }); + start = text.indexOf(marker, start + marker.length); + } } + if (markers.length === 0) return baseSegmenter.segment(text); + markers.sort((a, b) => a.start - b.start || b.end - a.end); - // Build merged segment list. - const baseSegments = baseSegmenter.segment(text); const result: Intl.SegmentData[] = []; - let markerIdx = 0; - - for (const seg of baseSegments) { - // Skip past markers that are entirely before this segment. - while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) { - markerIdx++; - } - - const marker = markerIdx < markers.length ? markers[markerIdx]! : null; - - if (marker && seg.index >= marker.start && seg.index < marker.end) { - // This segment falls inside a marker. - // If this is the first segment of the marker, emit a merged segment. - if (seg.index === marker.start) { - const markerText = text.slice(marker.start, marker.end); - result.push({ - segment: markerText, - index: marker.start, - input: text, - }); + let markerIndex = 0; + for (const segment of baseSegmenter.segment(text)) { + while (markerIndex < markers.length && markers[markerIndex]!.end <= segment.index) markerIndex++; + const marker = markers[markerIndex]; + if (marker && segment.index >= marker.start && segment.index < marker.end) { + if (segment.index === marker.start) { + result.push({ segment: text.slice(marker.start, marker.end), index: marker.start, input: text }); } - // Otherwise skip (already merged into the first segment). } else { - result.push(seg); + result.push(segment); } } - return result; } @@ -212,11 +195,26 @@ interface EditorState { cursorCol: number; } -/** Undo snapshot: editor text state plus the paste registry. */ +export interface EditorTextToken { + /** Full text emitted when the editor value is expanded or submitted. */ + value: string; + /** Short label rendered in the editor. */ + displayText: string; +} + +export type EditorTextPart = string | EditorTextToken; + +interface RegisteredTextToken extends EditorTextToken { + marker: string; +} + +/** Undo snapshot: editor text state plus its marker registries. */ interface EditorSnapshot { state: EditorState; pastes: Map; pasteCounter: number; + textTokens: Map; + textTokenCounter: number; } interface LayoutLine { @@ -238,6 +236,8 @@ export interface EditorOptions { concealedInputPrefix?: string; placeholder?: string; placeholderStyle?: (text: string) => string; + /** Style applied to short labels for registered text tokens. */ + textTokenStyle?: (text: string) => string; } const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { @@ -291,6 +291,7 @@ export class Editor implements Component, Focusable { private concealedInputPrefix: string = ""; private placeholder: string = ""; private placeholderStyle: (text: string) => string = (text) => text; + private textTokenStyle: (text: string) => string = (text) => text; private cursorBlinkVisible = true; private cursorBlinkTimer?: ReturnType; @@ -322,6 +323,10 @@ export class Editor implements Component, Focusable { private pastes: Map = new Map(); private pasteCounter: number = 0; + // Atomic text tokens with short styled labels and expanded submission values + private textTokens = new Map(); + private textTokenCounter = 0; + // Bracketed paste mode buffering private pasteBuffer: string = ""; private isInPaste: boolean = false; @@ -368,6 +373,7 @@ export class Editor implements Component, Focusable { this.concealedInputPrefix = options.concealedInputPrefix ?? ""; this.placeholder = options.placeholder ?? ""; this.placeholderStyle = options.placeholderStyle ?? ((text) => text); + this.textTokenStyle = options.textTokenStyle ?? ((text) => text); } /** Set of currently valid paste IDs, for marker-aware segmentation. */ @@ -375,9 +381,34 @@ export class Editor implements Component, Focusable { return new Set(this.pastes.keys()); } - /** Segment text with paste-marker awareness, only merging markers with valid IDs. */ + /** Segment text with marker awareness so registered tokens remain atomic. */ private segment(text: string, mode: "word" | "grapheme"): Iterable { - return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds()); + return segmentWithMarkers( + text, + mode === "word" ? wordSegmenter : graphemeSegmenter, + this.validPasteIds(), + this.textTokens.keys(), + ); + } + + private isAtomicMarker(segment: string): boolean { + return isPasteMarker(segment) || this.textTokens.has(segment); + } + + private renderTextTokens(text: string): string { + let rendered = text; + for (const token of this.textTokens.values()) { + rendered = rendered.replaceAll(token.marker, this.textTokenStyle(token.displayText)); + } + return rendered; + } + + private expandTextTokens(text: string): string { + let expanded = text; + for (const token of this.textTokens.values()) { + expanded = expanded.replaceAll(token.marker, token.value); + } + return expanded; } getPaddingX(): number { @@ -601,8 +632,9 @@ export class Editor implements Component, Focusable { const layoutLine = visibleLines[visibleLineIndex]!; const layoutLineIndex = this.scrollOffset + visibleLineIndex; const linePrompt = layoutLineIndex === 0 ? this.promptStyle(prompt) : " ".repeat(promptWidth); - let displayText = layoutLine.text; - let lineVisibleWidth = visibleWidth(layoutLine.text); + const rawText = layoutLine.text; + let displayText = rawText; + let lineVisibleWidth = visibleWidth(rawText); let cursorInPadding = false; const shouldShowPlaceholder = this.isEditorEmpty() && layoutLine.hasCursor && this.placeholder.length > 0 && !this.autocompleteState; @@ -623,31 +655,31 @@ export class Editor implements Component, Focusable { } // Add cursor if this line has it else if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { - const before = displayText.slice(0, layoutLine.cursorPos); - const after = displayText.slice(layoutLine.cursorPos); + const before = rawText.slice(0, layoutLine.cursorPos); + const after = rawText.slice(layoutLine.cursorPos); // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) const marker = emitCursorMarker ? CURSOR_MARKER : ""; if (after.length > 0) { - // Cursor is on a character (grapheme) - replace it with highlighted version - // Get the first grapheme from 'after' - const afterGraphemes = [...this.segment(after, "grapheme")]; - const firstGrapheme = afterGraphemes[0]?.segment || ""; + const firstGrapheme = [...this.segment(after, "grapheme")][0]?.segment || ""; const restAfter = after.slice(firstGrapheme.length); - const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; - displayText = before + marker + cursor + restAfter; - // lineVisibleWidth stays the same - we're replacing, not adding + const token = this.textTokens.get(firstGrapheme); + const cursorText = token ? this.textTokenStyle(token.displayText) : firstGrapheme; + const cursor = `\x1b[7m${cursorText}\x1b[0m`; + displayText = this.renderTextTokens(before) + marker + cursor + this.renderTextTokens(restAfter); } else { // Cursor is at the end - add highlighted space const cursor = "\x1b[7m \x1b[0m"; - displayText = before + marker + cursor; + displayText = this.renderTextTokens(before) + marker + cursor; lineVisibleWidth = lineVisibleWidth + 1; // If cursor overflows content width into the padding, flag it if (lineVisibleWidth > contentWidth && paddingX > 0) { cursorInPadding = true; } } + } else { + displayText = this.renderTextTokens(rawText); } // Calculate padding based on actual visible width @@ -1093,7 +1125,7 @@ export class Editor implements Component, Focusable { * Use this when you need the full content (e.g., for external editor). */ getExpandedText(): string { - return this.expandPasteMarkers(this.state.lines.join("\n")); + return this.expandTextTokens(this.expandPasteMarkers(this.state.lines.join("\n"))); } getLines(): string[] { @@ -1115,9 +1147,38 @@ export class Editor implements Component, Focusable { } this.pastes.clear(); this.pasteCounter = 0; + this.textTokens.clear(); + this.textTokenCounter = 0; this.setTextInternal(normalized); } + /** Replace the editor value with text and atomic short-label tokens. */ + setTextWithTokens(parts: readonly EditorTextPart[]): void { + this.cancelAutocomplete(); + this.lastAction = null; + this.exitHistoryBrowsing(); + this.pushUndoSnapshot(); + this.pastes.clear(); + this.pasteCounter = 0; + this.textTokens.clear(); + this.textTokenCounter = 0; + + let text = ""; + for (const part of parts) { + if (typeof part === "string") { + text += part; + continue; + } + this.textTokenCounter += 1; + const id = this.textTokenCounter.toString(2); + const encodedId = [...id].map((bit) => (bit === "0" ? "\u200b" : "\u200c")).join(""); + const marker = `\u2063${encodedId}\u2063${part.displayText}\u2063`; + this.textTokens.set(marker, { ...part, marker }); + text += marker; + } + this.setTextInternal(this.normalizeText(text)); + } + /** * Insert text at the current cursor position. * Used for programmatic insertion (e.g., clipboard image markers). @@ -1356,11 +1417,13 @@ export class Editor implements Component, Focusable { private submitValue(): void { this.cancelAutocomplete(); - const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim(); + const result = this.expandTextTokens(this.expandPasteMarkers(this.state.lines.join("\n"))).trim(); this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; + this.textTokens.clear(); + this.textTokenCounter = 0; this.exitHistoryBrowsing(); this.scrollOffset = 0; this.undoStack.clear(); @@ -1985,7 +2048,7 @@ export class Editor implements Component, Focusable { this.setCursorCol( findWordBackward(currentLine, this.state.cursorCol, { segment: (text) => this.segment(text, "word"), - isAtomicSegment: isPasteMarker, + isAtomicSegment: (segment) => this.isAtomicMarker(segment), }), ); } @@ -2112,7 +2175,13 @@ export class Editor implements Component, Focusable { } private pushUndoSnapshot(): void { - this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter }); + this.undoStack.push({ + state: this.state, + pastes: this.pastes, + pasteCounter: this.pasteCounter, + textTokens: this.textTokens, + textTokenCounter: this.textTokenCounter, + }); } private undo(): void { @@ -2122,6 +2191,8 @@ export class Editor implements Component, Focusable { Object.assign(this.state, snapshot.state); this.pastes = snapshot.pastes; this.pasteCounter = snapshot.pasteCounter; + this.textTokens = snapshot.textTokens; + this.textTokenCounter = snapshot.textTokenCounter; this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { @@ -2179,7 +2250,7 @@ export class Editor implements Component, Focusable { this.setCursorCol( findWordForward(currentLine, this.state.cursorCol, { segment: (text) => this.segment(text, "word"), - isAtomicSegment: isPasteMarker, + isAtomicSegment: (segment) => this.isAtomicMarker(segment), }), ); } diff --git a/packages/tui/src/editor-component.ts b/packages/tui/src/editor-component.ts index ef78822e9..49ea7428c 100644 --- a/packages/tui/src/editor-component.ts +++ b/packages/tui/src/editor-component.ts @@ -1,4 +1,5 @@ import type { AutocompleteProvider } from "./autocomplete.ts"; +import type { EditorTextPart } from "./components/editor.ts"; import type { Component } from "./tui.ts"; /** @@ -19,6 +20,9 @@ export interface EditorComponent extends Component { /** Set the text content */ setText(text: string): void; + /** Set text with atomic short-label tokens that expand when submitted. */ + setTextWithTokens?(parts: readonly EditorTextPart[]): void; + /** Handle raw terminal input (key presses, paste sequences, etc.) */ handleInput(data: string): void; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 2462a254b..b805756f2 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -12,7 +12,13 @@ export { // Components export { Box } from "./components/box.ts"; export { CancellableLoader } from "./components/cancellable-loader.ts"; -export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts"; +export { + Editor, + type EditorOptions, + type EditorTextPart, + type EditorTextToken, + type EditorTheme, +} from "./components/editor.ts"; export { HStack } from "./components/h-stack.ts"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; export { Input } from "./components/input.ts"; diff --git a/packages/tui/test/editor-text-tokens.test.ts b/packages/tui/test/editor-text-tokens.test.ts new file mode 100644 index 000000000..1a445d4df --- /dev/null +++ b/packages/tui/test/editor-text-tokens.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { stripVTControlCharacters } from "node:util"; +import { Editor } from "../src/components/editor.ts"; +import type { TUI } from "../src/tui.ts"; +import { visibleWidth } from "../src/utils.ts"; +import { defaultEditorTheme } from "./test-themes.ts"; + +function createTestTui(): TUI { + return { + terminal: { rows: 24 }, + requestRender: () => {}, + isTerminalFocused: () => true, + } as unknown as TUI; +} + +const purple = (text: string) => `\x1b[38;2;196;114;214m${text}\x1b[39m`; + +describe("Editor text tokens", () => { + it("renders a short styled label while preserving the expanded value", () => { + const editor = new Editor(createTestTui(), defaultEditorTheme, { textTokenStyle: purple }); + const reference = ''; + + editor.setTextWithTokens([{ value: reference, displayText: "review" }, "\n\ncheck this change"]); + + assert.strictEqual(editor.getExpandedText(), `${reference}\n\ncheck this change`); + assert.ok(!editor.getText().includes(reference)); + const rendered = editor.render(40); + assert.ok(rendered.join("\n").includes(purple("review"))); + assert.ok(stripVTControlCharacters(rendered.join("\n")).includes("review")); + assert.ok(!stripVTControlCharacters(rendered.join("\n")).includes("skill-reference")); + assert.ok(rendered.every((line) => visibleWidth(line) <= 40)); + }); + + it("treats a token as one editable unit", () => { + const editor = new Editor(createTestTui(), defaultEditorTheme, { textTokenStyle: purple }); + editor.setTextWithTokens([{ value: "", displayText: "review" }]); + + editor.handleInput("\x7f"); + + assert.strictEqual(editor.getText(), ""); + assert.strictEqual(editor.getExpandedText(), ""); + }); + + it("submits expanded values for duplicate display labels", () => { + const editor = new Editor(createTestTui(), defaultEditorTheme, { textTokenStyle: purple }); + let submitted = ""; + editor.onSubmit = (text) => { + submitted = text; + }; + editor.setTextWithTokens([ + { value: "", displayText: "duplicate" }, + "\n", + { value: "", displayText: "duplicate" }, + ]); + + editor.handleInput("\r"); + + assert.strictEqual(submitted, "\n"); + assert.strictEqual(editor.getText(), ""); + }); +});