Skip to content

Draw in the interface

Draw panes and the band above the prompt from a Claude Code mod, restyle built-in UI, handle buttons and text fields, redraw on change and keep state.

A mod can draw its own UI inside Claude Code and alter parts Claude Code already draws. Each place you can draw is a render site: a pane, the band above the prompt, the spinner, a tool call row and so on. Before drawing a site, Claude Code fires ui.render; your hook returns what to draw.

In a terminal session the layout looks like this:

┌──────────────────────────── transcript ─────────────────────┬──── pane ────┐
│ ● my-mod: a log line                         [ toast ]       │ your sidebar │
│ > user message           (UserMessage)                       │              │
│ assistant reply          (AssistantMessage)                  │              │
│ ⏺ Edit(src/app.ts)       (ToolUse / ToolResult)              │              │
│ ✻ Thinking…              (Spinner)                           │              │
├──────────────────────────────────────────────────────────────┴──────────────┤
│ band above the prompt    (AbovePrompt)                                       │
│ > prompt input           (Claude Code's own, not a site)                     │
│ ⚠ my-mod: status line                                                        │
└─────────────────────────────────────────────────────────────────────────────┘

In a narrower or non-fullscreen terminal, the pane sits above the prompt as a framed region instead of a sidebar.

Build your first mod first. Every prop and limit is in /docs/plugins/mods/reference.

Worked example: a context gauge pane

We will build ctx-gauge: a /gauge command opens a pane with two tabs. Usage shows how full the context window was after the last turn. Pins has a button that pins the current percentage, and the pin count survives restarts.

Files

ctx-gauge/.claude-plugin/plugin.json:

{
  "name": "ctx-gauge",
  "version": "0.1.0",
  "description": "A pane showing context window usage",
  "author": { "name": "Cameron Shields" }
}

ctx-gauge/hooks/hooks.json:

{ "modules": ["./register.js"] }

The module

ctx-gauge/hooks/register.js:

const PANE = 'ctx-gauge'

let tab = 'usage'          // which tab is showing
let percent = null         // context % after the last turn
let pins = []              // pinned percentages, persisted in $.store

export function register(on) {
  on('session.start', async ($, e, next) => {
    const saved = await $.store.get('pins')
    if (Array.isArray(saved)) pins = saved
    await $.command.register({ name: 'gauge', description: 'Open the context gauge', immediate: true })
    return next(e)
  })

  // Refresh the number after every main-thread turn
  on('turn.complete', async ($, e, next) => {
    const result = await next(e)
    if (!e.agentId) {
      const usage = await $.session.usage()
      percent = usage.context.percent
      $.ui.invalidate('ui.render')
    }
    return result
  })

  on('command.run', { command: 'gauge' }, async ($) => {
    await $.ui.open({ id: PANE, title: 'Context', focus: true, closeOnEscape: true })
    return {}
  })

  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
    if (e.requestId !== PANE) return next(e)
    const { Box, Text, Button } = $.ui.resolve(e)
    const redraw = () => $.ui.invalidate('ui.render')

    const tabButton = (id, label, hotkey) =>
      Button({ key: 'tab-' + id, label, hotkey, plain: true, dimColor: tab !== id,
        onPress: () => { tab = id; redraw() } })

    const shown = percent === null ? 'no turns yet' : Math.round(percent) + '% used'
    const body = tab === 'usage'
      ? [Text(percent > 80 ? { bold: true, color: 'red', children: [shown] } : { bold: true, children: [shown] })]
      : [
          Button({ key: 'pin', label: 'Pin current', hotkey: 'p', onPress: async () => {
            if (percent === null) return
            pins = [...pins, Math.round(percent)]
            redraw()
            await $.store.set('pins', pins)
          } }),
          Text({ dimColor: true, children: [pins.length ? 'Pinned: ' + pins.join('%, ') + '%' : 'Nothing pinned'] }),
        ]

    return Box({
      flexDirection: 'column',
      children: [
        Box({ flexDirection: 'row', columnGap: 3, children: [tabButton('usage', 'Usage', '1'), tabButton('pins', 'Pins', '2')] }),
        Text({ children: [' '] }),
        ...body,
      ],
    })
  })
}

Try it

claude --plugin-dir ./ctx-gauge

Run /gauge, have a short conversation, and watch the percentage update after each turn. Press 2, then p to pin. Close with Esc, restart Claude Code with the same flag, reopen: the pins are still there.

What is going on

  • command.run only opens the pane. Opening draws nothing; Claude Code then fires ui.render to ask what goes in it.
  • ui.render rebuilds the whole tree from tab, percent and pins every time it runs.
  • Every interaction follows one render cycle: a callback changes state, calls $.ui.invalidate('ui.render'), and the hook builds a fresh tree.
  • $.store persists pins between sessions. tab and percent are module variables and reset on reload.

Choosing where to draw

A ui.render hook runs for every site unless you filter it. Use a matcher such as { component: 'Pane' }. Inside the hook, e.component names the site, e.surface is terminal or desktop, e.props holds site data, and for panes e.requestId is the id you opened it with.

Pane. A sidebar beside the transcript in a wide fullscreen terminal, otherwise a framed region above the prompt. Several open panes get tabs showing their titles. A pane exists only once your mod calls $.ui.open({ id }). Filter on { component: 'Pane' } and check e.requestId.

The band (AbovePrompt). A strip directly above the prompt, always present and shared by all mods. Return a tree to show something, or next(e) to show nothing. A tree replaces whatever later mods draw there; to keep theirs, include await next(e) as a child in your Box.

Changing what Claude Code already draws

These built-in parts are render sites too:

SiteWhat it is
UserMessage, AssistantMessageMessages in the transcript
ToolUse, ToolResult, ToolGroupA tool call row, its result, a collapsed group
CommandOutputWhat a command printed
AskUserQuestionThe dialog Claude uses to ask you something
Spinner, ToolProgress, TurnDurationWorking indicator, a running tool's live progress, the line closing a turn
InfoNotice, SessionMode, PromptHintStatus under the logo, footer mode labels, hint under the prompt

You have three options at each one. Using the spinner and a percent variable:

// Tweak: keep Claude Code's drawing, change a prop
on('ui.render', { component: 'Spinner' }, async ($, e, next) =>
  next({ ...e, props: { ...e.props, suffix: ' · ctx ' + Math.round(percent ?? 0) + '%…' } }))
// Replace: return your own tree and never call next
on('ui.render', { component: 'Spinner' }, async ($, e) => {
  const { Text } = $.ui.resolve(e)
  return Text({ italic: true, children: ['working… context at ' + Math.round(percent ?? 0) + '%'] })
})
// Leave alone when there is nothing to add
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
  if (percent === null) return next(e)
  return next({ ...e, props: { ...e.props, suffix: ' · ctx ' + Math.round(percent) + '%…' } })
})

At these sites, next(e) returns a reference to Claude Code's drawing, { type: 'engine', ref } (unless a later mod returned its own tree). You can return it as is, or wrap it:

on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
  const { Box, Text } = $.ui.resolve(e)
  const original = await next(e)
  return Box({ flexDirection: 'column', children: [original, Text({ dimColor: true, children: ['Esc to interrupt'] })] })
})

The permission prompt is not a render site, so it cannot be changed. AskUserQuestion is, but your tree must contain the engine reference exactly once with your elements above it, or Claude Code draws its own dialog.

Pane, AbovePrompt, Spinner and the transcript sites work in both terminal and Desktop; some status sites are terminal-only (see the reference table).

Opening a pane

await $.ui.open({ id: 'ctx-gauge', title: 'Context', focus: true })
await $.ui.close({ id: 'ctx-gauge' })
FieldEffect
idThe pane's name; your ui.render hook checks for it
titleTab label when several panes are open
focusRequest keyboard focus
closeOnEscapeEsc closes the pane
holdToastsHold $.ui.toast notifications until the pane closes
rowsHeight requested when above the prompt (default: a third of the space)
columnsWidth requested when beside the transcript

focus, closeOnEscape and holdToasts accept only true. Passing false throws (for example ui.open: focus is true or left out). Add the field conditionally instead:

const base = { id: 'ctx-gauge', title: 'Context' }
await $.ui.open(userAsked ? { ...base, focus: true } : base)

Register the opening command with immediate: true if it should work while Claude is mid-turn.

Narrow terminals. A pane opened because the user did something (ran a command, pressed a button) appears at any width. A pane your mod opens on its own (from a timer or turn.start) appears only from 144 columns, or 110 once the user has opened that pane themselves. $.ui.open resolves { isPlaced: true } when shown, or isPlaced: false with a reason when waiting. A waiting pane appears when the user opens it or widens the window. To merely announce something, use $.ui.toast('...').

Building trees

A ui.render hook returns an element tree. Get element functions with const { Box, Text, Button } = $.ui.resolve(e), call them with props, and nest elements and strings in children.

ElementDrawsWhere
BoxFlex container: flexDirection, columnGap, gap, padding, borderStyle, width and moreEverywhere
TextStyled text: color (theme key or colour name), bold, dimColor, italic, wrap ('wrap', 'truncate', 'truncate-start', 'truncate-middle', 'truncate-end')Everywhere
ButtonCalls onPressEverywhere
Link, Code, MarkdownA link (href, optional label), a code block, Claude-style formatted text. Markdown takes text, not children, and needs a key if you pass onLinkPressEverywhere
Input, SelectText field, dropdownTerminal, Desktop
SvgAn SVG documentDesktop
ClientA region drawn by a second file of yours for animation and pointer input; it has no mods API and talks to your hooks by posting data (ui.message). Failures raise ui.faultTerminal, Desktop
Raster, ImageA grid of coloured cells; a pictureTerminal

In a .tsx or .jsx module you can use JSX after destructuring the elements. Samples with screenshots are in /docs/plugins/mods/gallery.

If a tree uses an element the app lacks, a prop an element does not take, or children where none are allowed, Claude Code draws its own version of the site instead. With --plugin-dir you get a transcript line like ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own; the debug log records ui.render (Pane): a hook returned a tree that does not validate. When a drawing silently fails to appear, look for that line.

A grid of coloured cells

For heat maps, sparklines or game boards in the terminal, draw one Raster rather than a Box per cell. Props: key, columns, rows, and cells, a base64 string packing three 32-bit numbers per cell: the character's code point, its colour, and its background. Colours are 24-bit RGB like 0x1565c0; 0x01000000 means the terminal default.

A seven-day activity strip:

const DEFAULT = 0x01000000
const pack = (cells) =>
  new Uint8Array(Uint32Array.from(cells.flatMap(([ch, fg]) => [ch.codePointAt(0), fg, DEFAULT])).buffer).toBase64()

const shade = (n) => (n === 0 ? 0x424242 : n < 5 ? 0x81c784 : n < 15 ? 0x43a047 : 0x1b5e20)

on('ui.render', { component: 'Pane' }, async ($, e, next) => {
  if (e.requestId !== 'activity') return next(e)
  const { Text, Raster } = $.ui.resolve(e)
  const commitsPerDay = [0, 3, 12, 7, 20, 1, 0]
  if (e.surface !== 'terminal') return Text({ children: ['Commits: ' + commitsPerDay.join(' ')] })
  return Raster({ key: 'week', columns: 7, rows: 1, cells: pack(commitsPerDay.map((n) => ['■', shade(n)])) })
})

Desktop has no Raster, hence the fallback. Each character must be one cell wide. To animate without rerunning the hook, call $.ui.blit({ requestId: 'activity', key: 'week', columns: 7, rows: 1, cells }) with new cells of the same size.

Presses and typing

Controls call your callbacks, which run in your module:

  • Button: onPress(e), where e.surface is the app that sent it.
  • Input: onSubmit(value) on Enter, onInput(value) on every change.
  • Select: onSelect(value), with options a non-empty list of unique { value, label }.

Give every control a key; tests address controls by key. Each use also fires ui.press, ui.input or ui.select with the key in e.element, and other mods can hook those; their hooks run before your callback and can alter or replace it. No API lets a mod press another mod's button.

Focus and keys

Your mod never reads the keyboard directly. Claude Code routes keys to your controls only while your pane or band has focus (the one exception: digit hotkeys on band buttons). A pane gets focus when opened with focus: true from a command or press, when the user presses Ctrl+X then Tab, or when clicked. focus: true is honoured only if the prompt is empty and nothing else has focus, so a pane cannot steal keystrokes mid-typing.

Key (with focus)Effect
TabNext control
Up, DownMove between controls, or scroll when content overflows
EnterPress the focused button, submit the input, pick in a select
A button's hotkeyPress it (except while an Input has focus, which takes printable keys)
Page Up, Page Down, Home, EndScroll overflowing content
Ctrl+X then an arrowResize: Left or Up grows the pane, Right or Down shrinks it
Ctrl+X then XClose the pane, even from inside a field
EscReturn focus to the prompt; also closes with closeOnEscape: true

Tab and arrows cannot be rebound, so games steer with w, a, s, d.

hotkey is a single digit or lowercase letter. autoFocus: true (only true is accepted) chooses the control focused when the pane opens.

Button styleTerminal showsDesktop shows
Default (bracketed)[ Pin current ], hotkey not shownLabel with a small key badge
plain: truep: Pin currentLabel with a small key badge

In the terminal, either mention the key in a bracketed label or use plain: true so users can see it.

A field with a list

A bookmarks pane: type a URL, press Enter, and each bookmark gets a remove button.

let marks = []

on('ui.render', { component: 'Pane' }, async ($, e, next) => {
  if (e.requestId !== 'marks') return next(e)
  const { Box, Text, Button, Input } = $.ui.resolve(e)
  const redraw = () => $.ui.invalidate('ui.render')
  return Box({
    flexDirection: 'column',
    children: [
      Input({
        key: 'add', label: 'URL', placeholder: 'Paste a link and press Enter',
        value: '', submitLabel: 'save', autoFocus: true,
        onSubmit: async (value) => {
          const url = value.trim()
          if (!url) return
          marks = [...marks, url]
          redraw()
          await $.store.set('marks', marks)
        },
      }),
      ...marks.map((url, i) => Box({
        flexDirection: 'row', columnGap: 1,
        children: [
          Button({ key: 'rm-' + i, label: 'remove', plain: true, onPress: async () => {
            marks = marks.filter((_, j) => j !== i)
            redraw()
            await $.store.set('marks', marks)
          } }),
          Text({ children: [url] }),
        ],
      })),
    ],
  })
})

The field clears after each submit because it is always drawn with value: ''; the user's typing replaces value only until the next draw. In the terminal the field reads URL: Paste a link and press Enter ⏎ save: label gets : appended, placeholder is dim until typing starts, and submitLabel follows ⏎. Submitting never starts a turn unless your callback calls $.prompt.submit. To restore marks next session, load them in session.start.

Redrawing

A drawing is a snapshot of the last tree your hook returned.

Automatic redraws happen when the site's props change or the terminal width changes, and once more after your ui.fault hooks return (so you can drop a failing Client). There is no timer, and Claude Code cannot see your variables change.

Your data changed: call $.ui.invalidate('ui.render'), as every callback above does. Values held in $.state redraw automatically.

On a schedule: start a timer in session.start:

on('session.start', async ($, e, next) => {
  $.clock.every(1000, () => $.ui.invalidate('ui.render'))   // a ticking clock
  return next(e)
})

Redraws are throttled (10 per second, or 30 in the terminal for the visible pane, expanded band and prompt hint). Faster calls coalesce into one redraw that reads your data at that moment, so animations cannot exceed the limit.

Keeping state

Store inLasts untilGood for
Module variableThe module reloads (every save during development)Disposable UI state like the current tab
$.stateSession ends, or /clear, /resume, /branchValues a drawing depends on that should survive a reload
$.storeYour mod deletes it, or no session uses the store for cleanupPeriodDays. Saved as your plugin's JSON file under ~/.claude/plugins/store/Settings, history, anything expected next time

$.store.get(key) resolves to the value or undefined; $.store.set(key, value) takes any JSON value; $.store.delete(key) removes it.

Reactive $.state

A ui.render hook that reads a $.state value subscribes to it, so writing the value redraws that site automatically. It also survives module reloads.

1. Declare the values in ctx-gauge/types/index.d.ts:

declare module 'claude-code' {
  interface PluginState {
    'ctx-gauge': {
      tab: 'usage' | 'pins'
      pinCount: number
    }
  }
}

2. Point the manifest at it with "types": "./types/index.d.ts" in plugin.json, so claude plugin validate checks your code against it.

3. Define, read and write with the helpers from claude-code:

import { atom, read, update } from 'claude-code'

const pinCount = atom({ plugin: 'ctx-gauge', key: 'pinCount' }, 0)   // top of module

// inside ui.render
const n = await read($, pinCount)

// inside a callback
onPress: () => update($, pinCount, (v) => v + 1)

Rules: write plugin and key as string literals; keep each atom in a const (let fails with takes a source the scan can read); declare every value (otherwise ctx-gauge.pinCount is not declared); and write only from callbacks or other events' hooks, since ui.render hooks may read but not write state.

Reloading saved values after /clear

/clear, /resume and /branch reset every $.state value to its default, and session.start does not fire again. classic.SessionStart does, with e.source of clear, resume or fork. If you copy a stored value into $.state at start-up, copy it again there, or you will draw the default and may then save the default over your stored data:

async function loadPins($) {
  const saved = Number((await $.store.get('pinCount')) ?? 0)
  await update($, pinCount, () => saved)
}

on('session.start', async ($, e, next) => {
  await loadPins($)
  return next(e)
})

// SessionStart also fires at startup and after compaction, which do not reset state
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
  await loadPins($)
  return next(e)
})

Because session.start reruns on every reload and overwrites $.state from the store, save to the store on every change so it never lags. /docs/plugins/mods/test shows how to test this path.

Several sessions, one store

All sessions on the machine share your mod's $.store, and a get then set is not atomic. Two sessions doing read-modify-write will race and the later write wins. To reduce the risk:

  • give each item its own key, so writers to different keys never collide;
  • re-read immediately before writing, rather than relying on a copy loaded at start-up.
onPress: async () => {
  const current = Number((await $.store.get('pinCount')) ?? 0)
  await $.store.set('pinCount', current + 1)
  await update($, pinCount, () => current + 1)
}

A write from another session can still land between your get and set, but the window is small.