Skip to main content

Standardized UI

Composer buttons, menus, modals, and forms

Principle: prefer Finch's native UI — don't build your own dialogs and notification shells. Native components automatically adapt to theming, dark mode, keyboard navigation, and cross-platform consistency.

Which one to use

NeedUse
Lightweight feedback (saved, connected)ctx.ui.showToast()
Yes/no confirmation (delete, irreversible action)ctx.ui.showConfirmDialog()
Multiple action choices / showing informationctx.ui.showModalDialog()
A user-initiated form (API key, connection config)ctx.ui.showModalDialog({ fields })
A tool mid-execution needs a missing parameterexec.ui.requestForm()
A persistent Composer entry pointA ComposerAction button
Container-level account/connection settingsA container settings menu (see Session containers)
A floating widget (desktop pet, timer)ctx.ui.createCanvasWindow()

You can also explore some of these UI capabilities firsthand via the mini tool demo:

demo

Trigger flow

ComposerAction: composer buttons

Declare the slot in the manifest:

{
  "contributes": {
    "composerActions": [
      { "id": "git-branch", "icon": "GitBranch", "tooltip": "Switch branch" }
    ]
  }
}

Then provide the behavior in code:

const action = ctx.composerActions.register('git-branch', {
  async getBadge({ cwd }) {
    if (!cwd) throw new Error('N/A');   // Throwing hides the button
    return await getCurrentBranch(cwd); // A string becomes the badge text
  },
  async getMenu({ cwd }) {
    return (await listBranches(cwd)).map((b) => ({
      id: b, label: b, iconName: 'git-branch', hoverText: `Switch to ${b}`,
    }));
  },
  async execute({ cwd }, itemId, actions) {
    await checkout(cwd, itemId);
  },
});
ctx.subscriptions.push(action);

Four possible return values for getBadge():

ReturnEffect
stringShows badge text
{ text?, active? }active: true turns the button into a highlighted "on" state (accent-colored icon + background)
undefinedIcon only
Throws an errorHides the button entirely (means it doesn't apply to the current cwd)

The badge is pulled, not refreshed on a timer. When the underlying state changes in the background, call notifyUpdate() on the registration handle:

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

Keep polling intervals at 3 seconds or more. ctx.sessionId is available in the session UI, so toggle state can be scoped per-session instead of globally.

getReminder(): appends a strong reminder before every turn. Beyond badges and menus, a ComposerAction can append a system-level reminder to the model right before each message is sent, constraining how it should behave "this turn" — without needing a separate agent tool or the user typing instructions.

async getReminder({ surface, sessionId }) {
  return isEnabled(sessionId) ? REMINDER : undefined;
}
  • Returning a string injects that text as a reminder for this turn; undefined appends nothing.
  • It's called fresh before every message, so you can return different content based on surface (home / session) and sessionId — as a toggle, a one-shot, or a persistent per-session reminder.
  • The reminder is system-level text appended for the model — it never appears as a chat bubble and is never reviewed by the user, so use it carefully and avoid contradicting anything visible in the conversation.
  • A canonical example is the official plan-mode mini tool's "planning mode": once the user turns it on, getReminder() returns "output a structured plan only, don't run any tools, wait for user confirmation" on every turn; onClick toggles the button state and persists it in ctx.storage; once the model produces a plan, an onTurnEnd hook (fired when the model's turn ends) shows a confirmation dialog — confirming automatically turns off plan mode and fills the execution instruction into the composer via actions.composer.fill(), forming a complete "plan first, then execute" loop.

composer_action

Menus

Menu items are returned dynamically by getMenu(), called fresh every time the menu opens.

[
  { id: 'status', label: 'Connection status', description: 'Signed in',
    iconName: 'toggle-right', disabled: true },
  { id: 'divider', label: '', separator: true },
  { id: 'logout', label: 'Sign out', iconName: 'log-in' },
]

Four rules:

  • Every clickable item must have an iconName, and it must be either a built-in Finch icon id or a registered ext: SVG. An icon id that doesn't exist silently renders as plain text, with no warning — this is the single most common pitfall. Check the built-in icon list before setting any icon field — see Icon guidelines.
  • Status displays aren't actions. Show state with a disabled: true row; use a separate clickable row for login/logout.
  • separator: true is its own item, not a property of the next row.
  • Use hoverText for long descriptions (plain text, preserves line breaks, no Markdown) — don't stuff them into label.

menu

Modals

const result = await ctx.ui.showModalDialog({
  title: 'Choose an action',
  message: 'There are 3 unsynced records.',
  actions: [
    { id: 'cancel', label: 'Cancel' },
    { id: 'sync', label: 'Sync now', variant: 'primary' },
  ],
});
if (result.action === 'sync') { /* ... */ }

message supports lightweight structured text: blank lines, inline code, emphasis, dimmed/warning lines, and a standalone Markdown image ![alt](src) — useful for something like a login QR code. Image sources must be either credential-free https:// URLs or base64 data URLs under 5MB, and only ever render in the UI layer — they never enter the tool result or the model's context.

The returned handle supports programmatic closing, typically used for QR-code login:

const dialog = ctx.ui.showModalDialog({
  title: 'Scan to log in',
  message: `Open the app and scan the QR code below.\n\n![QR](data:image/png;base64,${png})`,
  actions: [{ id: 'close', label: 'Close' }],
});

// Close it proactively once background polling confirms login
await dialog.close('connected');
const result = await dialog;   // { action: 'connected' }

showModalDialog2

Forms

Two APIs render the exact same field grid. Field types are text / password / textarea / number / select / boolean / link, supporting required, secret, width, default, and options.

The choice isn't about appearance — it's about when input is needed:

exec.ui.requestForm(spec)ctx.ui.showModalDialog({ fields })
Called fromOnly inside a tool's execute()Anywhere — a button callback, a settings menu, even activate()
Requires a model turnYes, only while the model is calling your toolNo
Renders whereA card in the Composer's waiting areaA native modal, with custom buttons
Best forThe model is mid-execution and missing a parameterThe user proactively opens settings to enter an API key
const result = await ctx.ui.showModalDialog({
  title: 'Configure API key',
  actions: [
    { id: 'cancel', label: 'Cancel' },
    { id: 'save', label: 'Save', 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 ?? ''));
}

When fields is present, the first variant: 'primary' button stays disabled until required fields are filled in. Values from secret: true fields are never sent back to the model — store them with ctx.secrets, never in the tool result.

showModalDialog