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 11 hours 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 2 Oct 2026 04:34 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 2 Oct 2026 04:37 UTC.

Upstream edited
Recorded here
Lines+33added
Lines−33removed
From line 2 where the diff opens
First seen 1 Oct 2026 this site's first read of the page
Recorded edits7to this page, all time

The whole hunk

from line 2, old and new numbered
/
lines
from line 2
22 
33> 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.
44 
5A 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.
5A 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 fires 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.
66 
77This map shows where a mod can draw in a terminal session:
88 
from line 30
3030 <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" />
3131</Frame>
3232 
33Claude 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.
33The 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.
3434 
3535<Steps>
3636 <Step title="Create the plugin">
from line 57
5757 </Step>
5858 
5959 <Step title="Write the code">
60 The code does three jobs, one in each hook:
60 This list says what each hook does, in the order they appear in the code:
6161 
62 * Adds the `/hello-tabs` command
62 * Adds the `/hello-tabs` command, and loads the count an earlier session saved
6363 * Opens the pane when you run that command
6464 * Draws the pane's content: the row of tabs and the open tab's body
6565 
from line 162
162162 Each hook also does something the code doesn't make plain:
163163 
164164 * **[`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.
165 * **[`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.
165 * **[`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 fires `ui.render` to ask what goes in it.
166166 * **`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.
167167 
168168 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.
from line 183
183183 
184184A `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.
185185 
186Two 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:
186The pane and the band are empty until a mod fills them. Select a tab to see what each one is and how to draw in it:
187187 
188188<Tabs>
189189 <Tab title="Pane">
from line 210
210210| Site | What it is |
211211| :- | :- |
212212| `UserMessage`, `AssistantMessage` | A message in the transcript |
213| `ToolUse`, `ToolResult`, `ToolGroup` | A tool call's row, its result, and a folded run of calls |
213| `ToolUse`, `ToolResult`, `ToolGroup` | A tool call's row, its result, and a collapsed group of calls |
214214| `CommandOutput` | The row a command printed |
215215| `AskUserQuestion` | The dialog Claude opens to ask you a question |
216216| `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 |
217217| `InfoNotice`, `SessionMode`, `PromptHint` | Status lines under the logo, the mode labels in the footer, and the hint line under the prompt |
218218 
219At 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).
219At a site Claude Code already draws, your hook can 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).
220220 
221221<Tabs>
222222 <Tab title="Change a detail">
from line 332
332332* **Opened by something the user did**, such as a command they ran or a button they pressed, the pane appears at any width
333333* **Opened by your mod acting by itself**, such as from a timer or a [`turn.start`](/docs/en/plugins/mods/events#follow-a-turn) hook, the pane appears only in a terminal at least 144 columns wide. After the user has opened that pane once themselves, 110 columns is enough.
334334 
335When the pane appears, `$.ui.open` resolves to `{ isPlaced: true }`. When the pane is waiting, `isPlaced` is `false` and `reason` is a string that says why. A waiting pane appears when the user opens it or widens the terminal. To say something is available without opening a pane, call `$.ui.toast('Your message')`, which shows a small notice that disappears after a few seconds.
335When the pane appears, `$.ui.open` resolves to `{ isPlaced: true }`. When the pane is waiting, `isPlaced` is `false` and `reason` is a string that says why. A waiting pane appears when the user opens it or widens the terminal. To say something is available without opening a pane, call `$.ui.toast('Your message')`, which shows a toast notification.
336336 
337337## Build a tree from elements
338338 
from line 340
340340 
341341To get the elements, call `$.ui.resolve(e)` in your hook, as in `const { Box, Text, Button } = $.ui.resolve(e)`. Each element is a function. You pass it props, and you put the elements and strings that go inside it in `children`.
342342 
343Most drawings use four elements. Select a tab to see each one and how the terminal draws it:
343Select a tab to see each of the most common elements and how the terminal draws it:
344344 
345345<Tabs>
346346 <Tab title="Text">
from line 416
416416| `Text` | Styled text. Takes `color`, `bold`, `dimColor`, `italic`, and `wrap`. A `color` is a theme key or a color such as `'red'`. A `wrap` is `'wrap'`, `'truncate'`, `'truncate-start'`, `'truncate-middle'`, or `'truncate-end'`. | Everywhere |
417417| `Button` | A control that calls `onPress` | Everywhere |
418418| `Link`, `Code`, `Markdown` | A link with `href` and an optional `label`, a code block, and text formatted the way Claude's replies are. `Markdown` takes its content in a `text` prop, not in `children`, and needs a `key` when you pass `onLinkPress`. | Everywhere |
419| `Input`, `Select` | A text field and a picker | Terminal, Desktop |
419| `Input`, `Select` | A text field and a dropdown | Terminal, Desktop |
420420| `Svg` | An SVG document | Desktop |
421421| `Client` | A region drawn by a second file of yours, for animation and pointer input. That file gets no mods API. It reaches your hooks only by posting data, which arrives as a `ui.message` event. | Terminal, Desktop |
422422| `Raster`, `Image` | A [grid of colored cells](#draw-a-grid-of-colored-cells), and a picture | Terminal |
423423 
424If your module is a `.tsx` or `.jsx` file, you can write the tree as JSX. Destructure the elements from `$.ui.resolve(e)` first, because a hooks module has no element globals.
424If your module is a `.tsx` or `.jsx` file, you can write the tree as JSX. Destructure the elements from `$.ui.resolve(e)` first.
425425 
426426If a tree uses an element the app doesn't have, a prop an element doesn't take, or a child where none goes, Claude Code draws its own version of the site.
427427 
from line 429
429429 
430430### Draw a grid of colored cells
431431 
432For a heat map, a sparkline, or a game board in the terminal, draw one `Raster` and not a `Box` for each cell. A `Raster` takes a `key`, its size in `columns` and `rows`, and `cells`, which packs every cell into one string. Each cell is three numbers: the character's code point, its color, and its background color. A color is a hexadecimal number with two digits each for red, green, and blue, such as `0xc62828` for a red, or `0x01000000` for the terminal's default.
432For a heat map, a sparkline, or a game board in the terminal, draw one `Raster` and not a `Box` for each cell. A `Raster` takes a `key`, its size in `columns` and `rows`, and `cells`, a base64 string that packs every cell. Each cell is three numbers: the character's code point, its color, and its background color. A color is a 24-bit RGB value in hexadecimal, such as `0xc62828` for a red. The value `0x01000000`, one above that range, means the terminal's default.
433433 
434434The Desktop app has no `Raster`, so check `e.surface` and draw text there. This pane body draws a three by two heat map:
435435 
from line 473
473473 
474474## Respond to presses and typing
475475 
476When the user presses a button, types into a field, or picks from a list your mod drew, Claude Code calls the function you gave that control, and it runs in your module. Each control takes its own callbacks:
476When the user presses a button, types into a field, or picks from a list your mod drew, Claude Code calls that control's callback, which runs in your module. Each control takes its own callbacks:
477477 
478478* **`Button`**: takes `onPress(e)`, where `e.surface` is the app the press came from
479479* **`Input`**: takes `onSubmit(value)` and `onInput(value)`
480480* **`Select`**: takes `onSelect(value)` with its choices in `options`, a list of at least one choice with unique values, such as `[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]`
481481 
482A test presses or types into a control by its `key`, so give each control one. Each use of a control also fires [`ui.press`, `ui.input`, or `ui.select`](/docs/en/plugins/mods/reference#interface) with the `key` in `e.element`, and another mod can hook those events. Its hook runs before your callback, so it sees what the user types into your `Input` and can change it or answer in place of your callback. The mods API has no method that presses another mod's button.
482A test presses or types into a control by its `key`, so give each control one. Each use of a control also fires [`ui.press`, `ui.input`, or `ui.select`](/docs/en/plugins/mods/reference#interface) with the `key` in `e.element`, and another mod can handle those events. Its hook runs before your callback, so it sees what the user types into your `Input` and can change it or answer in place of your callback. The mods API has no method that presses another mod's button.
483483 
484484<h3 id="know-which-keys-your-mod-can-receive">
485485 Keyboard focus and hotkeys
from line 489
489489 
490490#### How a pane gets keyboard focus
491491 
492A pane gets keyboard focus in one of three ways:
492A pane gets keyboard focus when:
493493 
494494* Your mod opens it with `focus: true` from a command or a press
495495* The user presses Ctrl+X then Tab
from line 513
513513 
514514#### Set a hotkey and the first focus
515515 
516Two props on a control decide how the keyboard reaches it:
516These props on a control decide how the keyboard reaches it:
517517 
518518* **`hotkey`**: to let the user press a `Button` with one key, give it a `hotkey` of one digit or one lowercase letter, as in `hotkey: 'a'`
519* **`autoFocus`**: to choose which control has the focus when the pane opens, add `autoFocus: true` to it. Leave the prop off the others, because Claude Code refuses `autoFocus: false`.
519* **`autoFocus`**: to choose which control has the focus when the pane opens, add `autoFocus: true` to it. The prop accepts only `true`, so omit it on the other controls.
520520 
521521How a hotkey shows depends on the button and the app:
522522 
from line 539
539539╰──────────────────────────────────────────────────────────╯
540540```
541541 
542The example uses two techniques:
542The example uses these techniques:
543543 
544544* **Take typed input**: an `Input` calls `onSubmit(value)` with the field's text when the user presses Enter, and `onInput(value)` on every change
545545* **Draw a list**: map your data to one row each, and give every row's button its own `key`
from line 613
613613 
614614The example saves the notes and doesn't load them. To bring them back in the next session, read them in a `session.start` hook, the way `hello-tabs` reads `count`.
615615 
616Three props make up the field's line, `Note: Type a note and press Enter ⏎ add`:
616These props make up the field's line, `Note: Type a note and press Enter ⏎ add`:
617617 
618618| Prop | In the example | What it is |
619619| :- | :- | :- |
from line 678
678678})
679679```
680680 
681Claude Code now runs your `ui.render` hook once a second. The timer stops when the module reloads, and the new copy of the module starts its own.
681Claude Code now runs your `ui.render` hook once a second. The timer stops when the module reloads, and the new instance of the module starts its own.
682682 
683683### How often a site can redraw
684684 
685Claude Code limits how often it redraws a site, so your mod can call `$.ui.invalidate` as often as its data changes. The visible pane and the band have a higher limit than other sites, and the [limits table](/docs/en/plugins/mods/reference#limits) has the numbers.
685Claude Code throttles redraws of a site, so your mod can call `$.ui.invalidate` as often as its data changes. The visible pane and the band have a higher limit than other sites, and the [limits table](/docs/en/plugins/mods/reference#limits) has the numbers.
686686 
687Calls that come faster than the limit are combined into one redraw. That redraw runs your hook once, and the hook reads your data as it is at that moment, so the latest value shows and the values in between don't. An animation can't run faster than the limit.
687Calls that come faster than the limit are coalesced into one redraw. That redraw runs your hook once, and the hook reads your data as it is at that moment, so the latest value shows and the values in between don't. An animation can't run faster than the limit.
688688 
689689## Keep state
690690 
691A mod has three places to keep a value, and they differ in how long the value lasts: until the module reloads, until the session ends, or from one session to the next. Choose by how long the value has to last:
691Where a mod keeps a value decides how long the value lasts: until the module reloads, until the session ends, or from one session to the next. Choose by how long the value has to last:
692692 
693693| Keep it in | It lasts until | Use it for |
694694| :- | :- | :- |
from line 706
706706 
707707#### Declare the values
708708 
709Declare the values in a types file. The outer key is your plugin's name, and each entry under it is a value and its type. Save this as `hello-tabs/types/index.d.ts`:
709Declare the values in a type declaration file. The outer key is your plugin's name, and each entry under it is a value and its type. Save this as `hello-tabs/types/index.d.ts`:
710710 
711711```typescript hello-tabs/types/index.d.ts theme={null}
712712declare module 'claude-code' {
from line 752
752752 
753753Because the `ui.render` hook read `count`, Claude Code runs the hook again each time the button writes it.
754754 
755Three rules apply to the code:
755These rules apply to the code:
756756 
757* **Write `plugin` and `key` as literal strings**: `claude plugin validate` reads them from your source
758* **Declare every value in the types file**: otherwise validation fails with `hello-tabs.count is not declared`
757* **Write `plugin` and `key` as string literals**: `claude plugin validate` reads them from your source
758* **Declare every value in the type declaration file**: otherwise validation fails with `hello-tabs.count is not declared`
759759* **Write from a callback or another event's hook**: a `ui.render` hook can read state and can't write it, so write from `onPress`, `onSubmit`, or a hook for another event
760760 
761761#### Change `hello-tabs` to use `$.state`
from line 773
773773 Load a saved value again after `/clear`
774774</h3>
775775 
776If your mod copies a saved value from `$.store` into `$.state` at `session.start`, it has to copy it again after `/clear`, `/resume`, or `/branch`. Those commands put every `$.state` value back to its default, and `session.start` doesn't fire again. [`classic.SessionStart`](/docs/en/plugins/mods/events#hook-the-settings-hook-events) does fire after each of them, with `e.source` set to `clear`, `resume`, or `fork`, so copy the value again in a hook on it. Otherwise your drawing shows the default, and a callback that saves the `$.state` value writes the default over what you stored.
776If your mod copies a saved value from `$.store` into `$.state` at `session.start`, it has to copy it again after `/clear`, `/resume`, or `/branch`. Those commands reset every `$.state` value to its default, and `session.start` doesn't fire again. [`classic.SessionStart`](/docs/en/plugins/mods/events#hook-the-settings-hook-events) does fire after each of them, with `e.source` set to `clear`, `resume`, or `fork`, so copy the value again in a hook on it. Otherwise your drawing shows the default, and a callback that saves the `$.state` value writes the default over what you stored.
777777 
778778This code loads `count` from both hooks. It builds on the `$.state` version of `hello-tabs`, where `count` is an atom and `update` is imported. Put `loadCount` above `register`, and add the `loadCount` call to the `session.start` hook you already have. `classic.SessionStart` also fires at startup and after compaction, which doesn't reset `$.state`, so the filter on `source` keeps the hook to the three resets:
779779 
from line 807
807807 
808808Every session on your machine that runs your mod shares one `$.store`. A `get` followed by a `set` isn't atomic. When two sessions each read a value, change it, and write it back, they race, and the second write replaces the first.
809809 
810Two choices make that less likely:
810To make that less likely:
811811 
812812* **Give each item its own key**: a `set` changes only its own key, so sessions that write different keys don't overwrite each other
813813* **Read again right before you write**: for a value that several sessions change, `get` the key in the callback and build the new value from that, not from a copy you loaded at `session.start`. Another session's write is still lost if it lands between your `get` and your `set`.
Feedback