From 27f7710e2483f0eef5c459ae0a7f9222a614b31f Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Fri, 22 May 2026 18:20:26 +0800 Subject: [PATCH 1/5] feat(core/cli): add skill support system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills are SKILL.md files under ~/.x-code/skills//SKILL.md (global) or .x-code/skills//SKILL.md (project). Loaded at startup; project-level overrides global on name conflict. New capabilities: - core: SkillRegistry + loadSkills loader (YAML frontmatter, zod validation) - core: activateSkill tool — AI injects skill context into conversation - core: AgentOptions.skillRegistry plumbed through to tool creation - cli: /skill list — shows all loaded skills with descriptions - cli: /skill install — downloads via shell into global skills dir - cli: / [arg] — activates skill; without arg stores as pending and injects with the user's next real message (pendingSkillRef pattern) - cli: /clear resets any pending skill Bug fixes: - Session slug no longer polluted by XML as first message - /skill list hint uses platform-correct path (GLOBAL_XCODE_DIR) - Install hint forbids webFetch+write (corrupts YAML); requires shell download Tests: 15 new tests in packages/core/tests/skills.test.ts --- packages/cli/src/index.ts | 3 + packages/cli/src/ui/components/App.tsx | 164 ++++++++++++++-- packages/core/src/agent/loop.ts | 13 +- packages/core/src/agent/system-prompt.ts | 35 +++- packages/core/src/index.ts | 4 + packages/core/src/skills/loader.ts | 122 ++++++++++++ packages/core/src/skills/registry.ts | 39 ++++ packages/core/src/tools/activate-skill.ts | 36 ++++ packages/core/src/types/index.ts | 9 + packages/core/tests/skills.test.ts | 219 ++++++++++++++++++++++ 10 files changed, 628 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/skills/loader.ts create mode 100644 packages/core/src/skills/registry.ts create mode 100644 packages/core/src/tools/activate-skill.ts create mode 100644 packages/core/tests/skills.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 553b1ca..246f02d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,7 @@ import { PROVIDER_KEY_URLS, createModelRegistry, createOAuthProviderFactory, + createSkillRegistry, createSubAgentRegistry, debugLog, getAvailableProviders, @@ -292,6 +293,7 @@ async function main() { const providerRegistry = createModelRegistry() const model = providerRegistry.languageModel(modelId as `${string}:${string}`) const subAgentRegistry = await createSubAgentRegistry() + const skillRegistry = await createSkillRegistry() // MCP: load servers, run trust dialog if project-level config is // unfamiliar. Done BEFORE Ink mounts so the readline-based trust @@ -348,6 +350,7 @@ async function main() { permissionMode: argv.plan ? 'plan' : 'default', modelRegistry: providerRegistry, subAgentRegistry, + skillRegistry, mcpRegistry: mcpLoadResult.registry, mcpPermissionStore, } diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 91569d4..d63d432 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -1,9 +1,13 @@ // @x-code-cli/cli — Root App component -import { useCallback, useEffect, useRef, useState } from 'react' +import fs from 'node:fs/promises' +import path from 'node:path' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useApp } from 'ink' import { + GLOBAL_XCODE_DIR, MODEL_ALIASES, PROVIDER_MODELS, createModelRegistry, @@ -63,7 +67,8 @@ interface AppProps { onSessionInfoReady?: (getter: () => { sessionId: string; taskSlug: string; messageCount: number } | null) => void } -/** Slash commands — used for both help text and tab completion */ +/** Slash commands — built-in static set used for help text and tab completion. + * Skill commands are appended dynamically at runtime from the skill registry. */ export const SLASH_COMMANDS = [ { name: '/help', description: 'Show this help message' }, { @@ -110,6 +115,14 @@ export const SLASH_COMMANDS = [ { name: 'refresh', description: 'Reload mcpServers from disk and reconnect' }, ], }, + { + name: '/skill', + description: 'Manage skills', + subcommands: [ + { name: 'install', description: 'Fetch and install a skill from a URL' }, + { name: 'list', description: 'List installed skills' }, + ], + }, { name: '/exit', description: 'Exit (flushes session)' }, ] as const @@ -184,11 +197,18 @@ function formatRelativeTime(epochMs: number): string { // formatUsageHistory was replaced by the interactive handleUsageHistory // picker inside the component — see handleUsageHistory(). -const HELP_TEXT = - `X-Code CLI v${VERSION}\n\n` + - SLASH_COMMANDS.map((c) => ` ${c.name.padEnd(16)} ${c.description}`).join('\n') + - `\n\nModel aliases: ${Object.keys(MODEL_ALIASES).join(', ')}` + - `\nKeyboard: Esc to interrupt the current turn · ${process.platform === 'darwin' ? '⌃C' : 'Ctrl+C'} (twice) to exit` +function buildHelpText(skillCommands: readonly { name: string; description: string }[]): string { + const allCommands = [ + ...SLASH_COMMANDS, + ...skillCommands.map((s) => ({ name: `/${s.name}`, description: s.description })), + ] + return ( + `X-Code CLI v${VERSION}\n\n` + + allCommands.map((c) => ` ${c.name.padEnd(16)} ${c.description}`).join('\n') + + `\n\nModel aliases: ${Object.keys(MODEL_ALIASES).join(', ')}` + + `\nKeyboard: Esc to interrupt the current turn · ${process.platform === 'darwin' ? '⌃C' : 'Ctrl+C'} (twice) to exit` + ) +} // Prompt body for `/init`. Submitted as the user message so the agent runs // its full toolchain (Read/Glob/Grep/Edit/Write) over the codebase and @@ -296,6 +316,26 @@ export function App({ setPermissionMode, } = useAgent(model, options, initialSession) + // Derived from options.skillRegistry — stable for the session lifetime. + // Used both for tab completion (allCommands) and /skillname dispatch. + const skillCommands = useMemo( + () => (options.skillRegistry ? options.skillRegistry.list() : []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + // Combined command list: built-ins + loaded skills (for tab completion). + const allCommands = useMemo( + () => [...SLASH_COMMANDS, ...skillCommands.map((s) => ({ name: `/${s.name}`, description: s.description }))], + [skillCommands], + ) + + /** Skill pending injection: set when the user types `/skillname` with no + * argument (so we don't trigger an immediate AI response just to the skill + * XML). The skill content is prepended to the NEXT non-slash-command user + * message. Cleared on /clear or when consumed. */ + const pendingSkillRef = useRef<{ name: string; content: string } | null>(null) + // Transient one-line hint shown below the input box (in ChatInput's // footer slot, alongside the plan-mode / accept-edits indicators). Today // only used for the "Press Ctrl+C again to exit" double-press prompt — @@ -555,7 +595,7 @@ export function App({ switch (command) { case 'help': echoCommand(text) - addInfoMessage(HELP_TEXT) + addInfoMessage(buildHelpText(skillCommands)) return case 'model': @@ -581,6 +621,7 @@ export function App({ // cleared." line would force the cleared screen to immediately // start re-painting at row 1, defeating the "fresh launch" look // the user asked for. + pendingSkillRef.current = null clear() return @@ -619,6 +660,10 @@ export function App({ handleMemory() return + case 'skill': + await handleSkill(text, arg) + return + case 'mcp': await handleMcp(text, arg) return @@ -628,12 +673,42 @@ export function App({ exit() return - default: + default: { + // Check if the command matches a loaded skill before giving up. + const skill = options.skillRegistry?.get(command) + if (skill) { + echoCommand(text) + if (arg) { + // Skill + immediate request — inject and submit together so the + // model applies the skill persona to the user's specific ask. + await submit(`\n${skill.content}\n\n\n${arg}`, { + silent: true, + }) + } else { + // No follow-up yet — store as pending so the AI doesn't respond + // to the skill XML as if it were a user greeting. The skill + // context will be prepended to the user's next real message. + pendingSkillRef.current = { name: skill.name, content: skill.content } + addCommandMessage(text, `Skill **${skill.name}** loaded. Type your request.`) + } + return + } addCommandMessage(text, `Unknown command: /${command}. Type /help for available commands.`) return + } } } + // Prepend any pending skill context to the user's message, then clear it. + const pendingSkill = pendingSkillRef.current + if (pendingSkill) { + pendingSkillRef.current = null + await submit( + `\n${pendingSkill.content}\n\n\n${text}`, + { silent: true }, + ) + return + } await submit(text) } @@ -1075,9 +1150,72 @@ export function App({ * * Most subcommands are pure-read against `options.mcpRegistry`, which * is the frozen snapshot from CLI startup. `auth` / `refresh` / - * config changes all require a CLI restart to take effect because - * the system prompt cache (and provider prefix caches) are stable - * for the session — same constraint as sub-agents (see CLAUDE.md). + /** Minimal YAML name extractor for SKILL.md frontmatter. + * Only needs to find `name: ` — full parse happens in the loader. */ + function extractSkillName(content: string): string | null { + const match = content.match(/^---\r?\n[\s\S]*?^name:\s*["']?([^"'\r\n]+)["']?\s*$/m) + return match ? match[1].trim() : null + } + + async function handleSkill(text: string, arg: string) { + echoCommand(text) + const parts = arg.trim().split(/\s+/) + const sub = parts[0]?.toLowerCase() + const subArg = parts.slice(1).join(' ').trim() + + if (sub === 'install') { + if (!subArg) { + addCommandMessage(text, 'Usage: `/skill install `') + return + } + let content: string + try { + const res = await fetch(subArg) + if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`) + content = await res.text() + } catch (err) { + addCommandMessage(text, `Failed to fetch \`${subArg}\`: ${err instanceof Error ? err.message : String(err)}`) + return + } + + const name = extractSkillName(content) + if (!name) { + addCommandMessage(text, 'Invalid SKILL.md: missing `name` in frontmatter.') + return + } + + const skillDir = path.join(GLOBAL_XCODE_DIR, 'skills', name) + const skillFile = path.join(skillDir, 'SKILL.md') + try { + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(skillFile, content, 'utf-8') + } catch (err) { + addCommandMessage(text, `Failed to save skill: ${err instanceof Error ? err.message : String(err)}`) + return + } + + addCommandMessage(text, `Skill **${name}** installed to \`${skillFile}\`\n\nRestart the CLI to use \`/${name}\`.`) + return + } + + if (sub === 'list') { + const skills = options.skillRegistry?.list() ?? [] + if (skills.length === 0) { + const skillsPath = path.join(GLOBAL_XCODE_DIR, 'skills', '', 'SKILL.md') + addCommandMessage(text, `No skills loaded. Place SKILL.md files in \`${skillsPath}\` and restart.`) + return + } + const lines = skills.map((s) => `- **${s.name}** (${s.source}): ${s.description}`) + addCommandMessage(text, `**Loaded skills** (${skills.length}):\n\n${lines.join('\n')}`) + return + } + + addCommandMessage(text, 'Usage: `/skill install ` · `/skill list`') + } + + /** Skills and MCP server config changes all require a CLI restart to take + * effect because the system prompt cache (and provider prefix caches) are + * stable for the session — same constraint as sub-agents (see CLAUDE.md). * `logout` is the only mutator that takes effect immediately: it * just deletes a token from disk; the actual reconnect happens at * next launch. */ @@ -1566,7 +1704,7 @@ export function App({ } : null } - commands={SLASH_COMMANDS} + commands={allCommands} /> ) } diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index a23a59a..007cfed 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -13,6 +13,7 @@ import { listMcpResources, readMcpResource } from '../mcp/resources.js' import { bridgeMcpTool, toSystemPromptEntries } from '../mcp/tool-bridge.js' import { applyCacheControl } from '../providers/cache-control.js' import { getThinkingProviderOptions, mergeThinkingOptions } from '../providers/thinking.js' +import { createActivateSkillTool } from '../tools/activate-skill.js' import { toolRegistry, truncateToolResult } from '../tools/index.js' import { clearProgressReporter, setProgressReporter } from '../tools/progress.js' import { createTaskTool } from '../tools/task.js' @@ -210,6 +211,10 @@ function buildTools(options: AgentOptions) { tools.task = createTaskTool(options.subAgentRegistry) } + if (options.skillRegistry && options.skillRegistry.names().length > 0) { + tools.activateSkill = createActivateSkillTool(options.skillRegistry) + } + // MCP tools: declared without `execute` so the AI SDK leaves them in // `result.toolCalls` for processToolCalls to hand-dispatch through the // permission / loop-guard / abortSignal pipeline. @@ -412,9 +417,12 @@ export async function agentLoop( // file is created (well before the first runTurn), so paths are // never written with a stale empty slug. const taskText = userContentToText(userMessage) + // Strip XML blocks so the session slug and firstPrompt + // reflect the user's real intent rather than injected skill content. + const taskTextForMeta = taskText.replace(/]*>[\s\S]*?<\/activated_skill>/gi, '').trim() const taskSlugPromise: Promise = state.taskSlug ? Promise.resolve(state.taskSlug) - : generateTaskSlug(taskText, model, options.modelId, options.abortSignal) + : generateTaskSlug(taskTextForMeta || taskText, model, options.modelId, options.abortSignal) // Session continuation is handled explicitly by the UI: if the user accepts // the resume prompt, the pending work is embedded directly in their first @@ -453,7 +461,7 @@ export async function agentLoop( // the header line already exists in that case and we skip). Must come // AFTER taskSlug resolution because the filename is `-.jsonl`. // Fire-and-forget — never blocks the loop on FS errors. - void appendHeader(state, options.modelId, taskText) + void appendHeader(state, options.modelId, taskTextForMeta || taskText) const compressionThreshold = getCompressionThreshold(options.modelId) @@ -515,6 +523,7 @@ export async function agentLoop( // pre-MCP shape, preserving prefix-cache for sessions // without MCP configured. mcpTools: options.mcpRegistry ? toSystemPromptEntries(options.mcpRegistry.list()) : undefined, + skills: options.skillRegistry ? options.skillRegistry.list() : undefined, }) } const systemPrompt = state.systemPromptCache diff --git a/packages/core/src/agent/system-prompt.ts b/packages/core/src/agent/system-prompt.ts index bad4f01..9e6aea8 100644 --- a/packages/core/src/agent/system-prompt.ts +++ b/packages/core/src/agent/system-prompt.ts @@ -1,5 +1,8 @@ // @x-code-cli/core — System Prompt management +import path from 'node:path' + import { getShellProvider } from '../tools/shell-provider.js' +import { GLOBAL_XCODE_DIR, XCODE_DIR } from '../utils.js' const BASE_SYSTEM_PROMPT = `You are X-Code, an AI coding assistant running in the user's terminal. You are powered by the {model} model. @@ -21,7 +24,7 @@ You have access to these tools: - webFetch: Fetch and extract content from URLs - askUser: Ask the user clarifying questions with choices - todoWrite: Track multi-step tasks with a live checklist visible to the user -- task: Delegate a task to a specialized sub-agent (explore, plan, review, general-purpose){mcpCapabilities} +- task: Delegate a task to a specialized sub-agent (explore, plan, review, general-purpose){mcpCapabilities}{skillCapabilities} ## Sub-agent Delegation Use the task tool to delegate research, exploration, planning, or review tasks to a specialized sub-agent. Sub-agents run in isolated context — they don't see your conversation history and their intermediate tool calls never pollute your context window. Only the final conclusion comes back. @@ -233,6 +236,31 @@ export interface SystemPromptMcpTool { description: string } +/** Format the optional skills block. Returns "" when no skills are loaded + * so the prompt is byte-identical to the no-skills shape, preserving + * prefix-cache hits for sessions without any skills configured. */ +function formatSkillCapabilities(skills: readonly { name: string; description: string }[] | undefined): string { + const globalSkillsDir = path.join(GLOBAL_XCODE_DIR, 'skills', '', 'SKILL.md') + const projectSkillsDir = path.join(XCODE_DIR, 'skills', '', 'SKILL.md') + const installHint = `To install a skill from a URL: use the shell tool to download the raw file directly (e.g. \`Invoke-WebRequest -Uri -OutFile "${globalSkillsDir}"\` on Windows, or \`curl -L -o "${globalSkillsDir}"\` on macOS/Linux), then confirm the path. Do NOT use webFetch + write — webFetch renders markdown and corrupts YAML frontmatter. Alternatively, use /skill install . Restart the CLI after installing.` + + if (!skills || skills.length === 0) { + return `\n\n## Skills\n${installHint}` + } + + const lines = [ + '', + '', + '## Available Skills', + "Use the activateSkill tool to inject a skill's instructions when the task matches its description:", + ] + for (const s of skills) { + lines.push(`- ${s.name}: ${s.description}`) + } + lines.push('', installHint) + return lines.join('\n') +} + /** Format the optional MCP tools block. Returns "" when no tools AND * no registry are passed, so the byte layout of BASE_SYSTEM_PROMPT * after substitution exactly matches the pre-MCP version — preserves @@ -294,6 +322,10 @@ export function buildSystemPrompt(options?: { * absent or empty, the prompt body is byte-identical to the * pre-MCP version. */ mcpTools?: readonly SystemPromptMcpTool[] + /** Optional skill surface. When provided, an `## Available Skills` + * section is appended listing each skill name + description. When + * absent or empty, the prompt is byte-identical to the no-skills shape. */ + skills?: readonly { name: string; description: string }[] }): string { const shellProvider = getShellProvider() @@ -303,6 +335,7 @@ export function buildSystemPrompt(options?: { .replace(/\{model\}/g, options?.modelId ?? 'unknown') .replace(/\{isGitRepo\}/g, options?.isGitRepo ? 'yes' : 'no') .replace(/\{mcpCapabilities\}/g, formatMcpCapabilities(options?.mcpTools)) + .replace(/\{skillCapabilities\}/g, formatSkillCapabilities(options?.skills)) if (options?.knowledgeContext) { prompt += '\n\n' + options.knowledgeContext diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d99b7a3..9823f4f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -78,6 +78,10 @@ export { generateSessionSummary } from './knowledge/session.js' export { createSubAgentRegistry, createBuiltInRegistry, SubAgentRegistry } from './agent/sub-agents/index.js' export type { SubAgentDefinition, SubAgentEvent, SubAgentTrace } from './agent/sub-agents/index.js' +// Skills +export { SkillRegistry, createSkillRegistry } from './skills/registry.js' +export type { SkillDefinition } from './skills/registry.js' + // Session store (per-session jsonl transcript — used by /resume, // /usage history, and the CLI startup --resume / --continue flags). export { diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts new file mode 100644 index 0000000..2a4cd9a --- /dev/null +++ b/packages/core/src/skills/loader.ts @@ -0,0 +1,122 @@ +// @x-code-cli/core — Skill loader +// +// Scans ~/.x-code/skills/*/SKILL.md and /.x-code/skills/*/SKILL.md +// for user-defined skills with YAML frontmatter. The subdirectory layout +// mirrors all major competitors (Gemini CLI, Opencode, Codex) and allows +// future support files alongside SKILL.md. +// +// Priority: project-level skills override global skills of the same name. +// Bad files are skipped with a warning — one broken SKILL.md must never +// crash the CLI. +import fs from 'node:fs/promises' +import path from 'node:path' + +import { z } from 'zod' + +import { GLOBAL_XCODE_DIR, XCODE_DIR } from '../utils.js' +import type { SkillDefinition } from './registry.js' + +const SKILL_FILENAME = 'SKILL.md' + +const frontmatterSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), +}) + +/** Minimal YAML frontmatter parser — reuses the same subset logic as + * sub-agent loader: string scalars only, no dependency on gray-matter. */ +function parseFrontmatter(raw: string): { data: Record; body: string } | null { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/) + if (!match) return null + + const yamlBlock = match[1]! + const body = match[2]! + const data: Record = {} + + for (const line of yamlBlock.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + + const colonIdx = trimmed.indexOf(':') + if (colonIdx < 1) continue + + const key = trimmed.slice(0, colonIdx).trim() + let value: string = trimmed.slice(colonIdx + 1).trim() + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + + data[key] = value + } + + return { data, body } +} + +async function loadSkillsFromDir(dir: string, source: SkillDefinition['source']): Promise { + const skills: SkillDefinition[] = [] + + let entries: string[] + try { + entries = await fs.readdir(dir) + } catch { + return skills + } + + for (const entry of entries) { + const skillFile = path.join(dir, entry, SKILL_FILENAME) + + try { + await fs.access(skillFile) + } catch { + continue + } + + try { + const raw = await fs.readFile(skillFile, 'utf-8') + const parsed = parseFrontmatter(raw) + if (!parsed) { + console.error(`[skills] Skipping ${skillFile}: no valid YAML frontmatter`) + continue + } + + const result = frontmatterSchema.safeParse(parsed.data) + if (!result.success) { + console.error( + `[skills] Skipping ${skillFile}: invalid frontmatter — ${result.error.issues.map((i) => i.message).join(', ')}`, + ) + continue + } + + skills.push({ + name: result.data.name, + description: result.data.description, + content: parsed.body.trim(), + source, + }) + } catch (err) { + console.error(`[skills] Skipping ${skillFile}: ${err instanceof Error ? err.message : String(err)}`) + } + } + + return skills +} + +/** Load skills from global + project directories. + * Environment variable `XC_SKILLS_DIR` overrides both paths (testing only). */ +export async function loadSkills(): Promise { + const override = process.env.XC_SKILLS_DIR + if (override) { + return loadSkillsFromDir(override, 'project') + } + + const globalDir = path.join(GLOBAL_XCODE_DIR, 'skills') + const projectDir = path.join(process.cwd(), XCODE_DIR, 'skills') + + const globalSkills = await loadSkillsFromDir(globalDir, 'global') + const projectSkills = await loadSkillsFromDir(projectDir, 'project') + + // Project skills come last so their names win over global skills + // when the registry deduplicates by name. + return [...globalSkills, ...projectSkills] +} diff --git a/packages/core/src/skills/registry.ts b/packages/core/src/skills/registry.ts new file mode 100644 index 0000000..682c996 --- /dev/null +++ b/packages/core/src/skills/registry.ts @@ -0,0 +1,39 @@ +// @x-code-cli/core — Skill registry +import { loadSkills } from './loader.js' + +export interface SkillDefinition { + name: string + description: string + content: string + source: 'global' | 'project' +} + +export class SkillRegistry { + private readonly byName: Map + + constructor(skills: SkillDefinition[]) { + this.byName = new Map() + // Last-write wins: project skills override globals of the same name + // because loadSkills() returns globals first, then project skills. + for (const skill of skills) { + this.byName.set(skill.name, skill) + } + } + + get(name: string): SkillDefinition | undefined { + return this.byName.get(name) + } + + list(): SkillDefinition[] { + return [...this.byName.values()] + } + + names(): string[] { + return [...this.byName.keys()] + } +} + +export async function createSkillRegistry(): Promise { + const skills = await loadSkills() + return new SkillRegistry(skills) +} diff --git a/packages/core/src/tools/activate-skill.ts b/packages/core/src/tools/activate-skill.ts new file mode 100644 index 0000000..c4bb907 --- /dev/null +++ b/packages/core/src/tools/activate-skill.ts @@ -0,0 +1,36 @@ +// @x-code-cli/core — activateSkill tool +// +// Injected into the tool registry only when a SkillRegistry is present +// (i.e. at least one SKILL.md was found). The model calls this when it +// decides the current task matches a skill's description; the tool +// returns the skill's Markdown body wrapped in XML tags, which the model +// then sees as a tool-result and follows as instructions. +// +// This mirrors Gemini CLI's activate_skill tool and Claude Code's inline +// SkillTool — the common pattern across all major competitors. +import { tool } from 'ai' + +import { z } from 'zod' + +import type { SkillRegistry } from '../skills/registry.js' + +export function createActivateSkillTool(registry: SkillRegistry) { + const nameList = registry.names().join(', ') + + return tool({ + description: `Activate a skill to inject its instructions into the conversation. Available skills: ${nameList}. Call this when the current task clearly matches one of those skill descriptions.`, + inputSchema: z.object({ + name: z.string().describe('Name of the skill to activate'), + }), + execute: async ({ name }) => { + const skill = registry.get(name) + if (!skill) { + const available = registry.names() + return available.length > 0 + ? `Skill "${name}" not found. Available: ${available.join(', ')}` + : `Skill "${name}" not found. No skills are currently loaded.` + } + return `\n${skill.content}\n` + }, + }) +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 4c146b1..5d01211 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -6,6 +6,7 @@ import type { SubAgentRegistry } from '../agent/sub-agents/registry.js' import type { SubAgentEvent } from '../agent/sub-agents/types.js' import type { McpPermissionStore } from '../mcp/permissions.js' import type { McpRegistry } from '../mcp/registry.js' +import type { SkillRegistry } from '../skills/registry.js' // ─── Permission ─── @@ -213,6 +214,14 @@ export interface AgentOptions { * which tools the child can call. `task` is always in `deny`. */ toolFilter?: { allow?: string[]; deny?: string[] } + // ── Skill support ── + + /** Skill registry, populated at CLI startup by createSkillRegistry. + * Absent means no skills are configured — activateSkill tool is not + * registered and the `## Available Skills` section is omitted from + * the system prompt. */ + skillRegistry?: SkillRegistry + // ── MCP support ── /** MCP registry, populated at CLI startup by loadMcpServers. Absent diff --git a/packages/core/tests/skills.test.ts b/packages/core/tests/skills.test.ts new file mode 100644 index 0000000..17df822 --- /dev/null +++ b/packages/core/tests/skills.test.ts @@ -0,0 +1,219 @@ +// Tests for skill loader + registry +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { loadSkills } from '../src/skills/loader.js' +import { SkillRegistry } from '../src/skills/registry.js' + +/** Create a temp dir, write skill subdirs into it, return the dir path. */ +async function makeTempSkillsDir(skills: { dir: string; frontmatter: string; body: string }[]): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'xc-skills-test-')) + for (const s of skills) { + const skillDir = path.join(root, s.dir) + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(path.join(skillDir, 'SKILL.md'), `---\n${s.frontmatter}\n---\n${s.body}`, 'utf-8') + } + return root +} + +let originalSkillsDir: string | undefined + +beforeEach(() => { + originalSkillsDir = process.env.XC_SKILLS_DIR +}) + +afterEach(async () => { + if (originalSkillsDir === undefined) { + delete process.env.XC_SKILLS_DIR + } else { + process.env.XC_SKILLS_DIR = originalSkillsDir + } +}) + +// ── loadSkills ──────────────────────────────────────────────────────────────── + +describe('loadSkills', () => { + it('returns empty array when directory does not exist', async () => { + process.env.XC_SKILLS_DIR = path.join(os.tmpdir(), 'xc-skills-nonexistent-' + Date.now()) + const skills = await loadSkills() + expect(skills).toEqual([]) + }) + + it('loads a valid skill', async () => { + const dir = await makeTempSkillsDir([ + { + dir: 'code-review', + frontmatter: 'name: code-review\ndescription: Review code for quality', + body: 'Review the code carefully.', + }, + ]) + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills).toHaveLength(1) + expect(skills[0]).toMatchObject({ + name: 'code-review', + description: 'Review code for quality', + content: 'Review the code carefully.', + }) + }) + + it('loads multiple skills', async () => { + const dir = await makeTempSkillsDir([ + { + dir: 'skill-a', + frontmatter: 'name: skill-a\ndescription: Skill A', + body: 'Body A', + }, + { + dir: 'skill-b', + frontmatter: 'name: skill-b\ndescription: Skill B', + body: 'Body B', + }, + ]) + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills).toHaveLength(2) + const names = skills.map((s) => s.name).sort() + expect(names).toEqual(['skill-a', 'skill-b']) + }) + + it('skips skill dirs without SKILL.md', async () => { + const dir = await makeTempSkillsDir([ + { + dir: 'valid-skill', + frontmatter: 'name: valid-skill\ndescription: Valid', + body: 'Body', + }, + ]) + // Extra directory with no SKILL.md + await fs.mkdir(path.join(dir, 'empty-dir'), { recursive: true }) + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe('valid-skill') + }) + + it('skips SKILL.md with no frontmatter', async () => { + const dir = path.join(os.tmpdir(), 'xc-skills-nofm-' + Date.now()) + await fs.mkdir(path.join(dir, 'bad-skill'), { recursive: true }) + await fs.writeFile(path.join(dir, 'bad-skill', 'SKILL.md'), 'No frontmatter here.', 'utf-8') + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills).toHaveLength(0) + }) + + it('skips SKILL.md missing required frontmatter fields', async () => { + const dir = await makeTempSkillsDir([ + { + dir: 'no-desc', + frontmatter: 'name: no-desc', // missing description + body: 'Body', + }, + ]) + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills).toHaveLength(0) + }) + + it('strips surrounding quotes from frontmatter values', async () => { + const dir = await makeTempSkillsDir([ + { + dir: 'quoted', + frontmatter: 'name: "quoted-skill"\ndescription: "A quoted description"', + body: 'Body', + }, + ]) + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills[0].name).toBe('quoted-skill') + expect(skills[0].description).toBe('A quoted description') + }) + + it('trims leading/trailing whitespace from body', async () => { + const dir = await makeTempSkillsDir([ + { + dir: 'trim-test', + frontmatter: 'name: trim-test\ndescription: Trim test', + body: '\n\n Body content \n\n', + }, + ]) + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills[0].content).toBe('Body content') + }) +}) + +// ── SkillRegistry ───────────────────────────────────────────────────────────── + +describe('SkillRegistry', () => { + it('starts empty when given no skills', () => { + const registry = new SkillRegistry([]) + expect(registry.list()).toEqual([]) + expect(registry.names()).toEqual([]) + }) + + it('get returns undefined for unknown skill', () => { + const registry = new SkillRegistry([]) + expect(registry.get('nonexistent')).toBeUndefined() + }) + + it('get returns the skill by name', () => { + const registry = new SkillRegistry([ + { name: 'review', description: 'Code review', content: 'Review...', source: 'global' }, + ]) + const skill = registry.get('review') + expect(skill).toBeDefined() + expect(skill!.name).toBe('review') + expect(skill!.content).toBe('Review...') + }) + + it('list returns all skills', () => { + const defs = [ + { name: 'a', description: 'A', content: 'Body A', source: 'global' as const }, + { name: 'b', description: 'B', content: 'Body B', source: 'project' as const }, + ] + const registry = new SkillRegistry(defs) + expect(registry.list()).toHaveLength(2) + }) + + it('names returns all skill names', () => { + const defs = [ + { name: 'alpha', description: 'Alpha', content: '', source: 'global' as const }, + { name: 'beta', description: 'Beta', content: '', source: 'global' as const }, + ] + const registry = new SkillRegistry(defs) + expect(registry.names().sort()).toEqual(['alpha', 'beta']) + }) + + it('project skill overrides global skill with same name', () => { + // loadSkills returns globals first, then project — registry deduplicates + // by last-write-wins, so project wins. + const defs = [ + { name: 'review', description: 'Global review', content: 'Global body', source: 'global' as const }, + { name: 'review', description: 'Project review', content: 'Project body', source: 'project' as const }, + ] + const registry = new SkillRegistry(defs) + expect(registry.list()).toHaveLength(1) + expect(registry.get('review')!.description).toBe('Project review') + expect(registry.get('review')!.source).toBe('project') + }) + + it('different names are not deduplicated', () => { + const defs = [ + { name: 'a', description: 'A', content: '', source: 'global' as const }, + { name: 'b', description: 'B', content: '', source: 'project' as const }, + ] + const registry = new SkillRegistry(defs) + expect(registry.list()).toHaveLength(2) + }) +}) From bcdca8f29940689a532cac67d8b97273c80e33b1 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Fri, 22 May 2026 18:36:10 +0800 Subject: [PATCH 2/5] chore: update vscode config --- .vscode/settings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 6ae09c6..6a440d1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,9 @@ "source.organizeImports": "never" }, "eslint.useFlatConfig": true, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "[markdown]": { + "editor.formatOnSave": false, + "editor.defaultFormatter": "vscode.markdown-language-features" + } } From 80d8d78ad1b82c488084a84537efb960f9c658d6 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Sat, 23 May 2026 12:17:53 +0800 Subject: [PATCH 3/5] chore: wip --- packages/cli/src/ui/components/App.tsx | 9 +- packages/cli/src/ui/components/ChatInput.tsx | 576 +----------------- .../cli/src/ui/components/chat-input/cells.ts | 86 +++ .../src/ui/components/chat-input/palette.ts | 130 ++++ .../ui/components/chat-input/permission.ts | 140 +++++ .../src/ui/components/chat-input/reducer.ts | 52 ++ .../ui/components/chat-input/text-helpers.ts | 82 +++ .../cli/src/ui/components/chat-input/types.ts | 78 +++ 8 files changed, 601 insertions(+), 552 deletions(-) create mode 100644 packages/cli/src/ui/components/chat-input/cells.ts create mode 100644 packages/cli/src/ui/components/chat-input/palette.ts create mode 100644 packages/cli/src/ui/components/chat-input/permission.ts create mode 100644 packages/cli/src/ui/components/chat-input/reducer.ts create mode 100644 packages/cli/src/ui/components/chat-input/text-helpers.ts create mode 100644 packages/cli/src/ui/components/chat-input/types.ts diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index d63d432..26a2772 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -677,10 +677,11 @@ export function App({ // Check if the command matches a loaded skill before giving up. const skill = options.skillRegistry?.get(command) if (skill) { - echoCommand(text) if (arg) { - // Skill + immediate request — inject and submit together so the - // model applies the skill persona to the user's specific ask. + // Skill + immediate request — echo then inject and submit together + // so the model applies the skill persona to the user's specific ask. + // submit is silent so echoCommand provides the visible echo. + echoCommand(text) await submit(`\n${skill.content}\n\n\n${arg}`, { silent: true, }) @@ -688,6 +689,7 @@ export function App({ // No follow-up yet — store as pending so the AI doesn't respond // to the skill XML as if it were a user greeting. The skill // context will be prepended to the user's next real message. + // addCommandMessage handles the echo, so echoCommand is not needed. pendingSkillRef.current = { name: skill.name, content: skill.content } addCommandMessage(text, `Skill **${skill.name}** loaded. Type your request.`) } @@ -1158,7 +1160,6 @@ export function App({ } async function handleSkill(text: string, arg: string) { - echoCommand(text) const parts = arg.trim().split(/\s+/) const sub = parts[0]?.toLowerCase() const subArg = parts.slice(1).join(' ').trim() diff --git a/packages/cli/src/ui/components/ChatInput.tsx b/packages/cli/src/ui/components/ChatInput.tsx index f08718e..0f3f91c 100644 --- a/packages/cli/src/ui/components/ChatInput.tsx +++ b/packages/cli/src/ui/components/ChatInput.tsx @@ -28,7 +28,7 @@ import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } fro import { useStdout } from 'ink' -import { debugLog, getPermissionLevel, suggestRuleLabel } from '@x-code-cli/core' +import { debugLog, suggestRuleLabel } from '@x-code-cli/core' import type { DisplayMessage, TodoItem } from '@x-code-cli/core' import { type FileEntry, applyCompletion, detectAtToken, scoreAndRank } from '../file-completion.js' @@ -60,171 +60,39 @@ import { } from '../terminal-glyphs.js' import { charWidth, sliceByWidth, visualWidth } from '../text-width.js' import { formatTokenCount, getToolInputPreview, getToolLabel, isCollapsibleReadOnlyTool } from '../utils.js' +import { type Cell, ansiTextToCells, cellsEqual, renderRowToAnsi, textToCells } from './chat-input/cells.js' +import { + BSU, + ESU_HIDE, + S_ACCENT_DIM, + S_BLUE_PURPLE, + S_BLUE_PURPLE_BOLD, + S_BOLD, + S_CURSOR, + S_DIM, + S_ERROR_BOLD, + S_GRAY, + S_GRAY_90, + S_NONE, + S_RESET, + S_SPINNER, + S_SUCCESS, + S_SUCCESS_DOT, + S_SUCCESS_DOT_DIM, + S_WARNING_BOLD, +} from './chat-input/palette.js' +import { formatElapsed, permissionContentCells, permissionTitle } from './chat-input/permission.js' +import { inputReducer } from './chat-input/reducer.js' +import { countContentRows, skipByWidth, truncateCellRow, truncatePathFromStart } from './chat-input/text-helpers.js' +import type { MenuItem, PermissionRequest, SelectRequest, SlashCommand, SpinnerState } from './chat-input/types.js' + +export type { PermissionRequest, SelectRequest, SlashCommand, SpinnerState } from './chat-input/types.js' const PASTE_REF_MIN_LINES = 3 const PASTE_REF_MIN_CHARS = 400 const MAX_VISIBLE_LINES = 10 const MAX_AT_COMPLETIONS = 8 -// ── CJK width helpers ─────────────────────────────────────────────────── -// `isWide` / `charWidth` / `visualWidth` / `sliceByWidth` live in -// `../text-width.js` — the single source of truth for the chat-input -// frame, scrollback diff, and markdown table layout. The local helpers -// below build on top of those primitives. - -function truncateCellRow(cells: Cell[], maxWidth: number): Cell[] { - let w = 0 - for (let i = 0; i < cells.length; i++) { - if (w + cells[i]!.width > maxWidth) { - const truncated = cells.slice(0, i) - if (w + 1 <= maxWidth) { - truncated.push({ char: GLYPH_ELLIPSIS, style: cells[i]!.style, width: 1 }) - } - return truncated - } - w += cells[i]!.width - } - return cells -} - -function skipByWidth(str: string, skipCols: number): number { - let w = 0, - i = 0 - for (const ch of str) { - if (w >= skipCols) break - w += charWidth(ch) - i += ch.length - } - return i -} - -/** Truncate a slash-separated path FROM THE START so the basename always - * survives. `packages/core/src/agent/very-long-name.ts` → `…/agent/very-long-name.ts`. - * Only used by the @-completion menu — readers care about WHICH file far - * more than they care about its top-level package, so dropping leading - * directories preserves the most informative chars. Falls back to a - * tail-trim only when the basename itself overflows. */ -function truncatePathFromStart(p: string, maxCols: number): string { - if (visualWidth(p) <= maxCols) return p - const segs = p.split('/') - const basename = segs[segs.length - 1] ?? '' - // Basename alone overflows: tail-trim it (rare — basenames rarely exceed - // a terminal width, but a single very-long file shouldn't crash render). - if (visualWidth(basename) >= maxCols - 1) { - return '…' + basename.slice(basename.length - Math.max(1, maxCols - 1)) - } - let acc = basename - for (let i = segs.length - 2; i >= 0; i--) { - const next = segs[i] + '/' + acc - if (visualWidth('…/' + next) > maxCols) break - acc = next - } - return '…/' + acc -} - -/** Strip ANSI CSI + OSC escape sequences so visual width math ignores them. - * Used to count how many TERMINAL rows a scrollback payload will occupy, - * which drives the pre-scroll line count — over/under-counting would leave - * visible gaps or let content overflow into the frame area. */ -function stripAnsi(s: string): string { - return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') -} - -/** Count display rows that `content` will occupy when written at the top of - * a blank area. Accounts for line wrap at `termWidth` using visual (CJK-aware) - * widths. A trailing `\n` is not counted as a row (cursor just advances to - * the next row but that row has no content). */ -function countContentRows(content: string, termWidth: number): number { - const clean = stripAnsi(content).replace(/\r\n/g, '\n').replace(/\r/g, '') - const lines = clean.split('\n') - const effective = clean.endsWith('\n') ? lines.slice(0, -1) : lines - const w = Math.max(1, termWidth) - let rows = 0 - for (const line of effective) { - rows += Math.max(1, Math.ceil(visualWidth(line) / w)) - } - return rows -} - -// ── Types ─────────────────────────────────────────────────────────────── - -/** One row in the slash-completion menu. Top-level command rows and - * subcommand rows are both rendered through this shape — display columns - * use `name`/`description`, but accept paths use `applyText` so a - * subcommand row (`{ name: 'auth', applyText: '/mcp auth' }`) replaces the - * whole input correctly. */ -interface MenuItem { - name: string - description: string - applyText: string - /** Dim suffix shown after `name` in the menu (e.g. `[on|off]` for - * `/thinking`). Only populated for stage-1 rows; subcommand rows - * don't carry one because the description column already explains - * the shape. */ - argumentHint?: string -} - -export interface SlashCommand { - name: string - description: string - /** Grey placeholder shown after the command name in the slash menu. - * Example: `argumentHint: '[on|off]'` makes the menu line read - * `/thinking [on|off] Toggle extended thinking ...`. Used by - * commands that take args but have no fixed enumerable subcommands - * (e.g. `/model `, `/review [PR]`). */ - argumentHint?: string - /** Fixed enumerable subcommands. When present, typing `/cmd ` (with - * trailing space) or `/cmd ` shows a second-stage fuzzy - * menu over `subcommands` — same UI as the top-level command menu. - * Reserved for commands with many discrete second tokens that are - * easy to forget (`/mcp` has 8). */ - subcommands?: ReadonlyArray<{ name: string; description: string }> -} - -export interface SpinnerState { - label: string - mode: 'requesting' | 'responding' | 'thinking' | 'tool-use' -} - -export interface PermissionRequest { - toolName: string - input: Record - onResolve: (decision: 'yes' | 'always' | 'no') => void - /** Set by use-agent when the tool resolves to an MCP registry entry. - * Drives the MCP-flavoured title / preview / always-allow label in - * the dialog. Absent for built-in tools (shell/edit/writeFile/…). */ - mcp?: { serverName: string; rawName: string } -} - -export interface SelectRequest { - question: string - /** `freeform: true` marks the auto-appended "Other" row that opens an - * inline text input instead of resolving with the literal label. - * Mirrors Claude Code's `__other__` sentinel — kept as a flag here so - * the resolver returns the typed text directly without a sentinel - * round-trip. - * - * `preview` carries pre-rendered ANSI lines that the dialog draws - * below the option list whenever this option is the focused one. - * Used by the `/syntax` picker to show a live color sample of each - * theme as the user arrows through. Each row should already be a - * complete ANSI-styled string — the dialog wraps it in a `RawAnsi`- - * like cell row without further processing. */ - options: { label: string; description: string; freeform?: boolean; preview?: string[] }[] - onResolve: (answer: string) => void - /** True for user-initiated pickers (slash commands like `/syntax`, - * `/model`) — Esc dismisses the dialog with an empty answer. AI- - * initiated dialogs (askUser tool, plan approval) leave this falsy: - * Esc is swallowed so the model isn't silently fed a blank answer. */ - dismissible?: boolean - /** Controls how options with descriptions are rendered: - * - `compact` (default): label and description on the same line, - * right-padded into two aligned columns. Best for short labels. - * - `compact-vertical`: description on a separate indented line - * below the label. Best for long descriptions (askUser). */ - layout?: 'compact' | 'compact-vertical' -} - interface ChatInputProps { /** All scrollback messages. New entries are committed to the terminal * scrollback (above our cell frame) via direct stdout writes. We own the @@ -300,394 +168,6 @@ interface ChatInputProps { contextUsage?: { used: number; window: number } | null } -// ── Reducer for atomic text + cursor updates ────────────────────────── - -interface InputState { - text: string - cursor: number -} - -type InputAction = - | { type: 'INSERT'; pos: number; chunk: string } - | { type: 'BACKSPACE_REF'; pos: number; deleteCount: number } - | { type: 'DELETE'; pos: number } - | { type: 'SET_CURSOR'; cursor: number } - | { type: 'SET_TEXT'; text: string; cursor: number } - | { type: 'RESET' } - -function inputReducer(state: InputState, action: InputAction): InputState { - switch (action.type) { - case 'INSERT': { - const { pos, chunk } = action - return { - text: state.text.slice(0, pos) + chunk + state.text.slice(pos), - cursor: pos + chunk.length, - } - } - case 'BACKSPACE_REF': { - const { pos, deleteCount } = action - if (pos === 0) return state - return { - text: state.text.slice(0, pos - deleteCount) + state.text.slice(pos), - cursor: pos - deleteCount, - } - } - case 'DELETE': { - const { pos } = action - if (pos >= state.text.length) return state - return { text: state.text.slice(0, pos) + state.text.slice(pos + 1), cursor: state.cursor } - } - case 'SET_CURSOR': - return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor } - case 'SET_TEXT': - return { text: action.text, cursor: action.cursor } - case 'RESET': - return { text: '', cursor: 0 } - default: - return state - } -} - -// ── Cell representation ───────────────────────────────────────────────── - -interface Cell { - char: string - style: string - width: number -} - -function cellsEqual(a: Cell, b: Cell): boolean { - return a.char === b.char && a.style === b.style -} - -/** Render a row of cells to a single ANSI-styled string (no cursor moves, - * no trailing erase). Used by the scrollback-commit inline-stream path - * so frame rows can be emitted as part of the `content + frame` stream. */ -function renderRowToAnsi(cells: Cell[]): string { - let out = '\x1b[0m' - let lastStyle = '\x1b[0m' - for (const cell of cells) { - if (cell.style !== lastStyle) { - out += cell.style - lastStyle = cell.style - } - out += cell.char - } - return out + '\x1b[0m' -} - -// ── Palette ───────────────────────────────────────────────────────────── -// Hardcoded RGB ANSI escapes because cells store raw style strings (the -// cell-diff emitter can't run chalk). Values mirror `ui/theme.ts` which -// itself mirrors Claude Code's dark theme (src/utils/theme.ts darkTheme) -// — keep these two tables in sync. -const S_GRAY = '\x1b[38;2;136;136;136m' // promptBorder rgb(136,136,136) #888888 -const S_ACCENT = '\x1b[38;2;215;119;87m' // claude rgb(215,119,87) #d77757 -const S_ACCENT_DIM = '\x1b[38;2;153;153;153m' // inactive rgb(153,153,153) #999999 -const S_SPINNER = '\x1b[38;2;147;165;255m' // claudeBlue rgb(147,165,255) #93a5ff -const S_SUCCESS = '\x1b[38;2;78;186;101;1m' // success rgb(78,186,101) #4eba65 -// Non-bold variant of SUCCESS — used for the live tool `●` bullet so it -// matches the committed `stdout-writer.formatToolCall` output exactly -// (`c.hex(SUCCESS)('●')` is non-bold there). If live used the bold variant, -// the dot would visibly "de-bold" at the moment the tool finishes. -const S_SUCCESS_DOT = '\x1b[0m\x1b[38;2;78;186;101m' -// Dim half of the running-tool bullet pulse animation. Same green hue as -// S_SUCCESS_DOT, but with the ANSI dim attribute (2) layered on top so -// terminals render it as a subdued shade of the same color rather than -// a different color entirely. Toggling between this and S_SUCCESS_DOT -// every few spinner frames produces the bright↔dim "heartbeat" CC uses -// to signal a tool is actively running, so the user can tell at a glance -// which committed line in scrollback turned into the live row. -const S_SUCCESS_DOT_DIM = '\x1b[0m\x1b[38;2;78;186;101;2m' -// Bold with NO foreground color — matches committed `c.bold(label)`. -// Must start with `\x1b[0m` to reset any prior foreground so bold doesn't -// inherit a color from the preceding cell (same reasoning as S_DIM). -const S_BOLD = '\x1b[0m\x1b[1m' -// BLUE_PURPLE (permission #99ccff) — used for the -// `(preview)` inside the live tool bubble to match committed -// `c.hex(BLUE_PURPLE)('(...)')`. Previously used S_SPINNER blue here -// (147,165,255) which is a DIFFERENT shade, producing a visible -// color shift at the live→committed handoff. -const S_BLUE_PURPLE = '\x1b[0m\x1b[38;2;153;204;255m' -const S_BLUE_PURPLE_BOLD = '\x1b[0m\x1b[38;2;153;204;255;1m' -const S_WARNING = '\x1b[38;2;255;193;7m' // warning rgb(255,193,7) #ffc107 -const S_WARNING_BOLD = '\x1b[38;2;255;193;7;1m' -const S_ERROR_BOLD = '\x1b[38;2;255;107;128;1m' -// NB: leading `\x1b[0m` matters. Plain `\x1b[2m` just adds the "dim" -// attribute ON TOP of whatever foreground color is active — so meta -// text rendered after a colored span (e.g. the spinner row, where -// S_SPINNER blue is emitted just before the meta transition) comes out -// as BLUE-dim instead of gray-dim. And on a spinner tick where only -// the seconds cell changes, the diff loop emits S_NONE (reset) first -// and then S_DIM starting from the seconds digit — so the SAME meta -// text is redrawn as WHITE-dim. Result: meta flashes white/blue every -// tick depending on which diff path fires ("一会白一会蓝"). Resetting -// SGR first then applying dim pins the color to the terminal default, -// so meta looks consistent regardless of prior SGR state. -const S_DIM = '\x1b[0m\x1b[2m' -// ANSI 90 (bright black). Equivalent to chalk's `c.gray()` output — -// `c.gray('⎿')` emits `\x1b[90m...\x1b[39m`. Use this for cells that -// MUST visually match a `c.gray()`-styled glyph in committed scrollback -// (currently: the `⎿` connector and the `(duration)` suffix in tool -// rows). S_DIM (`\x1b[2m` = dim attribute on default fg) renders as a -// noticeably different shade than `\x1b[90m` (explicit palette entry) -// on most terminals — the user perceives a color flash on the moment -// a tool finishes and its row switches from live frame to scrollback. -const S_GRAY_90 = '\x1b[0m\x1b[90m' -// S_NONE means "default styling — no fg color, no attribute" and MUST -// be a non-empty escape, otherwise the cell-diff loop's -// `if (cell.style !== lastStyle) buf += cell.style` branch emits an -// empty string and leaves the terminal SGR state inherited from -// whatever preceded it. That used to render rows like -// `[' '(NONE)][glyph(BLUE)][' '(NONE)][T(BLUE)]…` with the trailing -// NONE space inheriting the BLUE — and with non-atomic terminals the -// user perceived the "Thinking" text flashing white→blue between -// frames as redundant SGR codes arrived just after the chars. Setting -// S_NONE to the explicit DEC reset (`\x1b[0m`, same byte as S_RESET) -// makes every NONE cell explicitly clear styling before its glyph, -// which removes the inheritance and the perceived flash. -// Reset ALL attributes at row end (\x1b[0m), not just foreground (\x1b[39m). -// Bold cells (e.g. Permission's Yes/No highlight) would otherwise bleed -// their bold attribute into the next row. The cell-diff emitter re-emits -// any non-empty style on the first cell of the next row, so a full reset -// here is safe. -const S_RESET = '\x1b[0m' -const S_NONE = '\x1b[0m' -// Inverse-video block used to PAINT the input cursor's position as a -// regular cell. The real terminal cursor is hidden app-wide (see the -// useEffect at component mount), so this is the only thing the user -// sees as "the cursor". Updates atomically with the rest of the cell- -// diff frame, so it never flickers on its own. Mirrors Gemini CLI's -// `` approach (renders an inverse-video -// block at the caret position) and Claude Code's same hidden-cursor -// strategy. -const S_CURSOR = '\x1b[7m' - -// NOTE: `\x1b7` / `\x1b8` (DECSC / DECRC) are DELIBERATELY NOT used -// anywhere in this file. The terminal provides a single save register, -// and Ink's own log-update reuses it on every render cycle — co-owning -// it from two places was producing "ghost" restore positions. We -// reconstruct cursor position with relative moves (CUU / CUD / \r / -// \x1b[NG absolute-column) and by treating post-dialog transitions as -// fresh first-paints (prevFrameRef cleared), which removes the cross- -// writer contention entirely. See the wasHidden handler below for the -// transition-case reasoning. - -/** DEC 2026 "Synchronized Update Mode". Between BSU and ESU, supported - * terminals buffer all output and render it as a single atomic frame. - * This eliminates the flash that otherwise occurs between eraseRegion - * wiping the frame and the full re-render that follows — the user sees - * only the final state, never the intermediate blank region. - * Unsupported terminals silently ignore these sequences. - * - * Cursor visibility is intentionally NOT toggled around each render. - * Earlier revisions cycled `\x1b[?25l` in BSU and `\x1b[?25h` in ESU to - * mask the diff-loop's intermediate cursor positions on terminals that - * don't fully atomize DEC 2026. At the 80ms spinner cadence that - * produced a 12Hz hide/show flap which users perceived as "上下抖动" - * flicker around the input row — and sync-mode batching already hides - * the intermediate positions on every terminal we target (xterm.js / - * VSCode, Windows Terminal, iTerm2, Ghostty). So: the cursor stays - * shown throughout; sync mode handles atomicity; the end-of-buf park - * places it at the input column before ESU commits. When there is no - * active anchor (disabled / dialog) ESU_HIDE explicitly hides. */ -const BSU = '\x1b[?2026h' -const ESU_HIDE = '\x1b[?2026l\x1b[?25l' - -// NOTE: a DECSTBM-based `buildInsertHistoryAbove` existed briefly here -// (modeled on codex-rs insert_history.rs) but was reverted because it -// required the cell buffer to be anchored at the very bottom of the -// terminal — true in codex-rs (ratatui's Terminal manages a viewport -// rect), but NOT true in our setup, where the banner + partial scroll -// state can leave the cell buffer mid-screen. Setting a scroll region -// `[1, termRows - cellBufH]` then overlapped the live cell buffer rows, -// so history writes tore through the frame. Re-attempting this fix -// properly needs a "force cell buffer to the last N rows via absolute -// cursor positioning on every render" refactor — tracked separately. - -function textToCells(text: string, style: string): Cell[] { - const cells: Cell[] = [] - for (const ch of text) cells.push({ char: ch, style, width: charWidth(ch) }) - return cells -} - -/** Parse a string that already contains ANSI SGR escapes into Cell[]. Used - * by the select-options dialog's preview pane so a `/syntax` preview row - * built by render-diff (full of fg/bg color escapes) can be drawn into - * the cell buffer with each char carrying its correct active style. - * - * Each cell's `style` is `\x1b[0m` followed by every SGR escape that's - * active at that point — the cell-diff emitter relies on each cell's - * style being self-contained (it just blits `cell.style` on transitions - * without first resetting), so we always lead with reset to wipe - * whatever the previous cell left in the terminal SGR state. SGR resets - * (`\x1b[0m` / `\x1b[m`) clear the active stack; non-reset escapes are - * appended (we don't bother diffing fg-vs-bg-vs-attr buckets, since - * ANSI itself handles late escapes overriding earlier ones — the row - * may emit a few redundant bytes, but it always renders correctly). */ -function ansiTextToCells(text: string): Cell[] { - const cells: Cell[] = [] - const active: string[] = [] - let i = 0 - while (i < text.length) { - const ch = text[i]! - if (ch === '\x1b' && text[i + 1] === '[') { - let j = i + 2 - while (j < text.length && !/[A-Za-z]/.test(text[j]!)) j++ - if (j >= text.length) { - // Unterminated — treat as literal and bail out of escape mode. - i++ - continue - } - const escape = text.slice(i, j + 1) - if (/^\x1b\[0?m$/.test(escape)) { - active.length = 0 - } else if (/^\x1b\[[0-9;]*m$/.test(escape)) { - active.push(escape) - } - // Non-SGR CSI sequences are simply skipped — none should appear - // in our preview rows but we don't want them as visible text. - i = j + 1 - continue - } - const style = active.length === 0 ? S_NONE : '\x1b[0m' + active.join('') - cells.push({ char: ch, style, width: charWidth(ch) }) - i++ - } - return cells -} - -function permissionTitle(toolName: string, mcp?: { serverName: string; rawName: string }): string { - if (mcp) return `X-Code wants to use MCP tool: ${mcp.serverName}/${mcp.rawName}` - switch (toolName) { - case 'shell': - return 'X-Code wants to run a shell command' - case 'writeFile': - return 'X-Code wants to write a file' - case 'edit': - return 'X-Code wants to edit a file' - case 'enterPlanMode': - return 'X-Code wants to enter plan mode' - default: - return `X-Code wants to use ${toolName}` - } -} - -const PERMISSION_LEVEL_STYLE: Record = { - 'always-allow': { label: 'read-only', style: S_SUCCESS }, - ask: { label: 'write', style: S_WARNING }, - deny: { label: 'dangerous', style: S_ERROR_BOLD }, -} - -/** One-line `key: value, key: value` summary of an MCP tool's input. - * Values are JSON-encoded so strings render with their quotes and - * nested objects stay readable; long ones get trimmed before the join - * so a single oversized field can't swallow every other key. The outer - * truncate-to-terminal-width in `permissionContentCells` then caps the - * whole row. */ -function mcpInputPreview(input: Record): string { - const keys = Object.keys(input) - if (keys.length === 0) return '(no args)' - const PER_VALUE_MAX = 60 - const parts = keys.map((k) => { - let v: string - try { - v = JSON.stringify(input[k]) - } catch { - v = String(input[k]) - } - if (v === undefined) v = 'undefined' - if (v.length > PER_VALUE_MAX) v = v.slice(0, PER_VALUE_MAX - 1) + '…' - return `${k}: ${v}` - }) - return parts.join(', ') -} - -function permissionContentCells( - toolName: string, - input: Record, - termWidth: number, - mcp?: { serverName: string; rawName: string }, -): Cell[] | null { - // Frame geometry assumes exactly ONE row per permission content line. - // When a string is longer than termWidth the terminal will auto-wrap it - // onto the next physical row, which breaks every downstream absolute - // cursor position (the Yes/No rows, the input separator, the prompt - // itself) — the dialog appears "half missing" with only the title - // visible. Truncate here so the cell matrix and the on-screen rows - // stay 1:1. Mirrors the tool-bubble preview truncation in the live - // tool-list rendering below. - const truncateToWidth = (text: string, reservedCols: number): string => { - const maxLen = Math.max(10, termWidth - reservedCols) - return text.length > maxLen ? text.slice(0, maxLen - 1) + GLYPH_ELLIPSIS : text - } - if (mcp) { - // One-line `key: value, key: value` preview of the input. MCP tools - // can take arbitrary schemas, so we fall back to a generic serialiser - // rather than trying to guess "the important field". Empty input - // still renders the row (with `(no args)`) so the dialog height - // matches shell/edit and the always-allow row sits where the user - // expects it. - const preview = mcpInputPreview(input) - const cells: Cell[] = [] - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push(...textToCells(truncateToWidth(preview, 2 + 2), S_ACCENT)) - return cells - } - if (toolName === 'shell') { - const level = getPermissionLevel('shell', input) - const info = PERMISSION_LEVEL_STYLE[level] ?? PERMISSION_LEVEL_STYLE.ask - const cells: Cell[] = [] - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push({ char: ' ', style: S_NONE, width: 1 }) - const rawCommand = String(input.command ?? '') - const decoration = 2 + 2 + 1 + (info.label.length + 2) + 2 - const command = truncateToWidth('$ ' + rawCommand, decoration) - cells.push(...textToCells(command, S_ACCENT)) - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push(...textToCells(`[${info.label}]`, info.style)) - return cells - } - if (toolName === 'writeFile') { - const fp = String(input.filePath ?? '') - const cells: Cell[] = [] - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push({ char: ' ', style: S_NONE, width: 1 }) - const suffix = ' (new file)' - const truncated = truncateToWidth(fp, 2 + suffix.length + 2) - cells.push(...textToCells(truncated, S_ACCENT)) - cells.push(...textToCells(suffix, S_ACCENT_DIM)) - return cells - } - if (toolName === 'edit') { - const fp = String(input.filePath ?? '') - const cells: Cell[] = [] - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push(...textToCells(truncateToWidth(fp, 2 + 2), S_ACCENT)) - return cells - } - if (toolName === 'enterPlanMode') { - // Plan-mode entry has no per-call input — describe the consequence - // so the user knows what Yes/No actually means. - const cells: Cell[] = [] - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push(...textToCells('Read-only exploration; no edits until you approve a plan.', S_DIM)) - return cells - } - return null -} - -function formatElapsed(ms: number): string { - const seconds = Math.floor(ms / 1000) - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - const secs = seconds % 60 - return `${minutes}m ${secs}s` -} - // ── Component ─────────────────────────────────────────────────────────── export function ChatInput({ diff --git a/packages/cli/src/ui/components/chat-input/cells.ts b/packages/cli/src/ui/components/chat-input/cells.ts new file mode 100644 index 0000000..cdf236d --- /dev/null +++ b/packages/cli/src/ui/components/chat-input/cells.ts @@ -0,0 +1,86 @@ +// Cell representation + cell-builders for the cell-diff renderer. +// +// Each frame is a 2D grid of Cell. The diff loop in ChatInput.tsx walks +// the grid and only emits SGR/text bytes for cells whose `(char, style)` +// pair changed since the previous frame. `width` lets the diff loop +// skip the trailing half of a CJK pair without re-emitting the glyph. +import { charWidth } from '../../text-width.js' +import { S_NONE } from './palette.js' + +export interface Cell { + char: string + style: string + width: number +} + +export function cellsEqual(a: Cell, b: Cell): boolean { + return a.char === b.char && a.style === b.style +} + +/** Render a row of cells to a single ANSI-styled string (no cursor moves, + * no trailing erase). Used by the scrollback-commit inline-stream path + * so frame rows can be emitted as part of the `content + frame` stream. */ +export function renderRowToAnsi(cells: Cell[]): string { + let out = '\x1b[0m' + let lastStyle = '\x1b[0m' + for (const cell of cells) { + if (cell.style !== lastStyle) { + out += cell.style + lastStyle = cell.style + } + out += cell.char + } + return out + '\x1b[0m' +} + +export function textToCells(text: string, style: string): Cell[] { + const cells: Cell[] = [] + for (const ch of text) cells.push({ char: ch, style, width: charWidth(ch) }) + return cells +} + +/** Parse a string that already contains ANSI SGR escapes into Cell[]. Used + * by the select-options dialog's preview pane so a `/syntax` preview row + * built by render-diff (full of fg/bg color escapes) can be drawn into + * the cell buffer with each char carrying its correct active style. + * + * Each cell's `style` is `\x1b[0m` followed by every SGR escape that's + * active at that point — the cell-diff emitter relies on each cell's + * style being self-contained (it just blits `cell.style` on transitions + * without first resetting), so we always lead with reset to wipe + * whatever the previous cell left in the terminal SGR state. SGR resets + * (`\x1b[0m` / `\x1b[m`) clear the active stack; non-reset escapes are + * appended (we don't bother diffing fg-vs-bg-vs-attr buckets, since + * ANSI itself handles late escapes overriding earlier ones — the row + * may emit a few redundant bytes, but it always renders correctly). */ +export function ansiTextToCells(text: string): Cell[] { + const cells: Cell[] = [] + const active: string[] = [] + let i = 0 + while (i < text.length) { + const ch = text[i]! + if (ch === '\x1b' && text[i + 1] === '[') { + let j = i + 2 + while (j < text.length && !/[A-Za-z]/.test(text[j]!)) j++ + if (j >= text.length) { + // Unterminated — treat as literal and bail out of escape mode. + i++ + continue + } + const escape = text.slice(i, j + 1) + if (/^\x1b\[0?m$/.test(escape)) { + active.length = 0 + } else if (/^\x1b\[[0-9;]*m$/.test(escape)) { + active.push(escape) + } + // Non-SGR CSI sequences are simply skipped — none should appear + // in our preview rows but we don't want them as visible text. + i = j + 1 + continue + } + const style = active.length === 0 ? S_NONE : '\x1b[0m' + active.join('') + cells.push({ char: ch, style, width: charWidth(ch) }) + i++ + } + return cells +} diff --git a/packages/cli/src/ui/components/chat-input/palette.ts b/packages/cli/src/ui/components/chat-input/palette.ts new file mode 100644 index 0000000..d075a21 --- /dev/null +++ b/packages/cli/src/ui/components/chat-input/palette.ts @@ -0,0 +1,130 @@ +// Cell-style palette for ChatInput's direct-stdout cell-diff renderer. +// +// Hardcoded RGB ANSI escapes because cells store raw style strings (the +// cell-diff emitter can't run chalk). Values mirror `ui/theme.ts` which +// itself mirrors Claude Code's dark theme (src/utils/theme.ts darkTheme) +// — keep these two tables in sync. + +export const S_GRAY = '\x1b[38;2;136;136;136m' // promptBorder rgb(136,136,136) #888888 +export const S_ACCENT = '\x1b[38;2;215;119;87m' // claude rgb(215,119,87) #d77757 +export const S_ACCENT_DIM = '\x1b[38;2;153;153;153m' // inactive rgb(153,153,153) #999999 +export const S_SPINNER = '\x1b[38;2;147;165;255m' // claudeBlue rgb(147,165,255) #93a5ff +export const S_SUCCESS = '\x1b[38;2;78;186;101;1m' // success rgb(78,186,101) #4eba65 +// Non-bold variant of SUCCESS — used for the live tool `●` bullet so it +// matches the committed `stdout-writer.formatToolCall` output exactly +// (`c.hex(SUCCESS)('●')` is non-bold there). If live used the bold variant, +// the dot would visibly "de-bold" at the moment the tool finishes. +export const S_SUCCESS_DOT = '\x1b[0m\x1b[38;2;78;186;101m' +// Dim half of the running-tool bullet pulse animation. Same green hue as +// S_SUCCESS_DOT, but with the ANSI dim attribute (2) layered on top so +// terminals render it as a subdued shade of the same color rather than +// a different color entirely. Toggling between this and S_SUCCESS_DOT +// every few spinner frames produces the bright↔dim "heartbeat" CC uses +// to signal a tool is actively running, so the user can tell at a glance +// which committed line in scrollback turned into the live row. +export const S_SUCCESS_DOT_DIM = '\x1b[0m\x1b[38;2;78;186;101;2m' +// Bold with NO foreground color — matches committed `c.bold(label)`. +// Must start with `\x1b[0m` to reset any prior foreground so bold doesn't +// inherit a color from the preceding cell (same reasoning as S_DIM). +export const S_BOLD = '\x1b[0m\x1b[1m' +// BLUE_PURPLE (permission #99ccff) — used for the +// `(preview)` inside the live tool bubble to match committed +// `c.hex(BLUE_PURPLE)('(...)')`. Previously used S_SPINNER blue here +// (147,165,255) which is a DIFFERENT shade, producing a visible +// color shift at the live→committed handoff. +export const S_BLUE_PURPLE = '\x1b[0m\x1b[38;2;153;204;255m' +export const S_BLUE_PURPLE_BOLD = '\x1b[0m\x1b[38;2;153;204;255;1m' +export const S_WARNING = '\x1b[38;2;255;193;7m' // warning rgb(255,193,7) #ffc107 +export const S_WARNING_BOLD = '\x1b[38;2;255;193;7;1m' +export const S_ERROR_BOLD = '\x1b[38;2;255;107;128;1m' +// NB: leading `\x1b[0m` matters. Plain `\x1b[2m` just adds the "dim" +// attribute ON TOP of whatever foreground color is active — so meta +// text rendered after a colored span (e.g. the spinner row, where +// S_SPINNER blue is emitted just before the meta transition) comes out +// as BLUE-dim instead of gray-dim. And on a spinner tick where only +// the seconds cell changes, the diff loop emits S_NONE (reset) first +// and then S_DIM starting from the seconds digit — so the SAME meta +// text is redrawn as WHITE-dim. Result: meta flashes white/blue every +// tick depending on which diff path fires ("一会白一会蓝"). Resetting +// SGR first then applying dim pins the color to the terminal default, +// so meta looks consistent regardless of prior SGR state. +export const S_DIM = '\x1b[0m\x1b[2m' +// ANSI 90 (bright black). Equivalent to chalk's `c.gray()` output — +// `c.gray('⎿')` emits `\x1b[90m...\x1b[39m`. Use this for cells that +// MUST visually match a `c.gray()`-styled glyph in committed scrollback +// (currently: the `⎿` connector and the `(duration)` suffix in tool +// rows). S_DIM (`\x1b[2m` = dim attribute on default fg) renders as a +// noticeably different shade than `\x1b[90m` (explicit palette entry) +// on most terminals — the user perceives a color flash on the moment +// a tool finishes and its row switches from live frame to scrollback. +export const S_GRAY_90 = '\x1b[0m\x1b[90m' +// S_NONE means "default styling — no fg color, no attribute" and MUST +// be a non-empty escape, otherwise the cell-diff loop's +// `if (cell.style !== lastStyle) buf += cell.style` branch emits an +// empty string and leaves the terminal SGR state inherited from +// whatever preceded it. That used to render rows like +// `[' '(NONE)][glyph(BLUE)][' '(NONE)][T(BLUE)]…` with the trailing +// NONE space inheriting the BLUE — and with non-atomic terminals the +// user perceived the "Thinking" text flashing white→blue between +// frames as redundant SGR codes arrived just after the chars. Setting +// S_NONE to the explicit DEC reset (`\x1b[0m`, same byte as S_RESET) +// makes every NONE cell explicitly clear styling before its glyph, +// which removes the inheritance and the perceived flash. +// Reset ALL attributes at row end (\x1b[0m), not just foreground (\x1b[39m). +// Bold cells (e.g. Permission's Yes/No highlight) would otherwise bleed +// their bold attribute into the next row. The cell-diff emitter re-emits +// any non-empty style on the first cell of the next row, so a full reset +// here is safe. +export const S_RESET = '\x1b[0m' +export const S_NONE = '\x1b[0m' +// Inverse-video block used to PAINT the input cursor's position as a +// regular cell. The real terminal cursor is hidden app-wide (see the +// useEffect at component mount), so this is the only thing the user +// sees as "the cursor". Updates atomically with the rest of the cell- +// diff frame, so it never flickers on its own. Mirrors Gemini CLI's +// `` approach (renders an inverse-video +// block at the caret position) and Claude Code's same hidden-cursor +// strategy. +export const S_CURSOR = '\x1b[7m' + +// NOTE: `\x1b7` / `\x1b8` (DECSC / DECRC) are DELIBERATELY NOT used +// anywhere in this file. The terminal provides a single save register, +// and Ink's own log-update reuses it on every render cycle — co-owning +// it from two places was producing "ghost" restore positions. We +// reconstruct cursor position with relative moves (CUU / CUD / \r / +// \x1b[NG absolute-column) and by treating post-dialog transitions as +// fresh first-paints (prevFrameRef cleared), which removes the cross- +// writer contention entirely. See the wasHidden handler in ChatInput +// for the transition-case reasoning. + +/** DEC 2026 "Synchronized Update Mode". Between BSU and ESU, supported + * terminals buffer all output and render it as a single atomic frame. + * This eliminates the flash that otherwise occurs between eraseRegion + * wiping the frame and the full re-render that follows — the user sees + * only the final state, never the intermediate blank region. + * Unsupported terminals silently ignore these sequences. + * + * Cursor visibility is intentionally NOT toggled around each render. + * Earlier revisions cycled `\x1b[?25l` in BSU and `\x1b[?25h` in ESU to + * mask the diff-loop's intermediate cursor positions on terminals that + * don't fully atomize DEC 2026. At the 80ms spinner cadence that + * produced a 12Hz hide/show flap which users perceived as "上下抖动" + * flicker around the input row — and sync-mode batching already hides + * the intermediate positions on every terminal we target (xterm.js / + * VSCode, Windows Terminal, iTerm2, Ghostty). So: the cursor stays + * shown throughout; sync mode handles atomicity; the end-of-buf park + * places it at the input column before ESU commits. When there is no + * active anchor (disabled / dialog) ESU_HIDE explicitly hides. */ +export const BSU = '\x1b[?2026h' +export const ESU_HIDE = '\x1b[?2026l\x1b[?25l' + +// NOTE: a DECSTBM-based `buildInsertHistoryAbove` existed briefly here +// (modeled on codex-rs insert_history.rs) but was reverted because it +// required the cell buffer to be anchored at the very bottom of the +// terminal — true in codex-rs (ratatui's Terminal manages a viewport +// rect), but NOT true in our setup, where the banner + partial scroll +// state can leave the cell buffer mid-screen. Setting a scroll region +// `[1, termRows - cellBufH]` then overlapped the live cell buffer rows, +// so history writes tore through the frame. Re-attempting this fix +// properly needs a "force cell buffer to the last N rows via absolute +// cursor positioning on every render" refactor — tracked separately. diff --git a/packages/cli/src/ui/components/chat-input/permission.ts b/packages/cli/src/ui/components/chat-input/permission.ts new file mode 100644 index 0000000..c0cd239 --- /dev/null +++ b/packages/cli/src/ui/components/chat-input/permission.ts @@ -0,0 +1,140 @@ +// Permission-dialog cell builders + `formatElapsed`. +// +// Lives outside ChatInput.tsx because the permission rendering is a +// self-contained data → Cell[] mapping that has no React state. +import { getPermissionLevel } from '@x-code-cli/core' + +import { GLYPH_ELLIPSIS } from '../../terminal-glyphs.js' +import { type Cell, textToCells } from './cells.js' +import { S_ACCENT, S_ACCENT_DIM, S_DIM, S_ERROR_BOLD, S_NONE, S_SUCCESS, S_WARNING } from './palette.js' + +export function permissionTitle(toolName: string, mcp?: { serverName: string; rawName: string }): string { + if (mcp) return `X-Code wants to use MCP tool: ${mcp.serverName}/${mcp.rawName}` + switch (toolName) { + case 'shell': + return 'X-Code wants to run a shell command' + case 'writeFile': + return 'X-Code wants to write a file' + case 'edit': + return 'X-Code wants to edit a file' + case 'enterPlanMode': + return 'X-Code wants to enter plan mode' + default: + return `X-Code wants to use ${toolName}` + } +} + +const PERMISSION_LEVEL_STYLE: Record = { + 'always-allow': { label: 'read-only', style: S_SUCCESS }, + ask: { label: 'write', style: S_WARNING }, + deny: { label: 'dangerous', style: S_ERROR_BOLD }, +} + +/** One-line `key: value, key: value` summary of an MCP tool's input. + * Values are JSON-encoded so strings render with their quotes and + * nested objects stay readable; long ones get trimmed before the join + * so a single oversized field can't swallow every other key. The outer + * truncate-to-terminal-width in `permissionContentCells` then caps the + * whole row. */ +export function mcpInputPreview(input: Record): string { + const keys = Object.keys(input) + if (keys.length === 0) return '(no args)' + const PER_VALUE_MAX = 60 + const parts = keys.map((k) => { + let v: string + try { + v = JSON.stringify(input[k]) + } catch { + v = String(input[k]) + } + if (v === undefined) v = 'undefined' + if (v.length > PER_VALUE_MAX) v = v.slice(0, PER_VALUE_MAX - 1) + '…' + return `${k}: ${v}` + }) + return parts.join(', ') +} + +export function permissionContentCells( + toolName: string, + input: Record, + termWidth: number, + mcp?: { serverName: string; rawName: string }, +): Cell[] | null { + // Frame geometry assumes exactly ONE row per permission content line. + // When a string is longer than termWidth the terminal will auto-wrap it + // onto the next physical row, which breaks every downstream absolute + // cursor position (the Yes/No rows, the input separator, the prompt + // itself) — the dialog appears "half missing" with only the title + // visible. Truncate here so the cell matrix and the on-screen rows + // stay 1:1. Mirrors the tool-bubble preview truncation in the live + // tool-list rendering below. + const truncateToWidth = (text: string, reservedCols: number): string => { + const maxLen = Math.max(10, termWidth - reservedCols) + return text.length > maxLen ? text.slice(0, maxLen - 1) + GLYPH_ELLIPSIS : text + } + if (mcp) { + // One-line `key: value, key: value` preview of the input. MCP tools + // can take arbitrary schemas, so we fall back to a generic serialiser + // rather than trying to guess "the important field". Empty input + // still renders the row (with `(no args)`) so the dialog height + // matches shell/edit and the always-allow row sits where the user + // expects it. + const preview = mcpInputPreview(input) + const cells: Cell[] = [] + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push(...textToCells(truncateToWidth(preview, 2 + 2), S_ACCENT)) + return cells + } + if (toolName === 'shell') { + const level = getPermissionLevel('shell', input) + const info = PERMISSION_LEVEL_STYLE[level] ?? PERMISSION_LEVEL_STYLE.ask + const cells: Cell[] = [] + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push({ char: ' ', style: S_NONE, width: 1 }) + const rawCommand = String(input.command ?? '') + const decoration = 2 + 2 + 1 + (info.label.length + 2) + 2 + const command = truncateToWidth('$ ' + rawCommand, decoration) + cells.push(...textToCells(command, S_ACCENT)) + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push(...textToCells(`[${info.label}]`, info.style)) + return cells + } + if (toolName === 'writeFile') { + const fp = String(input.filePath ?? '') + const cells: Cell[] = [] + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push({ char: ' ', style: S_NONE, width: 1 }) + const suffix = ' (new file)' + const truncated = truncateToWidth(fp, 2 + suffix.length + 2) + cells.push(...textToCells(truncated, S_ACCENT)) + cells.push(...textToCells(suffix, S_ACCENT_DIM)) + return cells + } + if (toolName === 'edit') { + const fp = String(input.filePath ?? '') + const cells: Cell[] = [] + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push(...textToCells(truncateToWidth(fp, 2 + 2), S_ACCENT)) + return cells + } + if (toolName === 'enterPlanMode') { + // Plan-mode entry has no per-call input — describe the consequence + // so the user knows what Yes/No actually means. + const cells: Cell[] = [] + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push({ char: ' ', style: S_NONE, width: 1 }) + cells.push(...textToCells('Read-only exploration; no edits until you approve a plan.', S_DIM)) + return cells + } + return null +} + +export function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${minutes}m ${secs}s` +} diff --git a/packages/cli/src/ui/components/chat-input/reducer.ts b/packages/cli/src/ui/components/chat-input/reducer.ts new file mode 100644 index 0000000..0cc5b2d --- /dev/null +++ b/packages/cli/src/ui/components/chat-input/reducer.ts @@ -0,0 +1,52 @@ +// Reducer for atomic text + cursor updates in ChatInput. +// +// All mutations to the input buffer go through `useReducer(inputReducer)` +// so a single keypress that both edits text AND moves the cursor commits +// as one state transition (no intermediate frame where the cursor is in +// the wrong place). + +export interface InputState { + text: string + cursor: number +} + +export type InputAction = + | { type: 'INSERT'; pos: number; chunk: string } + | { type: 'BACKSPACE_REF'; pos: number; deleteCount: number } + | { type: 'DELETE'; pos: number } + | { type: 'SET_CURSOR'; cursor: number } + | { type: 'SET_TEXT'; text: string; cursor: number } + | { type: 'RESET' } + +export function inputReducer(state: InputState, action: InputAction): InputState { + switch (action.type) { + case 'INSERT': { + const { pos, chunk } = action + return { + text: state.text.slice(0, pos) + chunk + state.text.slice(pos), + cursor: pos + chunk.length, + } + } + case 'BACKSPACE_REF': { + const { pos, deleteCount } = action + if (pos === 0) return state + return { + text: state.text.slice(0, pos - deleteCount) + state.text.slice(pos), + cursor: pos - deleteCount, + } + } + case 'DELETE': { + const { pos } = action + if (pos >= state.text.length) return state + return { text: state.text.slice(0, pos) + state.text.slice(pos + 1), cursor: state.cursor } + } + case 'SET_CURSOR': + return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor } + case 'SET_TEXT': + return { text: action.text, cursor: action.cursor } + case 'RESET': + return { text: '', cursor: 0 } + default: + return state + } +} diff --git a/packages/cli/src/ui/components/chat-input/text-helpers.ts b/packages/cli/src/ui/components/chat-input/text-helpers.ts new file mode 100644 index 0000000..bdd6732 --- /dev/null +++ b/packages/cli/src/ui/components/chat-input/text-helpers.ts @@ -0,0 +1,82 @@ +// Width/path/ANSI helpers used by the ChatInput cell-diff renderer. +// `isWide` / `charWidth` / `visualWidth` / `sliceByWidth` live in +// `../../text-width.js` — the single source of truth for the chat-input +// frame, scrollback diff, and markdown table layout. The helpers below +// build on top of those primitives. +import { GLYPH_ELLIPSIS } from '../../terminal-glyphs.js' +import { charWidth, visualWidth } from '../../text-width.js' +import type { Cell } from './cells.js' + +export function truncateCellRow(cells: Cell[], maxWidth: number): Cell[] { + let w = 0 + for (let i = 0; i < cells.length; i++) { + if (w + cells[i]!.width > maxWidth) { + const truncated = cells.slice(0, i) + if (w + 1 <= maxWidth) { + truncated.push({ char: GLYPH_ELLIPSIS, style: cells[i]!.style, width: 1 }) + } + return truncated + } + w += cells[i]!.width + } + return cells +} + +export function skipByWidth(str: string, skipCols: number): number { + let w = 0, + i = 0 + for (const ch of str) { + if (w >= skipCols) break + w += charWidth(ch) + i += ch.length + } + return i +} + +/** Truncate a slash-separated path FROM THE START so the basename always + * survives. `packages/core/src/agent/very-long-name.ts` → `…/agent/very-long-name.ts`. + * Only used by the @-completion menu — readers care about WHICH file far + * more than they care about its top-level package, so dropping leading + * directories preserves the most informative chars. Falls back to a + * tail-trim only when the basename itself overflows. */ +export function truncatePathFromStart(p: string, maxCols: number): string { + if (visualWidth(p) <= maxCols) return p + const segs = p.split('/') + const basename = segs[segs.length - 1] ?? '' + // Basename alone overflows: tail-trim it (rare — basenames rarely exceed + // a terminal width, but a single very-long file shouldn't crash render). + if (visualWidth(basename) >= maxCols - 1) { + return '…' + basename.slice(basename.length - Math.max(1, maxCols - 1)) + } + let acc = basename + for (let i = segs.length - 2; i >= 0; i--) { + const next = segs[i] + '/' + acc + if (visualWidth('…/' + next) > maxCols) break + acc = next + } + return '…/' + acc +} + +/** Strip ANSI CSI + OSC escape sequences so visual width math ignores them. + * Used to count how many TERMINAL rows a scrollback payload will occupy, + * which drives the pre-scroll line count — over/under-counting would leave + * visible gaps or let content overflow into the frame area. */ +export function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') +} + +/** Count display rows that `content` will occupy when written at the top of + * a blank area. Accounts for line wrap at `termWidth` using visual (CJK-aware) + * widths. A trailing `\n` is not counted as a row (cursor just advances to + * the next row but that row has no content). */ +export function countContentRows(content: string, termWidth: number): number { + const clean = stripAnsi(content).replace(/\r\n/g, '\n').replace(/\r/g, '') + const lines = clean.split('\n') + const effective = clean.endsWith('\n') ? lines.slice(0, -1) : lines + const w = Math.max(1, termWidth) + let rows = 0 + for (const line of effective) { + rows += Math.max(1, Math.ceil(visualWidth(line) / w)) + } + return rows +} diff --git a/packages/cli/src/ui/components/chat-input/types.ts b/packages/cli/src/ui/components/chat-input/types.ts new file mode 100644 index 0000000..d167fce --- /dev/null +++ b/packages/cli/src/ui/components/chat-input/types.ts @@ -0,0 +1,78 @@ +// ChatInput public + internal data types. + +/** One row in the slash-completion menu. Top-level command rows and + * subcommand rows are both rendered through this shape — display columns + * use `name`/`description`, but accept paths use `applyText` so a + * subcommand row (`{ name: 'auth', applyText: '/mcp auth' }`) replaces the + * whole input correctly. */ +export interface MenuItem { + name: string + description: string + applyText: string + /** Dim suffix shown after `name` in the menu (e.g. `[on|off]` for + * `/thinking`). Only populated for stage-1 rows; subcommand rows + * don't carry one because the description column already explains + * the shape. */ + argumentHint?: string +} + +export interface SlashCommand { + name: string + description: string + /** Grey placeholder shown after the command name in the slash menu. + * Example: `argumentHint: '[on|off]'` makes the menu line read + * `/thinking [on|off] Toggle extended thinking ...`. Used by + * commands that take args but have no fixed enumerable subcommands + * (e.g. `/model `, `/review [PR]`). */ + argumentHint?: string + /** Fixed enumerable subcommands. When present, typing `/cmd ` (with + * trailing space) or `/cmd ` shows a second-stage fuzzy + * menu over `subcommands` — same UI as the top-level command menu. + * Reserved for commands with many discrete second tokens that are + * easy to forget (`/mcp` has 8). */ + subcommands?: ReadonlyArray<{ name: string; description: string }> +} + +export interface SpinnerState { + label: string + mode: 'requesting' | 'responding' | 'thinking' | 'tool-use' +} + +export interface PermissionRequest { + toolName: string + input: Record + onResolve: (decision: 'yes' | 'always' | 'no') => void + /** Set by use-agent when the tool resolves to an MCP registry entry. + * Drives the MCP-flavoured title / preview / always-allow label in + * the dialog. Absent for built-in tools (shell/edit/writeFile/…). */ + mcp?: { serverName: string; rawName: string } +} + +export interface SelectRequest { + question: string + /** `freeform: true` marks the auto-appended "Other" row that opens an + * inline text input instead of resolving with the literal label. + * Mirrors Claude Code's `__other__` sentinel — kept as a flag here so + * the resolver returns the typed text directly without a sentinel + * round-trip. + * + * `preview` carries pre-rendered ANSI lines that the dialog draws + * below the option list whenever this option is the focused one. + * Used by the `/syntax` picker to show a live color sample of each + * theme as the user arrows through. Each row should already be a + * complete ANSI-styled string — the dialog wraps it in a `RawAnsi`- + * like cell row without further processing. */ + options: { label: string; description: string; freeform?: boolean; preview?: string[] }[] + onResolve: (answer: string) => void + /** True for user-initiated pickers (slash commands like `/syntax`, + * `/model`) — Esc dismisses the dialog with an empty answer. AI- + * initiated dialogs (askUser tool, plan approval) leave this falsy: + * Esc is swallowed so the model isn't silently fed a blank answer. */ + dismissible?: boolean + /** Controls how options with descriptions are rendered: + * - `compact` (default): label and description on the same line, + * right-padded into two aligned columns. Best for short labels. + * - `compact-vertical`: description on a separate indented line + * below the label. Best for long descriptions (askUser). */ + layout?: 'compact' | 'compact-vertical' +} From b50c29c92ebad85ba94fe4df0b6bc00682328ac2 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Sat, 23 May 2026 15:56:28 +0800 Subject: [PATCH 4/5] feat(cli): /skill disable|enable|remove + fix long-description menu render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two threads land together — a menu-render bug surfaced when a skill description exceeded terminal width, which uncovered the missing disable/enable infrastructure and a YAML parser gap. - ChatInput.tsx + chat-input/text-helpers.ts: slash menu now wraps each description across up to 2 rows (wrapCellsToRows helper, ellipsis on overflow). Previously a row wider than termWidth would hard-wrap at the physical-row level — cell-diff treated it as a single grid row, so subsequent [K clears missed the wrapped overflow, and when the wrap spilled past the last terminal row the viewport scrolled, drifting the frame out of sync with lastFrameTopRef and producing phantom input boxes on every menu open/dismiss cycle. - skills/loader.ts + sub-agents/loader.ts: YAML frontmatter parser now folds indented continuation lines into the previous key's value (standard folded-scalar behavior). Without this, SKILL.md with a multi-line description: was silently truncated to the first line. - /skill disable|enable|remove: disable/enable write ~/.x-code/settings.json (global) or .x-code/settings.local.json (project) under {disabledSkills} — union across scopes. remove deletes the skill directory and clears both scopes' disable entries to avoid stale entries silencing a future re-install of the same name. Default --scope = the skill's own source. Each command prints "Restart the CLI to apply." SkillRegistry filters disabled on list/names/get; listAll/getEntry expose disabled state to the /skill list output (now annotated with [on]/[off] tags). - loop.ts: one-line debug log agent.skills.system-prompt prints the enabled and disabled names going into the prompt at session start, so users can verify disable actually hides the skill from the LLM. - Tests: registry filter, folded-scalar description, settings noop + unrelated-field preservation. --- packages/cli/src/ui/components/App.tsx | 144 +++++++++++++++++- packages/cli/src/ui/components/ChatInput.tsx | 57 ++++--- .../ui/components/chat-input/text-helpers.ts | 33 ++++ packages/core/src/agent/loop.ts | 12 ++ packages/core/src/agent/sub-agents/loader.ts | 12 ++ packages/core/src/index.ts | 4 +- packages/core/src/skills/loader.ts | 13 ++ packages/core/src/skills/registry.ts | 45 +++++- packages/core/src/skills/settings.ts | 104 +++++++++++++ packages/core/tests/skills.test.ts | 95 ++++++++++++ 10 files changed, 486 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/skills/settings.ts diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 26a2772..07df7fa 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -17,6 +17,7 @@ import { getAvailableProviders, getContextWindow, getMcpConfigPath, + getScopedDisabledSkills, getTokenStorage, listSessions, loadMergedConfigsFromDisk, @@ -31,10 +32,19 @@ import { resolveModelId, saveUserConfig, serverExists, + setSkillDisabled, + skillSettingsPath, trustProject, writeServerToConfig, } from '@x-code-cli/core' -import type { AgentOptions, KnowledgeFact, LanguageModel, LoadedSession, TokenUsage } from '@x-code-cli/core' +import type { + AgentOptions, + KnowledgeFact, + LanguageModel, + LoadedSession, + SkillSettingsScope, + TokenUsage, +} from '@x-code-cli/core' import { VERSION } from '../../version.js' import { useAgent } from '../hooks/use-agent.js' @@ -120,7 +130,10 @@ export const SLASH_COMMANDS = [ description: 'Manage skills', subcommands: [ { name: 'install', description: 'Fetch and install a skill from a URL' }, - { name: 'list', description: 'List installed skills' }, + { name: 'list', description: 'List installed skills (with on/off state)' }, + { name: 'disable', description: 'Disable a skill (kept on disk, takes effect after restart)' }, + { name: 'enable', description: 'Re-enable a previously disabled skill' }, + { name: 'remove', description: 'Delete a skill directory from disk' }, ], }, { name: '/exit', description: 'Exit (flushes session)' }, @@ -1159,6 +1172,27 @@ export function App({ return match ? match[1].trim() : null } + /** Split a skill argument into `(name, scope)`, recognizing + * `--scope=global` / `--scope=project` / `-s=global` etc. Bare arg with + * no flag returns `scope: undefined` so the caller can default off the + * skill's source. Unknown scope strings are ignored (scope stays + * undefined) — keeps the parser permissive. */ + function parseSkillScopeFlag(arg: string): { name: string; scope?: SkillSettingsScope } { + const tokens = arg.split(/\s+/).filter(Boolean) + let scope: SkillSettingsScope | undefined + const remaining: string[] = [] + for (const tok of tokens) { + const m = tok.match(/^(?:--scope|-s)(?:=(.+))?$/) + if (m) { + const value = m[1]?.toLowerCase() + if (value === 'global' || value === 'project') scope = value + continue + } + remaining.push(tok) + } + return { name: remaining.join(' '), scope } + } + async function handleSkill(text: string, arg: string) { const parts = arg.trim().split(/\s+/) const sub = parts[0]?.toLowerCase() @@ -1200,18 +1234,118 @@ export function App({ } if (sub === 'list') { - const skills = options.skillRegistry?.list() ?? [] + const skills = options.skillRegistry?.listAll() ?? [] if (skills.length === 0) { const skillsPath = path.join(GLOBAL_XCODE_DIR, 'skills', '', 'SKILL.md') addCommandMessage(text, `No skills loaded. Place SKILL.md files in \`${skillsPath}\` and restart.`) return } - const lines = skills.map((s) => `- **${s.name}** (${s.source}): ${s.description}`) + const lines = skills.map((s) => { + const tag = s.disabled ? '[off]' : '[on] ' + return `- ${tag} **${s.name}** (${s.source}): ${s.description}` + }) addCommandMessage(text, `**Loaded skills** (${skills.length}):\n\n${lines.join('\n')}`) return } - addCommandMessage(text, 'Usage: `/skill install ` · `/skill list`') + if (sub === 'disable' || sub === 'enable') { + const name = subArg.trim() + if (!name) { + addCommandMessage(text, `Usage: \`/skill ${sub} [--scope=global|project]\``) + return + } + const { name: bareName, scope } = parseSkillScopeFlag(name) + const entry = options.skillRegistry?.getEntry(bareName) + if (!entry) { + addCommandMessage( + text, + `No skill named \`${bareName}\` is loaded. Run \`/skill list\` to see available skills.`, + ) + return + } + // Default the disable scope to the skill's own source so users get the + // expected "disable the project skill yansu" without typing --scope. + // Re-enable is symmetric: clear from the source scope first; if the + // skill is still effectively disabled it's because the OTHER scope + // also lists it, and we'll surface that. + const effectiveScope: SkillSettingsScope = scope ?? entry.source + const disable = sub === 'disable' + let result: 'changed' | 'noop' + try { + result = await setSkillDisabled(bareName, effectiveScope, disable) + } catch (err) { + addCommandMessage(text, `Failed to update settings: ${err instanceof Error ? err.message : String(err)}`) + return + } + const settingsFile = skillSettingsPath(effectiveScope) + if (result === 'noop') { + addCommandMessage( + text, + disable + ? `Skill **${bareName}** is already disabled in ${effectiveScope} settings (\`${settingsFile}\`).` + : `Skill **${bareName}** is not disabled in ${effectiveScope} settings (\`${settingsFile}\`).`, + ) + return + } + // After re-enable, check whether the other scope is still hiding it + // — common pitfall when the user disables globally and then expects + // a project-level enable to revive it. + let otherScopeNote = '' + if (!disable) { + const other: SkillSettingsScope = effectiveScope === 'global' ? 'project' : 'global' + try { + const stillDisabled = (await getScopedDisabledSkills(other)).includes(bareName) + if (stillDisabled) { + otherScopeNote = `\n\n_Note: \`${bareName}\` is also listed in ${other} settings (\`${skillSettingsPath(other)}\`). Run \`/skill enable ${bareName} --scope=${other}\` to fully re-enable._` + } + } catch { + // best-effort hint — silent failure is fine + } + } + const verb = disable ? 'Disabled' : 'Enabled' + addCommandMessage( + text, + `${verb} skill **${bareName}** in ${effectiveScope} settings (\`${settingsFile}\`).${otherScopeNote}\n\nRestart the CLI to apply.`, + ) + return + } + + if (sub === 'remove') { + const name = subArg.trim() + if (!name) { + addCommandMessage(text, 'Usage: `/skill remove `') + return + } + const entry = options.skillRegistry?.getEntry(name) + if (!entry) { + addCommandMessage(text, `No skill named \`${name}\` is loaded. Run \`/skill list\` to see available skills.`) + return + } + const baseDir = entry.source === 'global' ? GLOBAL_XCODE_DIR : path.join(process.cwd(), '.x-code') + const skillDir = path.join(baseDir, 'skills', name) + try { + await fs.rm(skillDir, { recursive: true, force: true }) + } catch (err) { + addCommandMessage(text, `Failed to remove \`${skillDir}\`: ${err instanceof Error ? err.message : String(err)}`) + return + } + // Also clear any disable entries — leaving stale entries pointing + // at a removed skill would silently swallow a future re-install + // with the same name (it'd come back disabled). + try { + await setSkillDisabled(name, 'global', false) + await setSkillDisabled(name, 'project', false) + } catch { + // best-effort — main rm already succeeded + } + addCommandMessage(text, `Removed skill **${name}** from \`${skillDir}\`.\n\nRestart the CLI to apply.`) + return + } + + addCommandMessage( + text, + 'Usage: `/skill install ` · `/skill list` · `/skill disable ` · `/skill enable ` · `/skill remove `', + ) } /** Skills and MCP server config changes all require a CLI restart to take diff --git a/packages/cli/src/ui/components/ChatInput.tsx b/packages/cli/src/ui/components/ChatInput.tsx index 0f3f91c..2ebc5b6 100644 --- a/packages/cli/src/ui/components/ChatInput.tsx +++ b/packages/cli/src/ui/components/ChatInput.tsx @@ -83,7 +83,13 @@ import { } from './chat-input/palette.js' import { formatElapsed, permissionContentCells, permissionTitle } from './chat-input/permission.js' import { inputReducer } from './chat-input/reducer.js' -import { countContentRows, skipByWidth, truncateCellRow, truncatePathFromStart } from './chat-input/text-helpers.js' +import { + countContentRows, + skipByWidth, + truncateCellRow, + truncatePathFromStart, + wrapCellsToRows, +} from './chat-input/text-helpers.js' import type { MenuItem, PermissionRequest, SelectRequest, SlashCommand, SpinnerState } from './chat-input/types.js' export type { PermissionRequest, SelectRequest, SlashCommand, SpinnerState } from './chat-input/types.js' @@ -2060,30 +2066,43 @@ export function ChatInput({ const hintW = cmd.argumentHint ? cmd.argumentHint.length + 1 : 0 return Math.max(max, cmd.name.length + hintW) }, 0) + // Each description is wrapped across up to 2 rows; a description that + // still overflows gets an ellipsis at the end of row 2. Truncation is + // required: a row wider than termWidth hard-wraps at the physical-row + // level (cell-diff treats it as one grid row, so [K clears miss the + // wrapped overflow) and, when it spills past the last terminal row, + // scrolls the viewport — drifting the frame out of sync with + // lastFrameTopRef and leaving a phantom input box on every menu + // open/dismiss cycle. + const maxRowWidth = Math.max(20, termWidth - 1) + const descCol = labelWidth + 4 // 2-space gutter + label area (labelWidth + 2-space pad) + const descWidth = Math.max(10, maxRowWidth - descCol) for (let i = 0; i < matches.length; i++) { const cmd = matches[i] const sel = i === safeIndex - const cells: Cell[] = [] - cells.push({ char: ' ', style: S_NONE, width: 1 }) - cells.push({ char: ' ', style: S_NONE, width: 1 }) const labelLen = cmd.name.length + (cmd.argumentHint ? cmd.argumentHint.length + 1 : 0) const padRight = ' '.repeat(Math.max(2, labelWidth + 2 - labelLen)) - if (sel) { - cells.push(...textToCells(cmd.name, S_BLUE_PURPLE_BOLD)) - if (cmd.argumentHint) { - cells.push(...textToCells(' ', S_NONE)) - cells.push(...textToCells(cmd.argumentHint, S_DIM)) - } - cells.push(...textToCells(padRight, S_NONE)) - cells.push(...textToCells(cmd.description, S_RESET)) - } else { - cells.push(...textToCells(cmd.name, S_DIM)) - if (cmd.argumentHint) { - cells.push(...textToCells(' ' + cmd.argumentHint, S_DIM)) - } - cells.push(...textToCells(padRight + cmd.description, S_DIM)) + const padStyle = sel ? S_NONE : S_DIM + const descStyle = sel ? S_RESET : S_DIM + + const labelCells: Cell[] = [] + labelCells.push({ char: ' ', style: S_NONE, width: 1 }) + labelCells.push({ char: ' ', style: S_NONE, width: 1 }) + labelCells.push(...textToCells(cmd.name, sel ? S_BLUE_PURPLE_BOLD : S_DIM)) + if (cmd.argumentHint) { + labelCells.push(...textToCells(' ', padStyle)) + labelCells.push(...textToCells(cmd.argumentHint, S_DIM)) + } + labelCells.push(...textToCells(padRight, padStyle)) + + const descRows = wrapCellsToRows(textToCells(cmd.description, descStyle), descWidth, 2) + const row1: Cell[] = [...labelCells, ...(descRows[0] ?? [])] + frame.push(truncateCellRow(row1, maxRowWidth)) + if (descRows.length > 1) { + const indent: Cell[] = [] + for (let k = 0; k < descCol; k++) indent.push({ char: ' ', style: S_NONE, width: 1 }) + frame.push(truncateCellRow([...indent, ...descRows[1]!], maxRowWidth)) } - frame.push(cells) } } else if (activeMenu === 'at') { if (atMatches.length === 0) { diff --git a/packages/cli/src/ui/components/chat-input/text-helpers.ts b/packages/cli/src/ui/components/chat-input/text-helpers.ts index bdd6732..94ed6e7 100644 --- a/packages/cli/src/ui/components/chat-input/text-helpers.ts +++ b/packages/cli/src/ui/components/chat-input/text-helpers.ts @@ -22,6 +22,39 @@ export function truncateCellRow(cells: Cell[], maxWidth: number): Cell[] { return cells } +/** Hard-wrap `cells` across up to `maxRows` rows of `maxWidth` width each. + * When content overflows the row budget, trims trailing cells from the + * last row and appends an ellipsis. Char-based wrap (no word boundaries) + * — same model as `truncateCellRow`, just multi-row. */ +export function wrapCellsToRows(cells: Cell[], maxWidth: number, maxRows: number): Cell[][] { + if (maxRows <= 0 || maxWidth <= 0) return [] + const rows: Cell[][] = [] + let current: Cell[] = [] + let currentWidth = 0 + for (let i = 0; i < cells.length; i++) { + const c = cells[i]! + if (currentWidth + c.width > maxWidth) { + rows.push(current) + if (rows.length >= maxRows) { + const last = rows[rows.length - 1]! + let lastW = currentWidth + const ellipsisStyle = last.length > 0 ? last[last.length - 1]!.style : c.style + while (last.length > 0 && lastW + 1 > maxWidth) { + lastW -= last.pop()!.width + } + last.push({ char: GLYPH_ELLIPSIS, style: ellipsisStyle, width: 1 }) + return rows + } + current = [] + currentWidth = 0 + } + current.push(c) + currentWidth += c.width + } + if (current.length > 0) rows.push(current) + return rows +} + export function skipByWidth(str: string, skipCols: number): number { let w = 0, i = 0 diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 007cfed..960131d 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -511,6 +511,18 @@ export async function agentLoop( // for as long as the mode is active. Only the boundary turn pays the // cache miss. if (!state.systemPromptCache) { + // Names actually going into the system prompt — used to verify that + // disabled skills are filtered out (registry.list() drops them) and + // that the names you see match the registry's enabled set. Fires + // once per session because the prompt is built once and cached. + if (options.skillRegistry) { + const enabled = options.skillRegistry.list().map((s) => s.name) + const disabled = options.skillRegistry + .listAll() + .filter((s) => s.disabled) + .map((s) => s.name) + debugLog('agent.skills.system-prompt', `enabled=[${enabled.join(',')}] disabled=[${disabled.join(',')}]`) + } state.systemPromptCache = buildSystemPrompt({ knowledgeContext: fullKnowledgeContext, modelId: options.modelId, diff --git a/packages/core/src/agent/sub-agents/loader.ts b/packages/core/src/agent/sub-agents/loader.ts index a33e9f9..105cd82 100644 --- a/packages/core/src/agent/sub-agents/loader.ts +++ b/packages/core/src/agent/sub-agents/loader.ts @@ -32,7 +32,19 @@ function parseFrontmatter(raw: string): { data: Record; body: s const body = match[2]! const data: Record = {} + // Fold YAML continuation lines: an indented non-empty line is joined to + // the previous line with a single space. Matches the folded-scalar form + // commonly used for long `description:` values in agent frontmatter. + const foldedLines: string[] = [] for (const line of yamlBlock.split(/\r?\n/)) { + if (/^\s/.test(line) && line.trim() && foldedLines.length > 0) { + foldedLines[foldedLines.length - 1] += ' ' + line.trim() + } else { + foldedLines.push(line) + } + } + + for (const line of foldedLines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9823f4f..d7efc39 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,7 +80,9 @@ export type { SubAgentDefinition, SubAgentEvent, SubAgentTrace } from './agent/s // Skills export { SkillRegistry, createSkillRegistry } from './skills/registry.js' -export type { SkillDefinition } from './skills/registry.js' +export type { SkillDefinition, SkillEntry } from './skills/registry.js' +export { getScopedDisabledSkills, setSkillDisabled, skillSettingsPath } from './skills/settings.js' +export type { SkillSettingsScope } from './skills/settings.js' // Session store (per-session jsonl transcript — used by /resume, // /usage history, and the CLI startup --resume / --continue flags). diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts index 2a4cd9a..b979a3a 100644 --- a/packages/core/src/skills/loader.ts +++ b/packages/core/src/skills/loader.ts @@ -33,7 +33,20 @@ function parseFrontmatter(raw: string): { data: Record; body: s const body = match[2]! const data: Record = {} + // Fold YAML continuation lines: an indented non-empty line is joined to + // the previous line with a single space. Mirrors the folded-scalar form + // used by skill SKILL.md files where a long `description:` is wrapped + // with 2-space indented continuations. + const foldedLines: string[] = [] for (const line of yamlBlock.split(/\r?\n/)) { + if (/^\s/.test(line) && line.trim() && foldedLines.length > 0) { + foldedLines[foldedLines.length - 1] += ' ' + line.trim() + } else { + foldedLines.push(line) + } + } + + for (const line of foldedLines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue diff --git a/packages/core/src/skills/registry.ts b/packages/core/src/skills/registry.ts index 682c996..e85d7a5 100644 --- a/packages/core/src/skills/registry.ts +++ b/packages/core/src/skills/registry.ts @@ -1,5 +1,13 @@ // @x-code-cli/core — Skill registry +// +// Built once at CLI startup and frozen for the session. Adding, removing, +// enabling, or disabling a skill requires a CLI restart: the skill list is +// embedded in the system prompt and exposed as slash commands, and both +// caches assume byte-stable inputs for the whole session (CLAUDE.md). The +// /skill disable|enable|remove handlers write settings to disk and print a +// "Restart the CLI to apply." hint — they never mutate this registry. import { loadSkills } from './loader.js' +import { loadDisabledSkillsSet } from './settings.js' export interface SkillDefinition { name: string @@ -8,32 +16,53 @@ export interface SkillDefinition { source: 'global' | 'project' } +export interface SkillEntry extends SkillDefinition { + disabled: boolean +} + export class SkillRegistry { - private readonly byName: Map + private readonly byName: Map - constructor(skills: SkillDefinition[]) { + constructor(skills: SkillDefinition[], disabled: ReadonlySet = new Set()) { this.byName = new Map() // Last-write wins: project skills override globals of the same name // because loadSkills() returns globals first, then project skills. for (const skill of skills) { - this.byName.set(skill.name, skill) + this.byName.set(skill.name, { ...skill, disabled: disabled.has(skill.name) }) } } + /** Enabled skill by name. Disabled skills are hidden from the agent loop + * and slash-command dispatch — use `getEntry()` if you need to inspect + * the disabled flag (the /skill list handler does). */ get(name: string): SkillDefinition | undefined { - return this.byName.get(name) + const entry = this.byName.get(name) + if (!entry || entry.disabled) return undefined + return entry } + /** Enabled skills only. */ list(): SkillDefinition[] { - return [...this.byName.values()] + return [...this.byName.values()].filter((s) => !s.disabled) } + /** Enabled skill names only. */ names(): string[] { - return [...this.byName.keys()] + return [...this.byName.values()].filter((s) => !s.disabled).map((s) => s.name) + } + + /** Every loaded skill, with `disabled` flag. Used by /skill list and the + * disable/enable/remove handlers so they can act on disabled skills too. */ + listAll(): SkillEntry[] { + return [...this.byName.values()] + } + + getEntry(name: string): SkillEntry | undefined { + return this.byName.get(name) } } export async function createSkillRegistry(): Promise { - const skills = await loadSkills() - return new SkillRegistry(skills) + const [skills, disabled] = await Promise.all([loadSkills(), loadDisabledSkillsSet()]) + return new SkillRegistry(skills, disabled) } diff --git a/packages/core/src/skills/settings.ts b/packages/core/src/skills/settings.ts new file mode 100644 index 0000000..dcb2b75 --- /dev/null +++ b/packages/core/src/skills/settings.ts @@ -0,0 +1,104 @@ +// Skill settings — disabledSkills list per scope. +// +// Global scope: ~/.x-code/settings.json +// Project scope: /.x-code/settings.local.json (gitignored) +// +// Both files share the shape `{ disabledSkills?: string[] }`. A skill is +// effectively disabled when its name appears in EITHER scope's list — we +// take the union, not an override. To re-enable from a global disable +// while keeping it disabled elsewhere, remove the name from the global +// list. The settings files are session-immutable: SkillRegistry filters +// on this list at startup, so toggle/remove takes effect on next launch. +import fs from 'node:fs/promises' +import path from 'node:path' + +import { GLOBAL_XCODE_DIR, XCODE_DIR } from '../utils.js' + +export type SkillSettingsScope = 'global' | 'project' + +export interface SkillSettings { + disabledSkills?: string[] +} + +export function skillSettingsPath(scope: SkillSettingsScope): string { + if (scope === 'global') return path.join(GLOBAL_XCODE_DIR, 'settings.json') + return path.join(process.cwd(), XCODE_DIR, 'settings.local.json') +} + +async function readSettings(scope: SkillSettingsScope): Promise { + const file = skillSettingsPath(scope) + try { + const raw = await fs.readFile(file, 'utf-8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') return {} + const obj = parsed as Record + const list = Array.isArray(obj.disabledSkills) + ? obj.disabledSkills.filter((s): s is string => typeof s === 'string') + : [] + return { disabledSkills: list } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {} + // Malformed JSON: ignore + return empty so a broken settings file never + // blocks startup. The user can fix the file and re-launch. + return {} + } +} + +async function writeSettings(scope: SkillSettingsScope, settings: SkillSettings): Promise { + const file = skillSettingsPath(scope) + await fs.mkdir(path.dirname(file), { recursive: true }) + // Read-modify-write: settings.json may carry unrelated fields later. We + // re-read the raw object, splice in the updated `disabledSkills` array, + // and write back so a future schema addition isn't clobbered. + let existing: Record = {} + try { + const raw = await fs.readFile(file, 'utf-8') + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === 'object') existing = parsed as Record + } catch { + // ignore — first write + } + const list = settings.disabledSkills ?? [] + if (list.length === 0) { + delete existing.disabledSkills + } else { + existing.disabledSkills = list + } + await fs.writeFile(file, JSON.stringify(existing, null, 2) + '\n', 'utf-8') +} + +export async function loadDisabledSkillsSet(): Promise> { + const [g, p] = await Promise.all([readSettings('global'), readSettings('project')]) + const merged = new Set() + for (const name of g.disabledSkills ?? []) merged.add(name) + for (const name of p.disabledSkills ?? []) merged.add(name) + return merged +} + +/** Toggle a skill's disabled state in the given scope. `disable=true` adds + * the name; `disable=false` removes it. Returns the action that actually + * happened so the caller can render an accurate message + * ("already disabled" vs "disabled"). */ +export async function setSkillDisabled( + name: string, + scope: SkillSettingsScope, + disable: boolean, +): Promise<'changed' | 'noop'> { + const current = await readSettings(scope) + const list = new Set(current.disabledSkills ?? []) + const had = list.has(name) + if (disable) { + if (had) return 'noop' + list.add(name) + } else { + if (!had) return 'noop' + list.delete(name) + } + await writeSettings(scope, { disabledSkills: [...list].sort() }) + return 'changed' +} + +export async function getScopedDisabledSkills(scope: SkillSettingsScope): Promise { + const s = await readSettings(scope) + return s.disabledSkills ?? [] +} diff --git a/packages/core/tests/skills.test.ts b/packages/core/tests/skills.test.ts index 17df822..4a22c50 100644 --- a/packages/core/tests/skills.test.ts +++ b/packages/core/tests/skills.test.ts @@ -216,4 +216,99 @@ describe('SkillRegistry', () => { const registry = new SkillRegistry(defs) expect(registry.list()).toHaveLength(2) }) + + it('disabled skills are hidden from list/names/get but appear in listAll', () => { + const defs = [ + { name: 'on-skill', description: 'On', content: '', source: 'global' as const }, + { name: 'off-skill', description: 'Off', content: '', source: 'global' as const }, + ] + const registry = new SkillRegistry(defs, new Set(['off-skill'])) + expect(registry.list().map((s) => s.name)).toEqual(['on-skill']) + expect(registry.names()).toEqual(['on-skill']) + expect(registry.get('off-skill')).toBeUndefined() + expect(registry.get('on-skill')).toBeDefined() + expect(registry.listAll()).toHaveLength(2) + expect(registry.listAll().find((s) => s.name === 'off-skill')!.disabled).toBe(true) + expect(registry.listAll().find((s) => s.name === 'on-skill')!.disabled).toBe(false) + expect(registry.getEntry('off-skill')!.disabled).toBe(true) + }) + + it('YAML folded scalar in description joins continuation lines', async () => { + const dir = await makeTempSkillsDir([ + { + dir: 'folded', + frontmatter: + 'name: folded\ndescription: First chunk of the description\n continues on the next line\n and a third line', + body: 'Body', + }, + ]) + process.env.XC_SKILLS_DIR = dir + + const skills = await loadSkills() + expect(skills).toHaveLength(1) + expect(skills[0].description).toBe('First chunk of the description continues on the next line and a third line') + }) +}) + +// ── settings (disabledSkills) ───────────────────────────────────────────────── + +describe('skill settings', () => { + let originalHome: string | undefined + let originalCwd: string + + beforeEach(() => { + originalHome = process.env.X_CODE_HOME + originalCwd = process.cwd() + }) + + afterEach(() => { + if (originalHome === undefined) delete process.env.X_CODE_HOME + else process.env.X_CODE_HOME = originalHome + process.chdir(originalCwd) + }) + + it('union of global + project disabled lists', async () => { + const { setSkillDisabled, loadDisabledSkillsSet } = await import('../src/skills/settings.js') + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xc-settings-test-home-')) + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xc-settings-test-proj-')) + // utils.ts caches GLOBAL_XCODE_DIR at module-eval time, so X_CODE_HOME + // alone won't redirect the global path here. We chdir into a temp + // project dir to point the project scope at a fresh location; the + // global path lives wherever utils.ts resolved it on first import. + process.chdir(projectDir) + + await setSkillDisabled('alpha', 'project', true) + await setSkillDisabled('beta', 'project', true) + const disabled = await loadDisabledSkillsSet() + expect(disabled.has('alpha')).toBe(true) + expect(disabled.has('beta')).toBe(true) + }) + + it('setSkillDisabled returns noop when state already matches', async () => { + const { setSkillDisabled } = await import('../src/skills/settings.js') + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xc-settings-noop-')) + process.chdir(projectDir) + + expect(await setSkillDisabled('gamma', 'project', true)).toBe('changed') + expect(await setSkillDisabled('gamma', 'project', true)).toBe('noop') + expect(await setSkillDisabled('gamma', 'project', false)).toBe('changed') + expect(await setSkillDisabled('gamma', 'project', false)).toBe('noop') + }) + + it('preserves unrelated fields in settings.json', async () => { + const { setSkillDisabled, skillSettingsPath } = await import('../src/skills/settings.js') + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xc-settings-merge-')) + process.chdir(projectDir) + + const file = skillSettingsPath('project') + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, JSON.stringify({ keepMe: 'yes', other: 42 }), 'utf-8') + + await setSkillDisabled('delta', 'project', true) + const raw = await fs.readFile(file, 'utf-8') + const parsed = JSON.parse(raw) + expect(parsed.keepMe).toBe('yes') + expect(parsed.other).toBe(42) + expect(parsed.disabledSkills).toEqual(['delta']) + }) }) From 6327599ae03ecc1e608285a1f726cd7c70233245 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Sat, 23 May 2026 16:06:01 +0800 Subject: [PATCH 5/5] test(core): integration coverage for createSkillRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single end-to-end test exercising loader + settings + registry filter together: writes two SKILL.md files to a temp dir (via XC_SKILLS_DIR), writes .x-code/settings.local.json with one of them in disabledSkills, calls createSkillRegistry(), and asserts list/names/get hide the disabled skill while listAll/getEntry retain it. Guards against future refactors that decouple the layers and silently let a disabled skill reach the agent loop — the unit tests above cover each layer in isolation but wouldn't catch a wiring regression. --- packages/core/tests/skills.test.ts | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/core/tests/skills.test.ts b/packages/core/tests/skills.test.ts index 4a22c50..1a03dae 100644 --- a/packages/core/tests/skills.test.ts +++ b/packages/core/tests/skills.test.ts @@ -312,3 +312,73 @@ describe('skill settings', () => { expect(parsed.disabledSkills).toEqual(['delta']) }) }) + +// ── createSkillRegistry integration ─────────────────────────────────────────── +// End-to-end through loader + settings + registry filter. The unit tests +// above each cover one layer in isolation; this guards against future +// refactors that decouple the layers and silently let a disabled skill +// reach the agent loop (the failure mode would be a settings.json entry +// that the registry stops honoring). + +describe('createSkillRegistry', () => { + let originalCwd: string + + beforeEach(() => { + originalCwd = process.cwd() + }) + + afterEach(() => { + process.chdir(originalCwd) + }) + + it('reads skills from disk, applies project-scope disable, and filters list/names/get', async () => { + const { createSkillRegistry } = await import('../src/skills/registry.js') + const { skillSettingsPath } = await import('../src/skills/settings.js') + + const skillsDir = await makeTempSkillsDir([ + { + dir: 'skill-on', + frontmatter: 'name: skill-on\ndescription: Stays enabled', + body: 'On body', + }, + { + dir: 'skill-off', + frontmatter: 'name: skill-off\ndescription: Should be disabled', + body: 'Off body', + }, + ]) + process.env.XC_SKILLS_DIR = skillsDir + + // Project-scope settings live under cwd/.x-code/settings.local.json. + // Chdir to a fresh temp dir so we don't pollute the real repo or the + // user's home (utils.ts caches GLOBAL_XCODE_DIR at import time, so we + // can't redirect global scope here — project scope is sufficient + // because XC_SKILLS_DIR also tags loaded skills as source='project'). + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xc-registry-int-')) + process.chdir(projectDir) + const settingsFile = skillSettingsPath('project') + await fs.mkdir(path.dirname(settingsFile), { recursive: true }) + await fs.writeFile(settingsFile, JSON.stringify({ disabledSkills: ['skill-off'] }), 'utf-8') + + const registry = await createSkillRegistry() + + // listAll surfaces both, with disabled flag set correctly. + const all = registry.listAll() + expect(all).toHaveLength(2) + const onEntry = all.find((s) => s.name === 'skill-on')! + const offEntry = all.find((s) => s.name === 'skill-off')! + expect(onEntry.disabled).toBe(false) + expect(offEntry.disabled).toBe(true) + + // list / names / get all hide the disabled one — this is the contract + // the agent loop and system-prompt builder rely on. + expect(registry.list().map((s) => s.name)).toEqual(['skill-on']) + expect(registry.names()).toEqual(['skill-on']) + expect(registry.get('skill-off')).toBeUndefined() + expect(registry.get('skill-on')).toBeDefined() + + // getEntry is the one accessor that still returns disabled skills, + // for the /skill list + /skill enable handlers to act on them. + expect(registry.getEntry('skill-off')?.disabled).toBe(true) + }) +})