Follow Discord
Sweep 06 Nov 2026 · 01:32Z Build v2.1.319 537 read Stable v2.1.311 Latest v2.1.318 Next v2.1.319 Feeds RSS JSON llms.txt llms-full.txt Unofficial
Mods · 06 of 10

Drawing on the screen

Panes, components, elements per surface, and how to redraw without making the terminal flicker.

Read against 2.1.286 Anthropic's page: Draw in the interface with a mod 18,168 characters 9 sections ui.json, paged

A mod can draw inside Claude Code's own transcript, with Claude Code's own components. At 2.1.287 that means the terminal and the desktop app. The declarations describe VS Code and the phone as well, but Anthropic's overview says nothing a mod draws shows in VS Code's chat panel, and from the phone it shows in the terminal on your machine. There's no webview and no side window. The /diff pane is a mod doing exactly this, which is the best evidence there is that the drawing API is real and complete.

ui.render

Every component the engine draws goes through a fold, and your hook sits in it:

on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
  const { Box, Text, Button } = $.ui.resolve(e)

  const drawn = await next(e)                    // what beneath drew

  return <Box>
    {drawn}
    <Text dimColor>{e.props.output.length} chars</Text>
    <Button label="copy" onPress={() => $.ui.toast("copied")} />
  </Box>
})

It works the same way as every other event. next(e) gives you what the links beneath drew, and you can wrap it, ignore it or replace it. Return without calling next and the component is yours alone.

$.ui.resolve

You never import elements. You ask the event for them:

const { Box, Text, Button, Select, Code } = $.ui.resolve(e)

It's there because each surface has its own set of elements, and one session can have several surfaces attached at once. Resolving against e gets you the right set. It's synchronous, so there's nothing to await, and ui.resolve is an event too, so another plugin can hook it. Anthropic's reference says the engine raises it while mods load, once for each app, render site and mod, and what comes out is the table $.ui.resolve(e) reads from then on.

The fifteen components

Component What it is
AskUserQuestion A question put to the person
UserMessage A message from the person
AssistantMessage A message from Claude
ToolUse A tool call as it shows in the transcript
ToolResult Its result
ToolGroup Several collapsed together
ToolProgress A row under a running tool call, today the run-in-background pill. Terminal only.
CommandOutput What a slash command printed
Spinner The working indicator
TurnDuration The elapsed-time readout
InfoNotice An informational row
SessionMode The mode indicator
PromptHint The hint line under the prompt
AbovePrompt The band just above the prompt
Pane A panel beside or above the transcript

ToolProgress arrived in 2.1.277. The declarations say two things about this list worth knowing. The permission dialog isn't on it, because the engine draws that alone, and Pane is the one component a plugin opens itself. Everything else you hook when the engine happens to draw it. A pane you create.

Elements, and the one that quietly draws nothing

Each surface has its own element set. This is the table from the 2.1.286 declarations, and it hasn't changed since 2.1.283:

Surface Elements
terminal Box, Text, Button, Input, Select, Link, Code, Markdown, Client, Raster, Image
desktop Box, Text, Button, Input, Select, Svg, Link, Code, Markdown, Client
vscode Box, Text, Button, Input, Select, Svg, Link, Code, Markdown
mobile Box, Text, Button, Svg, Link, Code, Markdown

The terminal has Raster and Image and no Svg. Every other surface has Svg. VS Code is the desktop set without Client, and the phone has no Input, Select or Client.

The 2.1.287 declarations still list all four surfaces, and the test kit still mounts all four. Anthropic's elements table has only Terminal and Desktop columns, and its rows agree with the first two rows here. Read the vscode and mobile rows as what the declarations describe, since by Anthropic's account neither draws a mod at 2.1.287. I haven't attached either to check.

An element the surface doesn't have, resolved through $.ui.resolve(e), draws an empty fragment. You get no error and no warning. At 2.1.287 in the test kit, an Svg resolved on the terminal mounted without complaint and drew as an empty Box, with nothing in the engine's report, while the same hook on the desktop drew the Svg. The first time it happens you'll assume your data is wrong. Resolve against the event, use what comes back, and branch on e.surface on purpose if you need to.

Anthropic's elements section puts it differently. It says a tree with an element the app doesn't have makes Claude Code draw its own version of the site, with a ui.render (Pane) refused: ... line in the transcript under --plugin-dir and a hook returned a tree that does not validate in the debug log. That's what happens to a node the resolver didn't make. At 2.1.287 the kit refused a hand-built { type: 'Svg' } node on the terminal with ui.render (Pane) refused: Svg is not an element of the terminal surface; the engine drew its own, and a bad prop with Text prop "bogusProp" is not allowed, which is the example their page gives. So the two pages describe two different paths, and both held in the kit. I haven't watched either in a live terminal session at 2.1.287.

The phone's missing fields are a gap in the app. The declarations say the mobile app "draws no field yet; not a limit of the device, nor of the control protocol", and the table grows when the app draws them. For now a mod built around buttons works everywhere, and one built around a text field doesn't.

The three remote surfaces reach a session over their own wire protocol, ui_* messages such as ui_attach to join and ui_render to ask for a component. The declarations have named both since at least 2.1.283, and this site's 2.1.285 changelog has the headless control handler taking them from 2.1.285. I haven't attached a desktop, phone or VS Code client to see it.

Props are an allowlisted subset of Ink's Box and Text props. Anything else fails validation and isn't just ignored, which is what you want. false, null and undefined children are dropped, so the usual {isOpen && <Thing />} works.

A Button can take role="dismiss" since 2.1.283. It's only a drawing hint: the desktop draws its own close control for it and the terminal draws a normal button. Your onPress still does the closing.

One measured rendering bug

At 2.1.269 the terminal Code element wraps long lines but measures them as if unwrapped, so a pane with a code line wider than itself draws over its neighbours. The fix is to break code lines yourself to the width you were given. I haven't re-checked it since, so treat it as something to test.

Opening a pane

$.ui.open takes seven keys:

const opened = await $.ui.open({
  id: 'my-pane',
  title: 'My pane',
  holdToasts: true,       // hold toasts back while it's up
  closeOnEscape: true,
  rows: 12,               // height when it sits inline above the prompt
  columns: 60,            // width when it's docked beside the transcript
  focus: true,
})

if (!opened.isPlaced) $.ui.toast('widen the terminal to see it')

Since 2.1.280 open tells you whether the pane is actually on screen. A pane the person asked for, from a command they typed or a button they pressed, is placed at any width. One your plugin opens by itself, from a timer or session.start say, waits undrawn below 144 terminal columns, or 110 if the person has opened that id before and not closed it by hand. You get { isPlaced: false, reason } back when that happens, so check it.

rows, columns and focus are requests. A size the person dragged the pane to wins, and focus is only given when the prompt is empty and has the keyboard. title can't contain control characters.

Opening the same id again updates the one pane and never opens a second. That's how the diff mod's fitDialog resizes itself. $.ui.close({ id }) closes it, and $.ui.panes() lists the panes your plugin has open, which helps after a hot reload since the engine keeps track of them for you.

Then you draw it by hooking ui.render on Pane and checking the id is yours:

on('ui.render', { component: 'Pane' }, async ($, e, next) => {
  if (e.requestId !== PANE_ID || !isOnPaneSurface(e)) return next(e)

  const { Box, Text, Button, Select, Code } = $.ui.resolve(e)

  columns = e.viewport?.columns ?? columns

  const width = Math.max(1, e.props.bodyColumns - RIGHT_PAD)
  const rows  = e.props.placement === 'dock' ? e.props.scroll.bodyRows : undefined   // inline: draw it all

  return paneView({ Box, Text, Button, Select, Code }, model, width, rows)
})

You need three things off the event. e.props.placement is 'dock' or 'inline', so you know whether you're beside the transcript or inside it. e.props.bodyColumns is the width you have. Height depends on the placement. Docked, e.props.scroll.bodyRows is the number of rows the pane has. Inline, the pane grows with your tree up to a limit and bodyRows counts only the rows showing now, so sizing the tree from it gives you an empty pane, which recipe 7 measured at 2.1.286. This page used to call both numbers the space you have to draw in. Anthropic's render sites section has the same split, and adds that a tree taller than the pane scrolls as a whole and that the first row should be two cells shorter in the terminal, because the pane's close mark is drawn over its last cell. e.viewport is the whole terminal, and e.viewport.isFullscreen says whether the transcript is fullscreen.

The return next(e) on the first line matters. Every Pane render in the session goes through your hook, other plugins' panes included, and passing theirs through untouched is all you owe them.

Reading the viewport without drawing anything

The diff mod hooks ui.render on PromptHint and draws nothing:

on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
  columns = e.viewport?.columns ?? columns

  return next(e)
})

It's only there to learn the terminal width, because PromptHint gets drawn often and a closed pane doesn't. Hooking a component just to watch it is fine and cheap, and passing it through keeps you invisible.

2.1.286 gives PromptHint a way to add to the line without taking it over. Its props gain tail, text drawn dim at the end of the engine's own hint, so the engine's pills stay live:

on('ui.render', { component: 'PromptHint' }, async ($, e, next) => next({ ...e, props: { ...e.props, tail: '3 files changed' } }))

The declarations say the terminal cuts it where the row ends, leaves it out when fewer than four columns of it would show, and that no other surface draws it yet. Rewrite hint itself and you replace the whole line, tail included. That's all from the 2.1.286 declarations, and I haven't looked at a tail on a real terminal.

Interaction

Each thing the person does raises an event, and each one is a fold you can step into.

Keyboard focus and hotkeys are covered on Anthropic's interface page. A pane gets the keyboard from focus: true, from Ctrl+X then Tab, or from a click. A Button takes a hotkey of one digit or one lowercase letter, plain: true to show that key in the terminal, and action to answer one of Claude Code's own keybindings. autoFocus: true on a control picks where focus starts, and autoFocus: false is refused. That's all from their page. I haven't tested any of it.

ui.press

Buttons take onPress, and the engine raises ui.press. Match on your plugin to get only yours.

Since 2.1.284 onPress gets an argument, the ui.press event as the hooks above it left it. Its surface says where the press came from, and its element is the Button's key. The declarations want you to pass the surface on when a press copies something:

<Button key="copy" label="copy" onPress={press => $.ui.copy({ text: url, surface: press.surface })} />

A handler that takes no argument still type-checks, so () => $.ui.toast("copied") is fine. What breaks is code that calls a Button's onPress itself with nothing, because the type now wants the press. I checked the argument in the test kit at 2.1.286: a press on a mounted desktop Button logged pressed on desktop element copy.

ui.scroll

on('ui.scroll', { requestId: PANE_ID }, async ($, e, next) => {
  scrollBy(e.by)
  $.ui.invalidate('ui.render')

  return {}                     // consumed, so the surface doesn't scroll too
})

e.by is how far, negative toward the top. A wheel tick is 1, a page key is a page. Returning {} swallows the scroll, and next({ ...e, offset }) moves it somewhere else.

ui.focus

e.element is a path to an element, a string like 0.1.2. You can send a focus move somewhere else by passing a different path down, or swallow it:

on('ui.focus', { plugin: MY_PLUGIN }, async ($, e, next) => {
  if (shouldLand) return next({ ...e, element: landingPath })

  return {}
})

ui.close, which you can refuse

This one surprised me. Closing a pane is a dispatch too, and a hook can say no:

on('ui.close', { id: PANE_ID }, async ($, e, next) => {
  if (e.origin.kind === 'person' && isInDetailView) {
    backToList()

    return { deny: 'back to the file list' }        // Escape goes up a level
  }

  return next(e)
})

That's how the diff pane makes Escape mean "back" inside a file and "close" at the top, without taking over the key. e.origin.kind is person, plugin or unload, so you can tell the person's Escape from code closing the pane. An unload close can't be refused, because the pane is already gone by the time you hear about it.

Client modules

Client is an element that runs a module of yours on the surface itself, for interaction that can't wait for a round trip. It posts back through ui.message, and those posts carry next.origin.plugin === "client". The module has to be a string literal path, and anything computed is refused at load.

A client tree has limits, and going past any of them unmounts the instance without truncating it:

  • 20,000 nodes
  • 32 levels deep
  • 100,000 characters serialised

Only the terminal and the desktop app have Client.

Redrawing

$.ui.invalidate('ui.render') asks for a redraw. Claude Code runs your hook again by itself only when the site's props change or the terminal's width does, as Anthropic's redraw section says. It can't see your module's variables, so a pane whose data changed in a timer callback keeps showing old content until you ask. This page used to say nothing redraws by itself, which went too far. The call is capped at ten redraws a second, thirty for the pane that's showing and for the band above the prompt, and calls in between get folded together. This page used to leave the band out of the thirty. Anthropic's limits and the 2.1.287 declarations both include it.

$.state is the other way to do it. A value set with $.state.set redraws whatever reads it, so you don't have to call invalidate at all. Anthropic's state section is the full manual. You name a value with atom and read and write it with read and update, all imported from claude-code, declare it under your plugin's name in a types file, and write it from a callback or another event's hook, because a ui.render hook can read state and can't write it. $.state goes back to its defaults on /clear, /resume and /branch, and session.start doesn't fire again, so a value you copied in from $.store has to be copied again from a classic.SessionStart hook. I haven't tested $.state myself.

Don't redraw more than you need to. The diff mod runs three timers and every one of them is there to avoid a redraw: a debounce on refresh, one that batches redraws, and a git HEAD poll that checks the pane is open first and does nothing if it isn't. A mod that redraws on every tool call makes the terminal flicker and the machine work.

The smaller screen verbs

Call What it does
$.ui.log(text) Writes a row to the transcript
$.ui.log(text, { to: 'debug' }) Writes to the debug log only
$.ui.notice(tool_use_id, text) One line under the open dialog for that tool call, such as its permission prompt. undefined removes it.
$.ui.toast(text) A toast, top right. A click dismisses it and hovering holds it.
$.ui.status(text) Your plugin's status line under the prompt, one per plugin. $.ui.status(undefined) clears it.
$.ui.ask(...) A question, which is tool.call on AskUserQuestion underneath
$.ui.copy({ text }) Puts text on the clipboard, and says whether it worked

These return nothing, except ask and copy, which return promises.

Log to the debug sink by default. Before 2.1.275 $.ui.log could only write to the transcript, so a chatty mod filled the person's screen with its own bookkeeping. Now { to: 'debug' } sends a line to claude --debug or --debug-file and the person never sees it.

Claude Code strips graphics escape sequences from hook output, so terminal image protocols won't get you past the element set. Raster and Image are the supported way to draw pictures on the terminal, and Svg is the way everywhere else.

Feedback