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" + } } 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..07df7fa 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, @@ -13,6 +17,7 @@ import { getAvailableProviders, getContextWindow, getMcpConfigPath, + getScopedDisabledSkills, getTokenStorage, listSessions, loadMergedConfigsFromDisk, @@ -27,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' @@ -63,7 +77,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 +125,17 @@ 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 (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)' }, ] as const @@ -184,11 +210,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 +329,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 +608,7 @@ export function App({ switch (command) { case 'help': echoCommand(text) - addInfoMessage(HELP_TEXT) + addInfoMessage(buildHelpText(skillCommands)) return case 'model': @@ -581,6 +634,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 +673,10 @@ export function App({ handleMemory() return + case 'skill': + await handleSkill(text, arg) + return + case 'mcp': await handleMcp(text, arg) return @@ -628,12 +686,44 @@ 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) { + if (arg) { + // 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, + }) + } 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. + // 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.`) + } + 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 +1165,192 @@ 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 + } + + /** 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() + 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?.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) => { + 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 + } + + 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 + * 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 +1839,7 @@ export function App({ } : null } - commands={SLASH_COMMANDS} + commands={allCommands} /> ) } diff --git a/packages/cli/src/ui/components/ChatInput.tsx b/packages/cli/src/ui/components/ChatInput.tsx index f08718e..2ebc5b6 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,45 @@ 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, + 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' 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 +174,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({ @@ -2580,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/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..94ed6e7 --- /dev/null +++ b/packages/cli/src/ui/components/chat-input/text-helpers.ts @@ -0,0 +1,115 @@ +// 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 +} + +/** 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 + 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' +} diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index a23a59a..960131d 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) @@ -503,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, @@ -515,6 +535,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/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/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..d7efc39 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -78,6 +78,12 @@ 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, 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). export { diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts new file mode 100644 index 0000000..b979a3a --- /dev/null +++ b/packages/core/src/skills/loader.ts @@ -0,0 +1,135 @@ +// @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 = {} + + // 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 + + 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..e85d7a5 --- /dev/null +++ b/packages/core/src/skills/registry.ts @@ -0,0 +1,68 @@ +// @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 + description: string + content: string + source: 'global' | 'project' +} + +export interface SkillEntry extends SkillDefinition { + disabled: boolean +} + +export class SkillRegistry { + private readonly byName: Map + + 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, 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 { + const entry = this.byName.get(name) + if (!entry || entry.disabled) return undefined + return entry + } + + /** Enabled skills only. */ + list(): SkillDefinition[] { + return [...this.byName.values()].filter((s) => !s.disabled) + } + + /** Enabled skill names only. */ + names(): string[] { + 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, 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/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..1a03dae --- /dev/null +++ b/packages/core/tests/skills.test.ts @@ -0,0 +1,384 @@ +// 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) + }) + + 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']) + }) +}) + +// ── 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) + }) +})