---
title: "标准化 UI · Finch Agent"
description: "Composer 按钮、菜单、模态框与表单"
source: https://finchwork.app/zh/docs/minitools-ui
---

# 标准化 UI

**原则：优先使用 Finch 原生 UI，不要自建对话框和通知外壳。** 原生组件自动适配主题、深色模式、键盘操作和多端一致性。

## 选型对照

| 需求 | 用什么 |
| --- | --- |
| 轻量反馈（已保存、已连接） | `ctx.ui.showToast()` |
| 是/否确认（删除、不可逆操作） | `ctx.ui.showConfirmDialog()` |
| 多个动作选择 / 展示信息 | `ctx.ui.showModalDialog()` |
| 用户主动填表（API Key、连接配置） | `ctx.ui.showModalDialog({ fields })` |
| 工具执行中缺参数，需要补充 | `exec.ui.requestForm()` |
| Composer 常驻入口 | ComposerAction 按钮 |
| 容器级账号/连接设置 | 容器 settings menu（见《[Session 容器](/zh/docs/minitools-containers)》） |
| 悬浮小部件（桌宠、计时器） | `ctx.ui.createCanvasWindow()` |

当然，你可以通过小程序演示来了解小程序的部分 UI 能力：

![demo](/assets/docs/minitools/demo.png)

## 触发链路

## ComposerAction：输入框按钮

manifest 声明槽位：

```json
{
  "contributes": {
    "composerActions": [
      { "id": "git-branch", "icon": "GitBranch", "tooltip": "切换分支" }
    ]
  }
}
```

代码提供行为：

```ts
const action = ctx.composerActions.register('git-branch', {
  async getBadge({ cwd }) {
    if (!cwd) throw new Error('N/A');   // 抛错 = 隐藏按钮
    return await getCurrentBranch(cwd); // 字符串 = badge 文字
  },
  async getMenu({ cwd }) {
    return (await listBranches(cwd)).map((b) => ({
      id: b, label: b, iconName: 'git-branch', hoverText: `切换到 ${b}`,
    }));
  },
  async execute({ cwd }, itemId, actions) {
    await checkout(cwd, itemId);
  },
});
ctx.subscriptions.push(action);
```

`getBadge()` 的四种返回：

| 返回 | 效果 |
| --- | --- |
| `string` | 显示 badge 文字 |
| `{ text?, active? }` | `active: true` 进入高亮“开启”态（强调色图标 + 背景） |
| `undefined` | 只显示图标 |
| 抛出错误 | **隐藏整个按钮**（表示不适用于当前 cwd） |

badge 是**被拉取**的，不会自己定时刷新。后台状态变化时调用注册句柄的 `notifyUpdate()`：

```ts
const timer = setInterval(async () => {
  if (await stateChanged()) action.notifyUpdate();
}, 5000);
ctx.subscriptions.push({ dispose: () => clearInterval(timer) });
```

轮询间隔建议 ≥ 3 秒。`ctx.sessionId` 在会话界面可用，切换开关状态可以按会话隔离而不是全局。

**`getReminder()`：每轮发送前追加强提醒。** 除了 badge 和菜单，ComposerAction 还能在用户每次发送消息前给模型追加一段系统层提醒，用来约束“这一轮”该怎么做——不占用一个独立的 Agent 工具，也不需要用户手写指令。

```ts
async getReminder({ surface, sessionId }) {
  return isEnabled(sessionId) ? REMINDER : undefined;
}
```

-   返回 `string` 时，这段文字会作为提醒注入这一轮上下文；返回 `undefined` 什么都不追加。
-   每轮发送前都会重新调用，可以按 `surface`（`home` / `session`）和 `sessionId` 返回不同内容，做成开关式、一次性或按会话持久化的提醒。
-   提醒是**追加给模型的系统层文本**，不会出现在聊天气泡里，也不经过用户审阅，谨慎使用，避免和可见对话内容矛盾。
-   典型案例是官方 `plan-mode` 小程序的“计划模式”：用户点亮按钮后，`getReminder()` 每轮返回“只输出结构化计划，不要执行任何工具，等待用户确认”；`onClick` 负责切换按钮状态并持久化到 `ctx.storage`；模型给出计划后，再配合 `onTurnEnd` 钩子（模型这一轮结束时触发）弹出确认对话框，用户确认则自动关闭计划模式并把执行指令 `actions.composer.fill()` 进输入框，形成“先计划、后执行”的完整闭环。

![composer\_action](/assets/docs/minitools/composer_action.png)

## 菜单

菜单项由 `getMenu()` 动态返回，每次打开都会重新调用。

```ts
[
  { id: 'status', label: '连接状态', description: '已登录',
    iconName: 'toggle-right', disabled: true },
  { id: 'divider', label: '', separator: true },
  { id: 'logout', label: '退出登录', iconName: 'log-in' },
]
```

四条规则：

-   **每个可点击项必须有 `iconName`**，且必须是 Finch 内置图标 id 或已注册的 `ext:` SVG。**用了不存在的图标 id 会静默渲染成纯文本，没有任何警告**——这是最高频的踩坑点，设置任何 `icon` 字段前先核对内置图标列表，见《[小程序图标规范](/zh/docs/minitools-icons)》。
-   **状态展示不是动作**。用 `disabled: true` 的行显示状态，登录/退出用独立的可点击行。
-   **`separator: true` 是独立的一项**，不是下一行的属性。
-   长说明用 `hoverText`（纯文本，保留换行，不解析 Markdown），不要塞进 `label`。

![menu](/assets/docs/minitools/menu.png)

## 模态框

```ts
const result = await ctx.ui.showModalDialog({
  title: '选择操作',
  message: '当前有 3 条未同步记录。',
  actions: [
    { id: 'cancel', label: '取消' },
    { id: 'sync', label: '立即同步', variant: 'primary' },
  ],
});
if (result.action === 'sync') { /* ... */ }
```

`message` 支持轻量结构化文本：空行、行内代码、强调、弱化/警告行，以及**独立成行的 Markdown 图片** `![alt](src)`——用于登录二维码这类临时可视内容。图片源只允许无凭证的 `https://` 或 5MB 以内的 base64 data URL，且只停留在 UI 层，不会进入工具结果或模型上下文。

返回的句柄支持程序化关闭，典型用于扫码登录：

```ts
const dialog = ctx.ui.showModalDialog({
  title: '扫码登录',
  message: `打开 App 扫描下方二维码。\n\n![QR](data:image/png;base64,${png})`,
  actions: [{ id: 'close', label: '关闭' }],
});

// 后台轮询到登录成功，主动关掉弹窗
await dialog.close('connected');
const result = await dialog;   // { action: 'connected' }
```

![showModalDialog2](/assets/docs/minitools/showModalDialog2.png)

## 表单

**两个 API 渲染完全相同的字段网格**，字段类型都是 `text` / `password` / `textarea` / `number` / `select` / `boolean` / `link`，支持 `required`、`secret`、`width`、`default`、`options`。

选择依据不是“长什么样”，而是**什么时候需要输入**：

|  | `exec.ui.requestForm(spec)` | `ctx.ui.showModalDialog({ fields })` |
| --- | --- | --- |
| 调用位置 | 只能在工具的 `execute()` 内 | 任何地方——按钮回调、设置菜单、甚至 `activate()` |
| 依赖模型轮次 | 是，必须模型正在调用你的工具 | 否 |
| 渲染位置 | Composer 等待区卡片 | 原生模态框，带自定义按钮 |
| 适用 | 模型执行到一半缺参数 | 用户主动点设置填 API Key |

```ts
const result = await ctx.ui.showModalDialog({
  title: '配置 API Key',
  actions: [
    { id: 'cancel', label: '取消' },
    { id: 'save', label: '保存', variant: 'primary' },
  ],
  fields: [
    { key: 'apiKey', label: 'API Key', type: 'password', secret: true, required: true },
  ],
});
if (result.action === 'save') {
  await ctx.secrets.set('apiKey', String(result.values?.apiKey ?? ''));
}
```

`fields` 存在时，第一个 `variant: 'primary'` 按钮在必填项填完前保持禁用。`secret: true` 字段的值不回传给模型，必须用 `ctx.secrets` 存储，不要写进工具结果。

![showModalDialog](/assets/docs/minitools/showModalDialog.png)
