Skip to main content

标准化 UI

Composer 按钮、菜单、模态框与表单

原则:优先使用 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 能力:

demo

触发链路

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

composer_action

菜单

菜单项由 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

menu

模态框

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 层,不会进入工具结果或模型上下文。

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

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

表单

两个 API 渲染完全相同的字段网格,字段类型都是 text / password / textarea / number / select / boolean / link,支持 requiredsecretwidthdefaultoptions

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

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

showModalDialog