1> ## Documentation Index
2> Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt
3> Use this file to discover all available pages before exploring further.
4
5# Draw in the interface with a mod
6
7> 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.
8
9A 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.
10
11This map shows where a mod can draw in a terminal session:
12
13<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" />
14
15<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" />
16
17In a narrower terminal, the pane sits above the prompt instead of beside the transcript.
18
19Build 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.
20
21<Note>
22 To look up one prop or limit, see the [reference](/docs/en/plugins/mods/reference#render-sites).
23</Note>
24
25## Build a pane with tabs
26
27In 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.
28
29The 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:
30
31<Frame>
32 <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" />
33
34 <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" />
35</Frame>
36
37Claude 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.
38
39<Steps>
40 <Step title="Create the plugin">
41 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.
42
43 Save the manifest as `hello-tabs/.claude-plugin/plugin.json`:
44
45 ```json hello-tabs/.claude-plugin/plugin.json theme={null}
46 {
47 "name": "hello-tabs",
48 "version": "0.1.0",
49 "description": "Opens a pane with two tabs and a counter",
50 "author": { "name": "Your Name" }
51 }
52 ```
53
54 Name your entry point in `hello-tabs/hooks/hooks.json`:
55
56 ```json hello-tabs/hooks/hooks.json theme={null}
57 {
58 "modules": ["./register.js"]
59 }
60 ```
61 </Step>
62
63 <Step title="Write the code">
64 The code does three jobs, one in each hook:
65
66 * Adds the `/hello-tabs` command
67 * Opens the pane when you run that command
68 * Draws the pane's content: the row of tabs and the open tab's body
69
70 Two module-level variables, `tab` and `count`, hold the pane's state.
71
72 Save this as `hello-tabs/hooks/register.js`:
73
74 ```javascript hello-tabs/hooks/register.js theme={null}
75 // The pane's id, used to open the pane and to recognize it when drawing
76 const PANE = 'hello-tabs'
77
78 // What the pane shows: which tab is open, and the counter's value
79 let tab = 'one'
80 let count = 0
81
82 export function register(on) {
83 // Runs before your first prompt, and again after a reload
84 on('session.start', async ($, e, next) => {
85 await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
86 // Load the count an earlier session saved, if there is one
87 const saved = await $.store.get('count')
88 if (typeof saved === 'number') count = saved
89 return next(e)
90 })
91
92 // Runs when you type /hello-tabs
93 on('command.run', { command: 'hello-tabs' }, async ($) => {
94 // Open the pane, give it the keyboard, and let Esc close it
95 await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
96 // Print nothing in the transcript
97 return {}
98 })
99
100 // Runs each time Claude Code draws a pane
101 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
102 // Leave other mods' panes alone
103 if (e.requestId !== PANE) return next(e)
104 // Get the elements this app can draw
105 const { Box, Text, Button } = $.ui.resolve(e)
106 // Ask Claude Code to run this hook again
107 const redraw = () => $.ui.invalidate('ui.render')
108
109 // One tab: a button that switches to its tab when pressed
110 const tabButton = (name, label, hotkey) =>
111 Button({
112 key: 'tab-' + name,
113 label,
114 hotkey,
115 plain: true,
116 // Dim the tab that isn't open
117 dimColor: tab !== name,
118 onPress: () => {
119 tab = name
120 redraw()
121 },
122 })
123
124 // What goes under the tabs, depending on which one is open
125 const body =
126 tab === 'one'
127 ? [Text({ children: ['This is the first tab.'] })]
128 : [
129 Box({
130 flexDirection: 'row',
131 columnGap: 2,
132 children: [
133 Button({
134 key: 'more',
135 label: 'Add one',
136 hotkey: 'a',
137 onPress: async () => {
138 count += 1
139 redraw()
140 // Save the count so it's there after a restart
141 await $.store.set('count', count)
142 },
143 }),
144 Text({ children: ['Count: ' + count] }),
145 ],
146 }),
147 ]
148
149 // The whole pane: the row of tabs, a blank line, then the body
150 return Box({
151 flexDirection: 'column',
152 children: [
153 Box({
154 flexDirection: 'row',
155 columnGap: 3,
156 children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
157 }),
158 Text({ children: [' '] }),
159 ...body,
160 ],
161 })
162 })
163 }
164 ```
165
166 Each hook also does something the code doesn't make plain:
167
168 * **[`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.
169 * **[`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.
170 * **`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.
171
172 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.
173 </Step>
174
175 <Step title="Open the pane">
176 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.
177 </Step>
178
179 <Step title="Check that the count was saved">
180 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.
181
182 To clear the count, have the mod call `$.store.delete('count')`. [Keep state](#keep-state) covers how long each kind of value lasts.
183 </Step>
184</Steps>
185
186## Pick where to draw
187
188A `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.
189
190Two 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:
191
192<Tabs>
193 <Tab title="Pane">
194 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.
195
196 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.
197
198 To draw in your pane, filter on `{ component: 'Pane' }` and check that `e.requestId` is your `id`.
199 </Tab>
200
201 <Tab title="Band above the prompt">
202 The band is a strip directly above the prompt input. It's always there, and every mod shares it.
203
204 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.
205
206 To draw in the band, filter on `{ component: 'AbovePrompt' }`.
207 </Tab>
208</Tabs>
209
210### Change what Claude Code already draws
211
212Claude 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:
213
214| Site | What it is |
215| :- | :- |
216| `UserMessage`, `AssistantMessage` | A message in the transcript |
217| `ToolUse`, `ToolResult`, `ToolGroup` | A tool call's row, its result, and a folded run of calls |
218| `CommandOutput` | The row a command printed |
219| `AskUserQuestion` | The dialog Claude opens to ask you a question |
220| `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 |
221| `InfoNotice`, `SessionMode`, `PromptHint` | Status lines under the logo, the mode labels in the footer, and the hint line under the prompt |
222
223At 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).
224
225<Tabs>
226 <Tab title="Change a detail">
227 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:
228
229 ```javascript theme={null}
230 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
231 // Keep Claude Code's spinner, and change the text after its word
232 return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
233 })
234 ```
235
236 The spinner keeps its animation and its word, and your text follows the word:
237
238 ```text theme={null}
239 Thinking · tool calls: 2…
240 ```
241 </Tab>
242
243 <Tab title="Replace the drawing">
244 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:
245
246 ```javascript theme={null}
247 on('ui.render', { component: 'Spinner' }, async ($, e) => {
248 const { Text } = $.ui.resolve(e)
249 // No call to next, so this line is drawn in the spinner's place
250 return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
251 })
252 ```
253
254 While Claude works, your line shows and Claude Code's spinner doesn't:
255
256 ```text theme={null}
257 Claude has made 2 tool calls
258 ```
259 </Tab>
260
261 <Tab title="Leave it alone">
262 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:
263
264 ```javascript theme={null}
265 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
266 // Nothing to show yet, so pass the event on unchanged
267 if (calls === 0) return next(e)
268 return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
269 })
270 ```
271
272 Before the first tool call, the spinner looks the way it does without the mod:
273
274 ```text theme={null}
275 Thinking…
276 ```
277 </Tab>
278</Tabs>
279
280The 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.
281
282The 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.
283
284### Open a pane at the right time
285
286A 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.
287
288To 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.
289
290```javascript theme={null}
291await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
292```
293
294To close the pane, call `$.ui.close` with the `id` you opened it with:
295
296```javascript theme={null}
297await $.ui.close({ id: 'hello-tabs' })
298```
299
300Besides `id`, `$.ui.open` takes these optional fields:
301
302| Field | What it does |
303| :- | :- |
304| `title` | The pane's tab label when more than one pane is open |
305| `focus` | Requests [keyboard focus](#know-which-keys-your-mod-can-receive) |
306| `closeOnEscape` | Makes Esc close the pane |
307| `holdToasts` | Holds toasts, the small notices from [`$.ui.toast`](/docs/en/plugins/mods/api#show-something-without-starting-a-turn), until the pane closes |
308| `rows` | The height to ask for when the pane sits above the prompt. The default is a third of the space. |
309| `columns` | The width to ask for when the pane sits beside the transcript |
310
311`focus`, `closeOnEscape`, and `holdToasts` are optional and accept only `true`. To leave one off, omit it. Passing `false` throws an error such as `ui.open: focus is true or left out`. To set one of them conditionally, add the field only when the condition holds. This call asks for keyboard focus only when `items` isn't empty:
312
313```javascript theme={null}
314const pane = { id: 'hello-tabs', title: 'Hello tabs' }
315await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)
316```
317
318To let a command open the pane while Claude is working, add `immediate: true` when you [register the command](/docs/en/plugins/mods/api#add-a-command). Without it, a command typed during a turn waits for the turn to end.
319
320#### When a pane waits for a wider terminal
321
322A pane your mod opens without being asked doesn't appear in a narrow terminal, so it can't take over a small screen. Whether it appears depends on what opened it:
323
324* **Opened by something the user did**, such as a command they ran or a button they pressed, the pane appears at any width
325* **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.
326
327When 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.
328
329## Build a tree from elements
330
331What a `ui.render` hook returns is an element tree: a description of what to draw, made of boxes, text, and controls nested inside each other. You describe the drawing, and Claude Code renders it in the terminal or the Desktop app.
332
333To 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`.
334
335Most drawings use four elements. Select a tab to see each one and how the terminal draws it:
336
337<Tabs>
338 <Tab title="Text">
339 `Text` draws a string, with optional styling such as `bold` and `color`:
340
341 ```javascript theme={null}
342 Text({ children: ['This is the first tab.'] })
343 ```
344
345 ```text theme={null}
346 This is the first tab.
347 ```
348 </Tab>
349
350 <Tab title="Box">
351 `Box` arranges what's inside it, in a row or a column. This one puts a button and a line of text side by side, two columns apart:
352
353 ```javascript theme={null}
354 Box({
355 flexDirection: 'row',
356 columnGap: 2,
357 children: [
358 Button({ key: 'more', label: 'Add one', onPress: addOne }),
359 Text({ children: ['Count: 0'] }),
360 ],
361 })
362 ```
363
364 ```text theme={null}
365 [ Add one ] Count: 0
366 ```
367 </Tab>
368
369 <Tab title="Button">
370 `Button` is a control the user can press. It runs your `onPress` callback. With `plain: true` it has no brackets and shows its hotkey:
371
372 ```javascript theme={null}
373 Button({ key: 'more', label: 'Add one', onPress: addOne })
374 Button({ key: 'tab-one', label: 'One', hotkey: '1', plain: true, onPress: showTabOne })
375 ```
376
377 ```text theme={null}
378 [ Add one ]
379 1: One
380 ```
381 </Tab>
382
383 <Tab title="Input">
384 `Input` is a text field. It runs your `onSubmit` callback with the text when the user presses Enter:
385
386 ```javascript theme={null}
387 Input({
388 key: 'new-note',
389 label: 'Note',
390 placeholder: 'Type a note and press Enter',
391 value: '',
392 submitLabel: 'add',
393 onSubmit: addNote,
394 })
395 ```
396
397 ```text theme={null}
398 Note: Type a note and press Enter ⏎ add
399 ```
400 </Tab>
401</Tabs>
402
403This table lists every element:
404
405| Element | What it draws | Where |
406| :- | :- | :- |
407| `Box` | A flex container. Takes layout props such as `flexDirection`, `columnGap`, `padding`, `borderStyle`, and `width`. | Everywhere |
408| `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 |
409| `Button` | A control that calls `onPress` | Everywhere |
410| `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 |
411| `Input`, `Select` | A text field and a picker | Terminal, Desktop |
412| `Svg` | An SVG document | Desktop |
413| `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 |
414| `Raster`, `Image` | A [grid of colored cells](#draw-a-grid-of-colored-cells), and a picture | Terminal |
415
416If 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.
417
418If 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.
419
420In a session started with `--plugin-dir`, a transcript line says so, such as `ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own`. The [debug log](/docs/en/plugins/mods/troubleshoot#read-the-debug-log) records it as `ui.render (Pane): a hook returned a tree that does not validate` with the same reason. Nothing else appears in the session, so when a drawing doesn't show up, check that line or the log.
421
422### Draw a grid of colored cells
423
424For 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.
425
426The Desktop app has no `Raster`, so check `e.surface` and draw text there. This pane body draws a three by two heat map:
427
428```javascript theme={null}
429// The value that means "use the terminal's default color"
430const DEFAULT_COLOR = 0x01000000
431
432// Pack rows of [character, color] pairs into the one string a Raster takes
433// One cell is three numbers: the character's code point, its color, and its background
434function cellsOf(rows) {
435 const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
436 return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
437}
438
439on('ui.render', { component: 'Pane' }, async ($, e, next) => {
440 // Draw only in the pane opened with the id 'heat'
441 if (e.requestId !== 'heat') return next(e)
442 const { Box, Text, Raster } = $.ui.resolve(e)
443 // Two rows of three cells, each a block character and its color
444 const rows = [
445 [['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
446 [['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
447 ]
448 if (e.surface !== 'terminal') {
449 return Text({ children: ['The heat map needs the terminal.'] })
450 }
451 return Box({
452 flexDirection: 'column',
453 children: [Raster({ key: 'grid', columns: 3, rows: 2, cells: cellsOf(rows) })],
454 })
455})
456```
457
458In the terminal, the pane shows the grid:
459
460<img src="https://mintcdn.com/claude-code/dgiVO_Od1X1faduV/images/mods-heat-map.svg?fit=max&auto=format&n=dgiVO_Od1X1faduV&q=85&s=b91bcce3bad74bc851149133d4acc5d5" alt="A pane in the terminal that holds a small grid of colored blocks, two rows of three. The top row is green, amber, and red. The bottom row is green, green, and amber." width="360" height="132" data-path="images/mods-heat-map.svg" />
461
462The `rows` array is the part you'd change, and `cellsOf` turns it into the packed string. The hook draws only in a pane whose `id` is `heat`, so open one with `$.ui.open({ id: 'heat' })` from a command, as the [`hello-tabs` example](#build-a-pane-with-tabs) opens its pane.
463
464Each character has to be one cell wide. To animate a `Raster` that's already on screen, call `$.ui.blit` with the pane's `id` as `requestId`, the `Raster`'s `key`, the same size, and new cells. For this example, that's `$.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) })`. It repaints that one element without running your `ui.render` hook again.
465
466## Respond to presses and typing
467
468When 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:
469
470* **`Button`**: takes `onPress(e)`, where `e.surface` is the app the press came from
471* **`Input`**: takes `onSubmit(value)` and `onInput(value)`
472* **`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' }]`
473
474A 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.
475
476<h3 id="know-which-keys-your-mod-can-receive">
477 Keyboard focus and hotkeys
478</h3>
479
480Your mod never reads the keyboard itself. The user presses a key, Claude Code decides which of your controls it's for, and that control's callback runs. Apart from a [digit hotkey on the band](/docs/en/plugins/mods/reference#elements), that happens only while your pane or band has keyboard focus. The rest of the time, keys go to the prompt.
481
482#### How a pane gets keyboard focus
483
484A pane gets keyboard focus in one of three ways:
485
486* Your mod opens it with `focus: true` from a command or a press
487* The user presses Ctrl+X then Tab
488* The user clicks it
489
490Claude Code grants `focus: true` only while the prompt is empty and nothing else has keyboard focus. A pane that opens while the user is typing doesn't take their keystrokes.
491
492#### What each key does
493
494This table lists what a key does while your pane or band has keyboard focus:
495
496| Key | What it does |
497| :- | :- |
498| Tab | Moves to the next control |
499| Up and Down | Move between controls while your drawing fits. When the pane or band has more rows than it can show, they scroll it. |
500| Enter | Presses the focused `Button`, submits the focused `Input`, or picks in a `Select` |
501| A button's hotkey | Presses that button. While an `Input` has the focus, every printable key goes to the field. |
502| Esc | Returns keyboard focus to the prompt. With `closeOnEscape: true`, it also closes the pane. |
503
504A mod can't bind Tab or the arrow keys to anything else, so a game steers with `w`, `a`, `s`, and `d`.
505
506#### Set a hotkey and the first focus
507
508Two props on a control decide how the keyboard reaches it:
509
510* **`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'`
511* **`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`.
512
513How a hotkey shows depends on the button and the app:
514
515| Button | In the terminal | In the Desktop app |
516| :- | :- | :- |
517| With brackets, the default | `[ Add one ]`, with no hotkey shown | The label with a small key beside it |
518| With `plain: true` | `1: One` | The label with a small key beside it |
519
520In the terminal, name the key in a bracketed button's label, or use `plain: true`, so the user can see what to press. The [elements reference](/docs/en/plugins/mods/reference#elements) has the other `Button` rules: `action`, digit hotkeys on the band, and two buttons on one hotkey.
521
522### Take typed input and draw a row for each item
523
524Many panes are a text field with a list under it. The example in this section is a notes pane: you type a note and press Enter to add it, and each note has an `x` button that deletes it. With two notes added, the terminal draws the pane this way:
525
526```text theme={null}
527╭──────────────────────────────────────────────────────────╮
528│ Note: Type a note and press Enter ⏎ add ✕ │
529│ x buy milk │
530│ x call bob │
531╰──────────────────────────────────────────────────────────╯
532```
533
534The example uses two techniques:
535
536* **Take typed input**: an `Input` calls `onSubmit(value)` with the field's text when the user presses Enter, and `onInput(value)` on every change
537* **Draw a list**: map your data to one row each, and give every row's button its own `key`
538
539This hook draws the pane's content:
540
541```javascript theme={null}
542// The list the pane draws
543let notes = []
544
545on('ui.render', { component: 'Pane' }, async ($, e, next) => {
546 // Draw only in the pane opened with the id 'notes'
547 if (e.requestId !== 'notes') return next(e)
548 const { Box, Text, Button, Input } = $.ui.resolve(e)
549 const redraw = () => $.ui.invalidate('ui.render')
550
551 return Box({
552 flexDirection: 'column',
553 children: [
554 Input({
555 key: 'new-note',
556 label: 'Note',
557 placeholder: 'Type a note and press Enter',
558 // Draw the field empty each time, which clears it after a submit
559 value: '',
560 submitLabel: 'add',
561 autoFocus: true,
562 // Runs when you press Enter in the field
563 onSubmit: async (value) => {
564 // Ignore an empty line
565 if (!value.trim()) return
566 notes = [...notes, value.trim()]
567 redraw()
568 await $.store.set('notes', notes)
569 },
570 }),
571 // One row for each note: a delete button, then the note's text
572 ...notes.map((note, i) =>
573 Box({
574 flexDirection: 'row',
575 columnGap: 1,
576 children: [
577 Button({
578 // A key of its own, so each row's button can be told apart
579 key: 'delete-' + i,
580 label: 'x',
581 plain: true,
582 onPress: async () => {
583 notes = notes.filter((_, j) => j !== i)
584 redraw()
585 await $.store.set('notes', notes)
586 },
587 }),
588 Text({ children: [note] }),
589 ],
590 }),
591 ),
592 ],
593 })
594})
595```
596
597To try the pane:
598
599* **Add a note**: type a line and press Enter. The line appears as a new row, and the field empties.
600* **Delete a note**: press Tab until the note's `x` button has the focus, then press Enter. The `x` is the button's label and not a hotkey, so typing the letter doesn't press it.
601
602Each change follows the same render cycle as `hello-tabs`: the callback changes `notes`, calls `redraw`, and saves the list to `$.store`.
603
604The field empties after each submit because of its `value` prop. `value` is the text the field holds when it's drawn, and the user's typing replaces it until your hook draws the field again. The example always draws the field with `''`.
605
606The 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`.
607
608Three props make up the field's line, `Note: Type a note and press Enter ⏎ add`:
609
610| Prop | In the example | What it is |
611| :- | :- | :- |
612| `label` | `Note` | The text before the field. The terminal draws `: ` after it. |
613| `placeholder` | `Type a note and press Enter` | Dim text that shows while the field is empty |
614| `submitLabel` | `add` | The word after `⏎` that says what Enter does |
615
616Submitting an `Input` doesn't start a turn unless your callback calls [`$.prompt.submit`](/docs/en/plugins/mods/api#start-a-turn-from-a-background-job).
617
618<h2 id="redraw-when-something-changes">
619 Redraw a site
620</h2>
621
622A drawing is a snapshot: it shows what your `ui.render` hook returned the last time the hook ran. To show something new, the hook has to run again. Claude Code runs it again for some changes, and your mod asks for the rest.
623
624### When Claude Code redraws without being asked
625
626Claude Code runs your `ui.render` hook again when the site's props change or the terminal's width changes. It doesn't run the hook on a timer, and it can't tell when a variable in your module changes.
627
628### Redraw when your data changes
629
630To have your sites drawn again after your own data changes, call `$.ui.invalidate('ui.render')`. This pane counts presses. The button's callback changes `count`, then asks for a redraw:
631
632```javascript theme={null}
633let count = 0
634
635on('ui.render', { component: 'Pane' }, async ($, e, next) => {
636 if (e.requestId !== 'counter') return next(e)
637 const { Box, Text, Button } = $.ui.resolve(e)
638 return Box({
639 flexDirection: 'row',
640 columnGap: 2,
641 children: [
642 Button({
643 key: 'more',
644 label: 'Add one',
645 onPress: () => {
646 count += 1
647 // The data changed, so ask Claude Code to draw the pane again
648 $.ui.invalidate('ui.render')
649 },
650 }),
651 Text({ children: ['Count: ' + count] }),
652 ],
653 })
654})
655```
656
657Each press raises the number in the pane. The [`hello-tabs` example](#build-a-pane-with-tabs) wraps the same call in its `redraw` function.
658
659A value you keep in [`$.state`](#keep-a-value-in-\$-state) doesn't need the call, because writing the value redraws the sites that read it.
660
661### Redraw on a timer
662
663To keep a clock, a countdown, or a value from outside the session current, redraw on a schedule. Start a timer in the module's `session.start` hook. If the module already has one, as `hello-tabs` does, add the [`$.clock.every`](/docs/en/plugins/mods/api#run-work-in-the-background) line to it:
664
665```javascript theme={null}
666on('session.start', async ($, e, next) => {
667 // Every 1000 milliseconds, ask Claude Code to draw your sites again
668 $.clock.every(1000, () => $.ui.invalidate('ui.render'))
669 return next(e)
670})
671```
672
673Claude 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.
674
675### How often a site can redraw
676
677Claude 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.
678
679Calls 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.
680
681## Keep state
682
683A 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:
684
685| Keep it in | It lasts until | Use it for |
686| :- | :- | :- |
687| A module-level variable | The module reloads, which happens every time you save a file during development | Values you can lose, as `tab` is in `hello-tabs` |
688| `$.state` | The session ends, or the user runs `/clear`, `/resume`, or `/branch` | Values a drawing depends on that should survive a reload |
689| `$.store` | Your mod deletes it, or no session reads or writes the store for [`cleanupPeriodDays`](/docs/en/settings-reference#cleanupperioddays). The store is a key-value store, saved as a JSON file of your plugin's own under `~/.claude/plugins/store/`. | Settings, history, anything the user expects to find next time |
690
691`$.store.get(key)` resolves to the value or `undefined`, and `$.store.set(key, value)` takes any JSON value.
692
693### Keep a value in `$.state`
694
695`$.state` holds values for the length of a session, and it redraws for you. It's reactive state: a `ui.render` hook that reads a value subscribes to it, so Claude Code redraws that site each time you write the value, and you don't call `$.ui.invalidate`. A value in `$.state` also survives a reload of the module, which a variable doesn't.
696
697To set it up, declare your values, point your manifest at the declaration, then define and use each value. The examples move the `count` from `hello-tabs` into `$.state`.
698
699#### Declare the values
700
701Declare 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`:
702
703```typescript hello-tabs/types/index.d.ts theme={null}
704declare module 'claude-code' {
705 interface PluginState {
706 'hello-tabs': {
707 tab: 'one' | 'two'
708 count: number
709 }
710 }
711}
712```
713
714#### Point the manifest at the declaration
715
716To let `claude plugin validate` check your code against that file, add a `types` field to the manifest with its path:
717
718```json hello-tabs/.claude-plugin/plugin.json theme={null}
719{
720 "name": "hello-tabs",
721 "version": "0.1.0",
722 "description": "Opens a pane with two tabs and a counter",
723 "author": { "name": "Your Name" },
724 "types": "./types/index.d.ts"
725}
726```
727
728#### Define, read, and write a value
729
730In your module, define each value with a default, read it while drawing, and write it from a callback. `atom` names a value and its default, `read` returns it, and `update` writes it. The three helpers call `$.state.get` and `$.state.set` for you:
731
732```javascript theme={null}
733import { atom, read, update } from 'claude-code'
734
735// At the top of the module: name the value and give its default
736const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
737
738// In the ui.render hook: read the value to draw it
739const n = await read($, count)
740
741// In a Button: write a new value from the old one
742onPress: () => update($, count, (value) => value + 1)
743```
744
745Because the `ui.render` hook read `count`, Claude Code runs the hook again each time the button writes it.
746
747Three rules apply to the code:
748
749* **Write `plugin` and `key` as literal strings**: `claude plugin validate` reads them from your source
750* **Declare every value in the types file**: otherwise validation fails with `hello-tabs.count is not declared`
751* **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
752
753#### Change `hello-tabs` to use `$.state`
754
755To move `count` in `hello-tabs` into `$.state`, change every line that uses it:
756
757* **At the top of the module**: add the `import` line, and replace `let count = 0` with the `atom` line
758* **In the `ui.render` hook**: add the `read` line before `tabButton`, and draw `'Count: ' + n` in the `Text`
759* **In the Add one button**: replace `onPress` with the one in [Save from more than one session](#save-from-more-than-one-session), which saves the count as well as writing it
760* **In the `session.start` hook**: replace the two lines that read `saved` with the `loadCount` call from [Load a saved value again after `/clear`](#load-a-saved-value-again-after-clear)
761
762Keep `redraw` for the tab buttons, because `tab` is still a variable.
763
764<h3 id="load-a-saved-value-again-after-clear">
765 Load a saved value again after `/clear`
766</h3>
767
768If 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.
769
770This 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:
771
772```javascript theme={null}
773// Copy the saved count from $.store into $.state, or 0 if nothing is saved
774async function loadCount($) {
775 const saved = Number((await $.store.get('count')) ?? 0)
776 await update($, count, () => saved)
777}
778
779// Runs before your first prompt, and again after a reload
780on('session.start', async ($, e, next) => {
781 await loadCount($)
782 return next(e)
783})
784
785// Runs again after /clear, /resume, and /branch, which reports fork
786on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
787 await loadCount($)
788 return next(e)
789})
790```
791
792With both hooks in place, the pane shows the saved count after `/clear` and not `0`, and the next press of **Add one** adds to the saved count.
793
794`loadCount` writes the stored value over the one in `$.state`, and `session.start` fires again each time the module reloads. To keep the store from falling behind, save on every change, as the **Add one** button does.
795
796To check the reload without a session, [test the drawing after `/clear`](/docs/en/plugins/mods/test#test-a-drawing-after-clear).
797
798### Save from more than one session
799
800Every 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.
801
802Two choices make that less likely:
803
804* **Give each item its own key**: a `set` changes only its own key, so sessions that write different keys don't overwrite each other
805* **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`.
806
807This button adds one to whatever the store holds now, then updates the drawing:
808
809```javascript theme={null}
810onPress: async () => {
811 // Read what the store holds now, which another session may have changed
812 const saved = Number((await $.store.get('count')) ?? 0)
813 // Save the new count, then show it
814 await $.store.set('count', saved + 1)
815 await update($, count, () => saved + 1)
816}
817```
818
819If a second session has pressed its own button three times since this session started, this press shows and saves a count that includes those three.
820
821## Next steps
822
823* [React to events](/docs/en/plugins/mods/events): feed your drawing from tool calls and turns
824* [Use the mods API](/docs/en/plugins/mods/api): feed your drawing from timers and model calls
825* [Test a drawing](/docs/en/plugins/mods/test#test-a-drawing): press your buttons from a test, on more than one surface
826* [Render sites](/docs/en/plugins/mods/reference#render-sites) and [elements](/docs/en/plugins/mods/reference#elements): each site's props and each element's props