{"slug": "how-to-add-a-side-pane-to-claude-code-with-a-mod-using-ui-open-and-ui-render", "title": "How to add a side pane to Claude Code with a mod using $.ui.open and ui.render", "summary": "A developer documented how to add a side pane to Claude Code using a mod, showing that a pane must be opened with $.ui.open({ id }) and its contents returned from a ui.render handler matching { component: 'Pane' } on the same requestId. The walkthrough includes a working register.tsx example with a /hello-pane command, a counter button that persists state via $.store, and notes that panes opened without a user action are only placed when the screen is wide enough.", "body_md": "To show a pane from a mod, you do two things: **open it with `$.ui.open({ id })`, and return its contents from `ui.render` with `{ component: 'Pane' }`**. Opening alone draws nothing. After you open it, Claude Code calls `ui.render` to ask what to draw in that pane, and you return a tree (nested components) only when `e.requestId` matches your `id`.\n\nA pane is your own panel next to the conversation. According to the official docs, it is:\n\nIf you open several panes, you switch between them with tabs labeled by title.\n\nThis pane opens with `/hello-pane` and has a button that increments a counter. Keep the three-file layout from Part 2 and replace only `register.tsx`.\n\n``` js\n// The pane's name. Used as the marker when opening and drawing\nconst PANE = 'hello-pane'\n\n// The value the pane draws\nlet count = 0\n\nexport function register(on) {\n  // At session start, add the command and read the previously saved count\n  on('session.start', async ($, e, next) => {\n    const saved = await $.store.get('count')\n    if (typeof saved === 'number') count = saved\n    await $.command.register({ name: 'hello-pane', description: 'Open the hello-pane panel' })\n    return next(e)\n  })\n\n  // Open with /hello-pane\n  on('command.run', { command: 'hello-pane' }, async ($) => {\n    await $.ui.open({ id: PANE, title: 'Hello', focus: true, closeOnEscape: true })\n    // Show nothing in the conversation\n    return {}\n  })\n\n  // Draw the pane\n  on('ui.render', { component: 'Pane' }, async ($, e, next) => {\n    // Leave other mods' panes alone\n    if (e.requestId !== PANE) return next(e)\n\n    const { Box, Text, Button } = $.ui.resolve(e)\n\n    return Box({\n      flexDirection: 'column',\n      children: [\n        Text({ bold: true, children: ['Hello pane'] }),\n        Box({\n          flexDirection: 'row',\n          columnGap: 2,\n          children: [\n            Button({\n              key: 'more',\n              label: 'Add 1',\n              hotkey: 'a',\n              onPress: async () => {\n                count += 1\n                $.ui.invalidate('ui.render')\n                // Keep it for the next session\n                await $.store.set('count', count)\n              },\n            }),\n            Text({ children: [`Count: ${count}`] }),\n          ],\n        }),\n      ],\n    })\n  })\n}\n```\n\nThis is the `hello-tabs` example from the official interface page, reduced to a single screen.\n\n`$.ui.open`\n`id` is required. Everything else is optional.\n\n| Field | Meaning | \n|---|---|\n| `id` | The pane's name. Used as `e.requestId` when drawing and in`$.ui.close({ id })` when closing | \n| `title` | Tab label when there are several panes | \n| `focus` | Send keyboard input to the pane | \n| `closeOnEscape` | Close with Esc | \n| `holdToasts` | Hold toasts until the pane is closed | \n| `rows` | Preferred height when shown above the prompt | \n| `columns` | Preferred width when shown next to the conversation | \n\n**`focus`, `closeOnEscape`, and `holdToasts` accept only `true`.** Writing `focus: false` throws. To set one conditionally, include or omit the whole field.\n\n``` js\nconst pane = { id: PANE, title: 'Hello' }\nawait $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)\n```\n\nA pane `id` can use letters, digits, `_`, and `-`, up to 64 characters.\n\nThe example opens the pane from the `/hello-pane` command. **Opening in response to a user action (a command, a button press, a sent prompt) is the norm.** An open with no user action behind it, for example from `session.start` or a timer, is only placed when the screen is wide enough. On the desktop, this is almost always the cause of \"I started it but the pane did not appear.\" Part 6 covers it in detail.\n\nIf you want the command to open the pane even while Claude is working, add `immediate: true` to `$.command.register`. Without it, the command waits until the turn ends.\n\n`ui.render` and `Pane`\n`ui.render` is called many times, once per place to draw. The second argument `{ component: 'Pane' }` (the matcher) narrows it to panes, and then `e.requestId` tells you whether it is your pane. **If it is not your pane, `return next(e)`** to pass it on. Forget this and you will blank out other mods' panes.\n\nA pane is a snapshot of \"the last tree `ui.render` returned.\" Changing a value alone does not change the screen.\n\n`onPress`\n`$.ui.invalidate('ui.render')`\nThis cycle is the same for every component. Claude Code redraws on its own only when the site's props or the screen width change. It does not redraw on a timer by itself. To redraw on a timer, create `$.clock.every(1000, () => $.ui.invalidate('ui.render'))` in `session.start`.\n\nRedraws are throttled to 10 per second. Faster requests are merged into one.\n\nThe pane width arrives as `e.props.bodyColumns` (in columns). When the pane is next to the conversation (`e.props.placement === 'dock'`), the height arrives as `e.props.scroll.bodyRows`. If the tree is taller than the pane, the whole thing scrolls.\n\n**The narrow side pane you usually open on the desktop was about 58 columns (about 440px).** Layouts tuned on a wide screen break here. If you make \"no overlapping or clipped text at this width\" your pass line from the start, you will not have to rebuild later.\n\n| Place | How long it lasts | Good for | \n|---|---|---|\n| A top-level variable in the file | Until the mod reloads (every save during development) | Display state you can afford to lose | \n| `$.state` | Until the session ends, or until `/clear` ,`/resume` ,`/branch` | Values the renderer reads that should survive a reload | \n| `$.store` | Until the mod deletes it (saved as JSON per plugin) | Settings, history, things you want next time | \n\nThe example keeps the count in `$.store`, so it survives closing and reopening Claude Code. `$.store` is **limited to 4 MiB per plugin**, and every session on the same PC shares one store. If two sessions read the same key and write it back, the later write overwrites the earlier one. For values written from several sessions, split the keys or re-read right before writing.\n\nA desktop pane can hold `Box`, `Text`, `Button`, `Input`, `Select`, `Markdown`, `Svg`, and others. **HTML is not supported.** You cannot put a web page into the panel as is, so draw rich visuals with `Svg`.\n\n``` js\nif (e.surface === 'desktop') {\n  const { Svg } = $.ui.resolve(e)\n  const w = 400\n  const h = 120\n  const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${w} ${h}\" width=\"100%\" height=\"100%\">\n    <rect x=\"0\" y=\"0\" width=\"${w * Math.min(count, 10) / 10}\" height=\"${h}\" fill=\"#4a90d9\"/>\n  </svg>`\n  return Svg({ source: svg, alt: `Count ${count}`, width: w, height: h })\n}\n```\n\n`Svg` needs both `source` and `alt`, and without `width` and `height` it shrinks to a small box. Part 6 explains why and how to fix it.\n\nYou cannot change how buttons look (color, shape, size). The most you can do is drop the brackets with `plain: true` and dim them with `dimColor: true`.\n\nA pane receives keyboard input when it was opened with `focus: true`, when it is clicked, or after Ctrl+X followed by Tab. While it has focus, Tab moves to the next component, Enter presses, a button's `hotkey` (one digit or one lowercase letter) presses that button, and Esc returns to the prompt. On the desktop, the key is shown small next to the button label.\n\nReal mods that use panes can be read alongside reproductions of their UI in the [modscode reviewed list](https://modscode.com/gallery/). Seeing how they lay things out in the narrow desktop panel was the most useful part for me.\n\nThe next article in this series covers the band, status line, and toast, which are lighter-weight than a pane.\n\n*This article was written with AI assistance (Claude) and checked against the official Claude Code docs; anything marked \"not verified\" could not be confirmed there.*", "url": "https://wpnews.pro/news/how-to-add-a-side-pane-to-claude-code-with-a-mod-using-ui-open-and-ui-render", "canonical_source": "https://dev.to/nakadadev/how-to-add-a-side-pane-to-claude-code-with-a-mod-using-uiopen-and-uirender-3c24", "published_at": "2026-10-07 13:09:15+00:00", "updated_at": "2026-10-07 13:18:03.201918+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents"], "entities": ["Claude Code", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-add-a-side-pane-to-claude-code-with-a-mod-using-ui-open-and-ui-render", "markdown": "https://wpnews.pro/news/how-to-add-a-side-pane-to-claude-code-with-a-mod-using-ui-open-and-ui-render.md", "text": "https://wpnews.pro/news/how-to-add-a-side-pane-to-claude-code-with-a-mod-using-ui-open-and-ui-render.txt", "jsonld": "https://wpnews.pro/news/how-to-add-a-side-pane-to-claude-code-with-a-mod-using-ui-open-and-ui-render.jsonld"}}