---
title: "Standardized UI · Finch Agent"
description: "Composer buttons, menus, modals, and forms"
source: https://finchwork.app/en/docs/minitools-ui
---

# Standardized UI

**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

| Need | Use |
| --- | --- |
| Lightweight feedback (saved, connected) | `ctx.ui.showToast()` |
| Yes/no confirmation (delete, irreversible action) | `ctx.ui.showConfirmDialog()` |
| Multiple action choices / showing information | `ctx.ui.showModalDialog()` |
| A user-initiated form (API key, connection config) | `ctx.ui.showModalDialog({ fields })` |
| A tool mid-execution needs a missing parameter | `exec.ui.requestForm()` |
| A persistent Composer entry point | A ComposerAction button |
| Container-level account/connection settings | A container settings menu (see [Session containers](/en/docs/minitools-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](/assets/docs/minitools/demo.png)

## Trigger flow

## ComposerAction: composer buttons

Declare the slot in the manifest:

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

Then provide the behavior in code:

```ts
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()`:

| Return | Effect |
| --- | --- |
| `string` | Shows badge text |
| `{ text?, active? }` | `active: true` turns the button into a highlighted "on" state (accent-colored icon + background) |
| `undefined` | Icon only |
| Throws an error | **Hides 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:

```ts
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.

```ts
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](/assets/docs/minitools/composer_action.png)

## Menus

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

```ts
[
  { 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](/en/docs/minitools-icons).
-   **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](/assets/docs/minitools/menu.png)

## Modals

```ts
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:

```ts
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](/assets/docs/minitools/showModalDialog2.png)

## 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 from | Only inside a tool's `execute()` | Anywhere — a button callback, a settings menu, even `activate()` |
| Requires a model turn | Yes, only while the model is calling your tool | No |
| Renders where | A card in the Composer's waiting area | A native modal, with custom buttons |
| Best for | The model is mid-execution and missing a parameter | The user proactively opens settings to enter an API key |

```ts
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](/assets/docs/minitools/showModalDialog.png)
