原则:优先使用 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 容器》) |
| 悬浮小部件(桌宠、计时器) | ctx.ui.createCanvasWindow() |
当然,你可以通过小工具演示来了解小工具的部分 UI 能力:

触发链路
ComposerAction:输入框按钮
manifest 声明槽位:
{
"contributes": {
"composerActions": [
{ "id": "git-branch", "icon": "GitBranch", "tooltip": "切换分支" }
]
}
}
代码提供行为:
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():
const timer = setInterval(async () => {
if (await stateChanged()) action.notifyUpdate();
}, 5000);
ctx.subscriptions.push({ dispose: () => clearInterval(timer) });
轮询间隔建议 ≥ 3 秒。ctx.sessionId 在会话界面可用,切换开关状态可以按会话隔离而不是全局。
getReminder():每轮发送前追加强提醒。 除了 badge 和菜单,ComposerAction 还能在用户每次发送消息前给模型追加一段系统层提醒,用来约束“这一轮”该怎么做——不占用一个独立的 Agent 工具,也不需要用户手写指令。
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()进输入框,形成“先计划、后执行”的完整闭环。

菜单
菜单项由 getMenu() 动态返回,每次打开都会重新调用。
[
{ 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字段前先核对内置图标列表,见《小工具图标规范》。 - 状态展示不是动作。用
disabled: true的行显示状态,登录/退出用独立的可点击行。 separator: true是独立的一项,不是下一行的属性。- 长说明用
hoverText(纯文本,保留换行,不解析 Markdown),不要塞进label。

模态框
const result = await ctx.ui.showModalDialog({
title: '选择操作',
message: '当前有 3 条未同步记录。',
actions: [
{ id: 'cancel', label: '取消' },
{ id: 'sync', label: '立即同步', variant: 'primary' },
],
});
if (result.action === 'sync') { /* ... */ }
message 支持轻量结构化文本:空行、行内代码、强调、弱化/警告行,以及独立成行的 Markdown 图片 ——用于登录二维码这类临时可视内容。图片源只允许无凭证的 https:// 或 5MB 以内的 base64 data URL,且只停留在 UI 层,不会进入工具结果或模型上下文。
返回的句柄支持程序化关闭,典型用于扫码登录:
const dialog = ctx.ui.showModalDialog({
title: '扫码登录',
message: `打开 App 扫描下方二维码。\n\n`,
actions: [{ id: 'close', label: '关闭' }],
});
// 后台轮询到登录成功,主动关掉弹窗
await dialog.close('connected');
const result = await dialog; // { action: 'connected' }

表单
两个 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 |
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 存储,不要写进工具结果。
