# 自定义扩展指令

本节介绍自定义指令、Skills 模板、子代理角色管理与旁路问答等扩展能力。

返回：[指令面板说明](./09.0.指令面板说明.md)

---

## `/custom`

创建自定义命令。

- **作用**: 打开自定义命令配置面板
- **功能**:
  - 创建新的自定义指令
  - 支持两种类型：
    - **execute**: 在终端执行命令
    - **prompt**: 发送提示词给 AI
    - **panel**: 加载 AnyPanel 插件显示面板
  - 支持全局和项目级别
  - **支持补充输入**: 可以在指令后面添加额外参数。若模板含 `$ARGUMENTS` 占位符则原地替换，否则追加到命令或提示词末尾（详见下文）
- **存储位置**:
  - 全局: `~/.snow/commands/`
  - 项目: `.snow/commands/`
- **示例**:
  - 输入 `/custom` 打开配置界面
  - 使用补充输入: `/mycommand 额外参数` - 参数会替换模板中的 `$ARGUMENTS` 占位符，或追加到末尾

### Panel 类型与 AnyPanel 插件

`panel` 类型的自定义指令会从 `~/.snow/plugin/anypanel/` 目录加载插件并显示面板。

- **插件目录**: `~/.snow/plugin/anypanel/`
- **支持的文件格式**: `.js` / `.mjs` / `.cjs`
- **command 字段**: 填写 AnyPanel 插件的 id
- **插件接口**: 模块需导出 `default`、`anyPanel` 或 `anyPanels`，包含 `id`、`name`、`init`、`handleInput`、`getStatus` 方法，以及 `render`（推荐）或 `getRenderLines` 之一

#### 渲染模式

AnyPanel 插件支持两种渲染模式，至少实现其一：

1. **富文本模式**（推荐）：实现 `render(state, ctx)` 方法。AnyPanelScreen 会注入 React、ink 组件（`Box`、`Text`、`Newline`、`Spacer`）、当前主题配色（`theme`）、用户语言、终端宽度和 `forceRerender()` 到 `ctx` 中，插件可以用它们构建带颜色、边框、布局的界面。
2. **纯文本模式**（向后兼容）：实现 `getRenderLines(state)` 方法，返回字符串行数组，AnyPanelScreen 用 `<Text>` 逐行渲染。

若两者都存在，优先使用 `render()`。

#### `render(state, ctx)` 渲染上下文

`ctx` 参数包含以下字段：

| 字段            | 类型      | 说明                                                                                                 |
| --------------- | --------- | ---------------------------------------------------------------------------------------------------- |
| `React`         | namespace | React 命名空间，用于 `React.createElement`                                                           |
| `Box`           | Component | ink 的 Box 组件                                                                                      |
| `Text`          | Component | ink 的 Text 组件                                                                                     |
| `Newline`       | Component | ink 的 Newline 组件                                                                                  |
| `Spacer`        | Component | ink 的 Spacer 组件                                                                                   |
| `theme`         | Theme     | 当前主题的完整颜色配置（`theme.colors.xxx`）                                                         |
| `language`      | string    | 当前用户语言                                                                                         |
| `terminalWidth` | number    | 终端宽度                                                                                             |
| `sessionId`     | string    | 当前会话 ID，无会话上下文时为空字符串                                                                |
| `sessionJson`   | string    | 当前会话的 JSON 原文，无会话上下文时为空字符串。可 `JSON.parse` 后读取消息列表、标题、摘要等任意字段 |
| `forceRerender` | function  | 请求重新渲染（用于异步操作完成后刷新画面）                                                           |

#### `init(ctx)` 初始化上下文

`init(ctx)` 方法接收的 `ctx` 参数包含以下字段：

| 字段             | 类型   | 说明                                                                                                 |
| ---------------- | ------ | ---------------------------------------------------------------------------------------------------- |
| `terminalWidth`  | number | 终端宽度                                                                                             |
| `terminalHeight` | number | 终端高度（估算值）                                                                                   |
| `language`       | string | 当前用户语言                                                                                         |
| `cwd`            | string | 当前工作目录                                                                                         |
| `sessionId`      | string | 当前会话 ID，无会话上下文时为空字符串                                                                |
| `sessionJson`    | string | 当前会话的 JSON 原文，无会话上下文时为空字符串。可 `JSON.parse` 后读取消息列表、标题、摘要等任意字段 |

> `sessionId` 和 `sessionJson` 在 `init(ctx)` 和 `render(state, ctx)` 中均可用。当面板在无会话上下文的环境中打开时（如独立面板），这两个字段为空字符串。

#### 插件接口概览

```ts
interface AnyPanelPlugin<S = unknown> {
	id: string;
	name: string;
	description?: string | Partial<Record<Language, string>>;
	author?: string;
	version?: string;
	enable?: boolean;

	// 可选：定时刷新间隔（毫秒），最小 100ms。
	// 设置后面板会按间隔定时重绘，无需用户按键。
	// 适用于实时监控面板。
	refreshIntervalMs?: number;

	init(ctx: AnyPanelInitContext): S;
	handleInput(state: S, input: AnyPanelInput): S;

	// 渲染方法（二选一，render 优先）
	render?(state: S, ctx: AnyPanelRenderContext): ReactNode;
	getRenderLines?(state: S): string[];

	getStatus(state: S): 'active' | 'done';
	getHint?(state: S): string;

	// --- 生命周期钩子（全部可选）---
	onMount?(state: S): void; // init() 之后、面板打开时调用
	onUnmount?(state: S): void; // 面板关闭 / 组件卸载时调用
	onFocus?(state: S): void; // 面板获得焦点时调用
	onBlur?(state: S): void; // 面板失去焦点时调用
}
```

泛型类型参数 `<S>` 默认为 `unknown`，不指定时所有 `state` 参数类型为 `unknown`（完全兼容旧插件）。在 TypeScript 编写插件时可以指定具体类型以获得更强的类型安全。

#### 富文本模式示例

```js
// ~/.snow/plugin/anypanel/my-panel.mjs
export default {
	id: 'my-panel',
	name: 'My Panel',
	init() {
		return {count: 0};
	},
	handleInput(state, input) {
		if (input.key.return) return {...state, count: state.count + 1};
		return state;
	},
	render(state, ctx) {
		const {React, Box, Text, theme} = ctx;
		const e = React.createElement;
		return e(
			Box,
			{flexDirection: 'column'},
			e(
				Text,
				{color: theme.colors.success, bold: true},
				`Count: ${state.count}`,
			),
		);
	},
	getStatus() {
		return 'active';
	},
	getHint() {
		return 'Enter: increment | ESC: exit';
	},
};
```

#### 纯文本模式示例

```js
// ~/.snow/plugin/anypanel/my-panel.mjs
export default {
	id: 'my-panel',
	name: 'My Panel',
	init() {
		return {count: 0};
	},
	handleInput(state, input) {
		if (input.key.return) return {...state, count: state.count + 1};
		return state;
	},
	getRenderLines(state) {
		return [`Count: ${state.count}`];
	},
	getStatus() {
		return 'active';
	},
	getHint() {
		return 'Enter: increment | ESC: exit';
	},
};
```

#### `description` 多语言支持

`description` 字段支持纯字符串（所有语言通用）或多语言对象。使用多语言对象时，按当前语言 → `en` → 第一个可用语言的优先级回退。

#### 定时刷新

AnyPanel 默认是事件驱动的（按键触发渲染，没有 tick 循环）。如果需要在 `init()` 中获取网络数据，必须使用同步方式（如 `execSync` 调用 `curl`）。异步操作完成后，调用 `ctx.forceRerender()` 触发重绘。

对于实时监控面板（如进程监控、日志流、系统资源等），可以设置 `refreshIntervalMs` 开启定时自动刷新。设置后面板会按间隔定时重绘，无需用户按键：

```js
export default {
	id: 'monitor',
	name: '进程监控',
	refreshIntervalMs: 2000, // 每 2 秒刷新一次
	init() {
		return {processes: []};
	},
	handleInput(state, input) {
		// ... 处理用户导航
		return state;
	},
	render(state, ctx) {
		// 重要：在每次 render 时同步获取最新数据
		// refreshIntervalMs 只触发重绘——不会调用 handleInput 也不会更新 state
		// 插件必须在 render() 内部主动读取当前数据
		const {execSync} = require('child_process');
		const out = execSync('ps aux', {encoding: 'utf8'});
		const processes = out.split('\n').slice(0, 20);
		// ... 渲染进程列表
	},
	getStatus() {
		return 'active';
	},
};
```

- 最小有效间隔为 100ms（低于此值会被截断到 100ms）。
- 未设置时面板为纯事件驱动（仅按键触发重绘）。
- **定时器只触发重绘——不会调用 `handleInput()` 也不会更新 `state`。** 插件必须在 `render()` 内部同步获取最新数据（如通过 `execSync`）。

#### 键盘输入

`handleInput(state, input)` 中的 `input` 参数包含：

| 字段    | 类型   | 说明                   |
| ------- | ------ | ---------------------- |
| `input` | string | 用户输入的原始字符     |
| `key`   | object | 按键状态标志（见下表） |

`key` 对象支持以下字段（均为 `boolean` 类型）：

| 按键         | 说明                   |
| ------------ | ---------------------- |
| `upArrow`    | 上方向键               |
| `downArrow`  | 下方向键               |
| `leftArrow`  | 左方向键               |
| `rightArrow` | 右方向键               |
| `return`     | Enter / 回车键         |
| `escape`     | ESC 键（始终关闭面板） |
| `backspace`  | 退格键                 |
| `delete`     | Delete 键              |
| `tab`        | Tab 键                 |
| `pageUp`     | Page Up 键             |
| `pageDown`   | Page Down 键           |
| `home`       | Home 键                |
| `end`        | End 键                 |
| `ctrl`       | Ctrl 修饰键            |
| `shift`      | Shift 修饰键           |
| `meta`       | Meta / Alt 修饰键      |

#### 生命周期钩子

AnyPanel 插件可以实现可选的生命周期钩子，用于资源管理：

| 钩子        | 调用时机                                                                                                                                                  |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onMount`   | `init()` 之后、面板打开时调用。适合启动异步任务、打开连接等。                                                                                             |
| `onUnmount` | 面板关闭时调用（ESC / `getStatus()` 返回 `'done'` / 组件卸载）。适合清理定时器、关闭连接等。仅调用一次——若已通过关闭流程调用，卸载 cleanup 不会重复调用。 |
| `onFocus`   | 面板获得焦点时调用。当前实现在面板挂载时（`onMount` 之后）调用一次。                                                                                      |
| `onBlur`    | 面板失去焦点时调用。当前实现在面板关闭前（`onUnmount` 之前）调用一次。                                                                                    |

所有钩子均为可选。钩子抛出错误时会被静默捕获，不影响面板运行。

#### 渲染错误恢复

如果 `render()` 或 `getRenderLines()` 抛出异常，AnyPanelScreen 会保留上次成功渲染的画面，并在面板内容下方以警告色显示错误信息。面板不会关闭——用户可以继续交互，下次渲染成功后错误提示会自动消失。

**示例命令文件** (`~/.snow/commands/mypanel.json`):

```json
{
	"type": "panel",
	"command": "my-plugin-id",
	"description": "My custom panel"
}
```

### `description` 字段（可选）

自定义命令的 JSON 文件支持可选字段 `description`，用于在指令面板（输入 `/` 的候选列表）里显示更简短的说明，避免长 prompt 占用大量终端空间。

- **兼容策略**: 未设置 `description` 时，会回退显示 `command`（对于 `type: "prompt"` 的命令即为完整提示词），因此旧命令文件无需修改。
- **设置方式**: 使用 `/custom` 创建命令时可填写该字段；留空则视为未设置。

**示例：**

```json
{
	"type": "prompt",
	"command": "请根据当前对话生成一份简短总结",
	"description": "总结当前对话"
}
```

### 参数占位符 `$ARGUMENTS`

默认情况下，命令后输入的参数会被**追加到命令或提示词末尾**。若想让参数插入到模板的指定位置，可以在 `command` 字段中使用 `$ARGUMENTS` 占位符：

- **模板含 `$ARGUMENTS`**：参数原地替换占位符（所有出现处均被替换），命令作者可精确控制参数落点
- **模板不含 `$ARGUMENTS`**：参数追加到末尾（保留旧行为，已有命令无需改动）
- **参数为空**：占位符替换为空字符串，`$ARGUMENTS` 不会残留在提示词或终端命令中

该占位符约定与 Snow CLI 的 Skills 路径及 Claude Code 的 slash 命令一致，便于跨工具迁移。`execute` 与 `prompt` 两种类型均适用。

**示例命令文件**（`~/.snow/commands/fix-issue.json`）：

```json
{
	"type": "prompt",
	"command": "Fix GitHub issue $ARGUMENTS following our coding standards.",
	"description": "按规范修复 GitHub issue"
}
```

执行 `/fix-issue 123` 后，发给 AI 的内容为：

```
Fix GitHub issue 123 following our coding standards.
```

若模板不含占位符（如上文「总结当前对话」示例），执行 `/summary 额外说明` 时参数会追加到末尾：`请根据当前对话生成一份简短总结 额外说明`。

### 命名空间自定义指令

自定义指令支持命名空间格式：`/<namespace>:<command> [args...]`。

当你需要按功能/团队/环境对指令进行分组管理时，这会非常有用。

**目录映射（指令名由文件路径推导）：**

- `.snow/commands/build.json` -> `/build`
- `.snow/commands/deploy/stage.json` -> `/deploy:stage`
- `.snow/commands/deploy/prod.json` -> `/deploy:prod`

同样的规则也适用于全局目录 `~/.snow/commands/`。

**注意事项 / 限制：**

- 参数以空格分隔：`/deploy:stage --dry-run`
- `:` 仅作为命名空间分隔符使用。
- namespace 使用 `/` 作为目录层级分隔。
- namespace 的每一段不能是 `.` 或 `..`，且不能包含 `:` 或 `\\`。
- command 部分不能包含空白、`\\`、`/` 或 `:`（且不能是 `.` 或 `..`）。

## `/skills`

创建技能模板。

- **作用**: 打开技能创建对话框
- **功能**:
  - 生成 SKILL.md（主文档）
  - 生成 reference.md（详细参考）
  - 生成 examples.md（使用示例）
  - 创建 templates/（模板文件）
  - 创建 scripts/（辅助脚本）
- **存储位置**:
  - 全局: `~/.snow/skills/`
  - 项目: `.snow/skills/`
- **命名规则**: 小写字母、数字、连字符；可用 `/` 作为命名空间分隔（每段最多 64 字符）
- **目录映射**: `~/.snow/skills/<namespace>/<skill>/SKILL.md` -> skill id `<namespace>/<skill>`
- **示例**: 输入 `/skills`，在对话框里填入 `team/my-skill` 创建技能

## 删除自定义命令/技能

创建自定义命令后，可以使用 `/<命令名> -d` 删除：

- **删除自定义命令**: `/mycommand -d`
- **位置识别**: 自动识别全局或项目级别
- **示例**: 如果创建了 `/deploy` 命令，使用 `/deploy -d` 删除
- **命名空间示例**: 如果创建了 `/deploy:stage` 命令，使用 `/deploy:stage -d` 删除

## `/role-subagent`

子代理角色定义文件管理。

- **作用**: 管理子代理的 ROLE 文件（`ROLE-<agentName>.md`），为不同的子代理定义独立的角色行为
- **功能**:
  - **创建**: `/role-subagent` - 打开交互式创建面板，依次选择作用域和子代理
  - **删除**: `/role-subagent -d` 或 `/role-subagent --delete` - 打开删除面板，选择要删除的子代理角色文件
  - **列表**: `/role-subagent -l` 或 `/role-subagent --list` - 打开子代理角色管理面板，查看和管理已有的角色文件
- **存储位置**:
  - 全局: `~/.snow/ROLE-<agentName>.md`
  - 项目: `<项目根目录>/ROLE-<agentName>.md`
- **优先级**: 加载自定义角色时，项目级优先于全局级
- **面板操作**:
  - **创建面板**:
    1. 选择位置: `G` - 全局, `P` - 项目, `ESC` - 取消
    2. 选择子代理: `↑/↓` - 导航, `Enter` - 选择, `ESC` - 返回上一步
    3. 确认: `Y` - 确认创建, `N` - 返回上一步
  - **删除面板**:
    1. 选择位置: `G` - 全局, `P` - 项目, `ESC` - 取消
    2. 选择文件: `↑/↓` - 导航, `Enter` - 选择, `ESC` - 返回上一步
    3. 确认: `Y` - 确认删除, `N` - 返回上一步
  - **列表面板**:
    - `Tab` - 切换 Global / Project
    - `↑/↓` - 移动选择
    - `D` - 删除选中的角色文件（需二次确认: `Y` 确认, `N/ESC` 取消）
    - `ESC` - 关闭面板
- **使用场景**: 需要为特定子代理（如探索代理、计划代理等）定制角色行为时
- **示例**:
  - `/role-subagent` - 打开创建面板
  - `/role-subagent -d` - 打开删除面板
  - `/role-subagent -l` - 打开列表管理面板

## `/btw`

快捷提问（旁路问答）。

- **作用**: 向 AI 发起一个独立的快捷问题，不影响当前对话上下文
- **功能**:
  - 在侧边面板中流式展示 AI 回复
  - 回复内容不会写入主对话历史
  - 支持滚动浏览回复内容
- **面板操作**:
  - **流式阶段**: `ESC` - 中止流式并关闭
  - **完成阶段**: `↑/↓` - 滚动浏览回复, `Enter` - 关闭, `ESC` - 关闭
  - **错误阶段**: `Enter` - 关闭, `ESC` - 关闭
- **使用场景**: 需要快速问一个与当前任务无关的问题，又不想打断对话上下文
- **示例**: `/btw 解释一下 TypeScript 中的泛型`
