Follow Discord
Sweep 02 Oct 2026 · 18:55Z Build v2.1.288 509 read Stable v2.1.285 Latest v2.1.288 Next v2.1.288 Feeds RSS JSON llms.txt llms-full.txt Unofficial
One change · claude-code

Draw in the interface with a mod changedplugins/mods/interface

Nearest release: v2.1.287, published an hour before upstream edited the page. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.

Upstream edited this page at 1 Oct 2026 17:59 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 1 Oct 2026 18:07 UTC.

Upstream edited
Recorded here
Lines+820added
Lines−0removed
From line — no hunk to open at
First seen 1 Oct 2026 this site's first read of the page
Recorded edits7to this page, all time

# Draw in the interface with a mod ## Build a pane with tabs ## Pick where to draw ### Change what Claude Code already draws ### Open a pane at the right time #### When a pane waits for a wider terminal ## Build a tree from elements ### Draw a grid of colored cells ## Respond to presses and typing #### How a pane gets keyboard focus #### What each key does #### Set a hotkey and the first focus ### Take typed input and draw a row for each item ### When Claude Code redraws without being asked ### Redraw when your data changes ### Redraw on a timer ### How often a site can redraw ## Keep state ### Keep a value in `$.state` #### Declare the values #### Point the manifest at the declaration #### Define, read, and write a value #### Change `hello-tabs` to use `$.state` ### Save from more than one session ## Next steps

The whole hunk

820 lines, new page
/
lines

A whole new page. There's nothing to diff it against, so here is what it says.

# Draw in the interface with a mod

> Draw panes, a band above the prompt, buttons, and text fields from a Claude Code mod, handle presses and input, and keep state between redraws and sessions.

A mod can draw its own interface in Claude Code and change parts of the interface Claude Code already draws. Each place a mod can draw is called a [render site](/docs/en/plugins/mods/reference#render-sites), such as a pane, the band above the prompt, or the spinner. Claude Code raises the [`ui.render`](/docs/en/plugins/mods/reference#interface) event each time it's about to draw a render site, and your hook for that event returns what to draw there.

This map shows where a mod can draw in a terminal session:

<img src="https://mintcdn.com/claude-code/dgiVO_Od1X1faduV/images/mods-screen-map.svg?fit=max&auto=format&n=dgiVO_Od1X1faduV&q=85&s=5fda26b6609c62b68c6f9e528c1590ea" className="dark:hidden" alt="Map of a Claude Code terminal session. A mod can add a pane as a sidebar on the right, a toast at the top right of the transcript, a log line in the transcript, a band above the prompt, and a status line under the prompt. A mod can redraw messages, tool call rows, and the spinner. The prompt is Claude Code's own." width="600" height="336" data-path="images/mods-screen-map.svg" />

<img src="https://mintcdn.com/claude-code/dgiVO_Od1X1faduV/images/mods-screen-map-dark.svg?fit=max&auto=format&n=dgiVO_Od1X1faduV&q=85&s=5b4161581a1bd2c0450b0c8b57bc1225" className="hidden dark:block" alt="Map of a Claude Code terminal session. A mod can add a pane as a sidebar on the right, a toast at the top right of the transcript, a log line in the transcript, a band above the prompt, and a status line under the prompt. A mod can redraw messages, tool call rows, and the spinner. The prompt is Claude Code's own." width="600" height="336" data-path="images/mods-screen-map-dark.svg" />

In a narrower terminal, the pane sits above the prompt instead of beside the transcript.

Build your [first mod](/docs/en/plugins/mods/create) before you start here. Begin with the worked example, which builds a pane with two tabs and a counter, then read the section for each piece you want to change.

<Note>
  To look up one prop or limit, see the [reference](/docs/en/plugins/mods/reference#render-sites).
</Note>

## Build a pane with tabs

In this section you build a mod that adds a `/hello-tabs` command, and the command opens a pane. A pane is a sidebar beside the transcript in a wide fullscreen terminal, or a framed region above the prompt otherwise. This pane shows two tabs, and the second tab has a button that adds one to a counter. The count is still there after you restart Claude Code.

The finished mod looks like this. The recording opens the pane, switches to the second tab, presses the button a few times, and returns to the first tab:

<Frame>
  <video autoPlay muted loop playsInline controls className="w-full dark:hidden" src="https://mintcdn.com/claude-code/dgiVO_Od1X1faduV/images/mods-hello-tabs-light.mp4?fit=max&auto=format&n=dgiVO_Od1X1faduV&q=85&s=49d520094d87b5b44bfe50fa49677f06" aria-label="The /hello-tabs command is typed at the Claude Code prompt and a framed pane opens above it, with '1: One' and '2: Two' across the top and the text 'This is the first tab.' The second tab shows an 'Add one' button beside 'Count: 1', and the count rises to 3. The pane then returns to the first tab." data-path="images/mods-hello-tabs-light.mp4" />

  <video autoPlay muted loop playsInline controls className="w-full hidden dark:block" src="https://mintcdn.com/claude-code/dgiVO_Od1X1faduV/images/mods-hello-tabs-dark.mp4?fit=max&auto=format&n=dgiVO_Od1X1faduV&q=85&s=ff7a14d713d6e5d3b0000efa8522ea4b" aria-label="The /hello-tabs command is typed at the Claude Code prompt and a framed pane opens above it, with '1: One' and '2: Two' across the top and the text 'This is the first tab.' The second tab shows an 'Add one' button beside 'Count: 1', and the count rises to 3. The pane then returns to the first tab." data-path="images/mods-hello-tabs-dark.mp4" />
</Frame>

Claude Code has no built-in tabs element, so the tabs are two buttons in a row. The mod keeps track of which one is active and draws that tab's content under the row.

<Steps>
  <Step title="Create the plugin">
    A mod is a plugin with a manifest, a `hooks.json` that points to your code, and the code file. [Create a mod](/docs/en/plugins/mods/create#write-a-mod-yourself) explains each one. Create a directory named `hello-tabs` with `.claude-plugin` and `hooks` directories inside it, then save the first two files.

    Save the manifest as `hello-tabs/.claude-plugin/plugin.json`:

    ```json hello-tabs/.claude-plugin/plugin.json theme={null}
    {
      "name": "hello-tabs",
      "version": "0.1.0",
      "description": "Opens a pane with two tabs and a counter",
      "author": { "name": "Your Name" }
    }
    ```

    Name your entry point in `hello-tabs/hooks/hooks.json`:

    ```json hello-tabs/hooks/hooks.json theme={null}
    {
      "modules": ["./register.js"]
    }
    ```
  </Step>

  <Step title="Write the code">
    The code does three jobs, one in each hook:

    * Adds the `/hello-tabs` command
    * Opens the pane when you run that command
    * Draws the pane's content: the row of tabs and the open tab's body

    Two module-level variables, `tab` and `count`, hold the pane's state.

    Save this as `hello-tabs/hooks/register.js`:

    ```javascript hello-tabs/hooks/register.js theme={null}
    // The pane's id, used to open the pane and to recognize it when drawing
    const PANE = 'hello-tabs'

    // What the pane shows: which tab is open, and the counter's value
    let tab = 'one'
    let count = 0

    export function register(on) {
      // Runs before your first prompt, and again after a reload
      on('session.start', async ($, e, next) => {
        await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
        // Load the count an earlier session saved, if there is one
        const saved = await $.store.get('count')
        if (typeof saved === 'number') count = saved
        return next(e)
      })

      // Runs when you type /hello-tabs
      on('command.run', { command: 'hello-tabs' }, async ($) => {
        // Open the pane, give it the keyboard, and let Esc close it
        await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
        // Print nothing in the transcript
        return {}
      })

      // Runs each time Claude Code draws a pane
      on('ui.render', { component: 'Pane' }, async ($, e, next) => {
        // Leave other mods' panes alone
        if (e.requestId !== PANE) return next(e)
        // Get the elements this app can draw
        const { Box, Text, Button } = $.ui.resolve(e)
        // Ask Claude Code to run this hook again
        const redraw = () => $.ui.invalidate('ui.render')

        // One tab: a button that switches to its tab when pressed
        const tabButton = (name, label, hotkey) =>
          Button({
            key: 'tab-' + name,
            label,
            hotkey,
            plain: true,
            // Dim the tab that isn't open
            dimColor: tab !== name,
            onPress: () => {
              tab = name
              redraw()
            },
          })

        // What goes under the tabs, depending on which one is open
        const body =
          tab === 'one'
            ? [Text({ children: ['This is the first tab.'] })]
            : [
                Box({
                  flexDirection: 'row',
                  columnGap: 2,
                  children: [
                    Button({
                      key: 'more',
                      label: 'Add one',
                      hotkey: 'a',
                      onPress: async () => {
                        count += 1
                        redraw()
                        // Save the count so it's there after a restart
                        await $.store.set('count', count)
                      },
                    }),
                    Text({ children: ['Count: ' + count] }),
                  ],
                }),
              ]

        // The whole pane: the row of tabs, a blank line, then the body
        return Box({
          flexDirection: 'column',
          children: [
            Box({
              flexDirection: 'row',
              columnGap: 3,
              children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
            }),
            Text({ children: [' '] }),
            ...body,
          ],
        })
      })
    }
    ```

    Each hook also does something the code doesn't make plain:

    * **[`session.start`](/docs/en/plugins/mods/reference#session)** also reads the saved count from [`$.store`](#keep-state), a key-value store that persists between sessions.
    * **[`command.run`](/docs/en/plugins/mods/api#add-a-command)** only tells Claude Code the pane exists. Opening a pane draws nothing by itself: Claude Code then raises `ui.render` to ask what goes in it.
    * **`ui.render`** returns the element tree, a `Box` that holds other boxes, text, and buttons, and builds it again from `tab` and `count` each time it runs.

    Pressing a button runs its `onPress` callback, which changes a variable and calls `redraw`. Claude Code then runs the `ui.render` hook again, and the hook builds a new tree from the new values. Every interactive drawing uses that render cycle: a callback changes state, and the hook renders again from the new state.
  </Step>

  <Step title="Open the pane">
    In your shell, start Claude Code with `claude --plugin-dir ./hello-tabs`. At the Claude Code prompt, run `/hello-tabs`. A pane opens with `1: One` and `2: Two` across the top. Press `2`, then press `a`, the hotkey for **Add one**, a few times. The count rises.
  </Step>

  <Step title="Check that the count was saved">
    Press Esc to close the pane, then exit the session. In your shell, start Claude Code again with the same `claude --plugin-dir ./hello-tabs` command, and at the Claude Code prompt run `/hello-tabs`. The count is where you left it.

    To clear the count, have the mod call `$.store.delete('count')`. [Keep state](#keep-state) covers how long each kind of value lasts.
  </Step>
</Steps>

## Pick where to draw

A `ui.render` hook runs for every render site unless you narrow it to the one you want to draw in. To choose the render site, pass a filter, called a [matcher](/docs/en/plugins/mods/events#filter-which-events-a-hook-handles), as the second argument to `on`. `{ component: 'Pane' }` runs the hook only for panes. In the hook, `e.component` names the site, `e.surface` says which app is drawing, and `e.props` holds the site's own data. For a pane, `e.requestId` is the `id` you opened it with.

Two sites are empty until a mod fills them, the pane and the band. Select a tab to see what each one is and how to draw in it:

<Tabs>
  <Tab title="Pane">
    A pane is a sidebar beside the transcript in a wide fullscreen terminal, or a framed region above the prompt otherwise. With several panes open, each gets a tab that shows its title.

    A pane appears when your mod calls `$.ui.open` with an `id` you choose, as in `$.ui.open({ id: 'hello-tabs' })`. [Open a pane at the right time](#open-a-pane-at-the-right-time) covers the other fields and when a pane waits for a wider terminal.

    To draw in your pane, filter on `{ component: 'Pane' }` and check that `e.requestId` is your `id`.
  </Tab>

  <Tab title="Band above the prompt">
    The band is a strip directly above the prompt input. It's always there, and every mod shares it.

    Your hook returns a tree to show something in the band, or `next(e)` to show nothing. A tree replaces what the mods [after yours](/docs/en/plugins/mods/events#the-order-mods-run-in) draw there. To keep theirs, put the result of `await next(e)` among the children of a [`Box`](#build-a-tree-from-elements) in your tree.

    To draw in the band, filter on `{ component: 'AbovePrompt' }`.
  </Tab>
</Tabs>

### Change what Claude Code already draws

Claude Code draws most of its interface itself: messages, tool call rows, the spinner, and more. Each of those parts is a render site too, so a mod can restyle or replace it. To change one, filter your `ui.render` hook on its name from this table:

| Site | What it is |
| :- | :- |
| `UserMessage`, `AssistantMessage` | A message in the transcript |
| `ToolUse`, `ToolResult`, `ToolGroup` | A tool call's row, its result, and a folded run of calls |
| `CommandOutput` | The row a command printed |
| `AskUserQuestion` | The dialog Claude opens to ask you a question |
| `Spinner`, `ToolProgress`, `TurnDuration` | Status lines for a turn: the line that animates while Claude works, a running tool's live progress line, and the line that closes a turn |
| `InfoNotice`, `SessionMode`, `PromptHint` | Status lines under the logo, the mode labels in the footer, and the hint line under the prompt |

At a site Claude Code already draws, your hook has three choices: change a detail, replace the drawing, or leave it alone. Select a tab to see each one applied to the spinner. The examples read a `calls` variable that another hook counts, as in the [tutorial mod](/docs/en/plugins/mods/create#write-a-mod-yourself).

<Tabs>
  <Tab title="Change a detail">
    To keep Claude Code's drawing and change one part of it, pass `next` a copy of the event with changed `props`. This hook changes the text after the spinner's word:

    ```javascript theme={null}
    on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
      // Keep Claude Code's spinner, and change the text after its word
      return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
    })
    ```

    The spinner keeps its animation and its word, and your text follows the word:

    ```text theme={null}
    Thinking · tool calls: 2…
    ```
  </Tab>

  <Tab title="Replace the drawing">
    To draw something of your own in the site's place, return a tree and don't call `next`. This hook draws one line of text where the spinner would be:

    ```javascript theme={null}
    on('ui.render', { component: 'Spinner' }, async ($, e) => {
      const { Text } = $.ui.resolve(e)
      // No call to next, so this line is drawn in the spinner's place
      return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
    })
    ```

    While Claude works, your line shows and Claude Code's spinner doesn't:

    ```text theme={null}
    Claude has made 2 tool calls
    ```
  </Tab>

  <Tab title="Leave it alone">
    To leave the site as Claude Code draws it, return `next(e)`. A hook often does that for some events and not others. This hook leaves the spinner alone until there's a call to count:

    ```javascript theme={null}
    on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
      // Nothing to show yet, so pass the event on unchanged
      if (calls === 0) return next(e)
      return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
    })
    ```

    Before the first tool call, the spinner looks the way it does without the mod:

    ```text theme={null}
    Thinking…
    ```
  </Tab>
</Tabs>

The permission prompt isn't a render site, so a mod can't change what it shows. The question dialog, `AskUserQuestion`, is one, so a mod can change that.

The terminal and the Desktop app don't raise all the same sites. `Pane`, `AbovePrompt`, `Spinner`, and the transcript sites work in both. A few other status lines are raised in the terminal only. The [render sites table](/docs/en/plugins/mods/reference#render-sites) lists where each one is raised.

### Open a pane at the right time

A pane appears only when your mod opens it. How and when you open it decides whether it takes keyboard focus, how much room it asks for, and whether it shows at all in a narrow terminal.

To open a pane, call [`$.ui.open`](/docs/en/plugins/mods/reference#mods-api-methods) with an `id` you choose. The `id` is the pane's name: your `ui.render` hook checks for it, and you pass it again to close the pane.

```javascript theme={null}
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
```

To close the pane, call `$.ui.close` with the `id` you opened it with:

```javascript theme={null}
await $.ui.close({ id: 'hello-tabs' })
```

Besides `id`, `$.ui.open` takes these optional fields:

| Field | What it does |
| :- | :- |
| `title` | The pane's tab label when more than one pane is open |

Cut at 300 lines. The page has the rest.

Feedback