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)
+ })
+})