Sweep 19 Sep 2026 · 02:36Z Build v2.1.278 500 read Stable v2.1.267 Latest v2.1.278 Next v2.1.278 Feeds RSS JSON llms.txt Unofficial
Mods · 06 of 10

Drawing on the screen

Panes, components, focus and scroll: the half of the runtime that puts pixels in the terminal.

A mod can draw. Not in a webview and not in a side channel: inside Claude Code's own transcript, using Claude Code's own components, on the terminal, the desktop app and the phone. The /diff pane is a mod doing this, which is the strongest evidence available that the drawing surface 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 } = await $.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>
})

The onion works here exactly as it does everywhere else. next(e) gives you what the links beneath drew, and you wrap it, ignore it, or replace it. Returning without calling next means the component is yours alone.

$.ui.resolve

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

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

This exists because the element set depends on the surface the render is for, and one session can have several attached at once. Resolving against e gets you the right ones. ui.resolve is itself an event, so another plugin can hook it.

The fourteen components

Component 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 appears in the transcript
ToolResult Its result
ToolGroup Several collapsed together
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 region directly above the prompt
Pane A panel beside the transcript

The cheat sheet lists thirteen and omits CommandOutput. Two things the declarations say about this list are worth quoting: the permission dialog is drawn by the engine alone and is not in it, and Pane is the one component whose instances a plugin opens. Everything else you hook when the engine happens to draw it. A pane you create.

Elements, and the thing that silently draws nothing

The element set is per surface and they are not the same:

export type Elements = {
  terminal: { Box, Text, Button, Input, Select, Link, Code, Client, Raster };
  desktop:  { Box, Text, Button, Input, Select, Svg, Link, Code, Client };
  mobile:   { Box, Text, Button, Svg, Link, Code };
};

Terminal has Raster and no Svg. Desktop and mobile have Svg and no Raster. Mobile has neither Input nor Select nor Client.

An element the surface does not have draws a fragment. No error, no warning, nothing in the log. An Svg on the terminal renders as empty space, and the first time you meet this you will assume your data is wrong. Resolve against the event, use what comes back, and if you branch on surface do it explicitly.

The mobile gap is a protocol gap rather than a device one, and the declarations say so: the control protocol carries presses (ui_press) but no ui_input or ui_select yet, "not a limit of the device". So a mod designed around buttons works everywhere and a mod designed around a text field does not.

Props are an allowlisted subset of Ink's Box and Text props. Any other prop fails validation rather than being ignored, which is the good outcome. false, null and undefined children are dropped, so the usual {isOpen && <Thing />} works.

One measured rendering bug

At 2.1.269 the terminal Code element wraps long lines but measures them unwrapped, so a pane containing a code line wider than the pane overdraws its neighbours. The workaround is to break code lines yourself to the width you were given. I have not re-measured it at 2.1.272, so treat it as a thing to check rather than a current fact.

Opening a pane

$.ui.open takes six keys:

await $.ui.open({
  id: 'my-pane',
  title: 'My pane',
  holdToasts: true,       // suppress toasts while it is up
  closeOnEscape: true,
  rows: 12,               // sizes an inline dialog
  focus: true,
})

Calling open again with the same id and a different rows resizes it rather than opening a second one. That is how the diff mod's fitDialog works. $.ui.close({ id }) is the counterpart.

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 } = await $.ui.resolve(e)

  columns = e.viewport?.columns ?? columns

  const width = Math.max(1, e.props.bodyColumns - RIGHT_PAD)
  const rows  = e.props.scroll.bodyRows

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

Three things come off the event and all three matter. e.props.placement is 'dock' or 'inline', which tells you whether you are beside the transcript or inside it. e.props.bodyColumns and e.props.scroll.bodyRows are your drawing area. e.viewport is the whole terminal.

The return next(e) on the first line is not a formality. Every Pane render in the session comes through your hook, including other plugins' panes, and passing them through untouched is the whole of your obligation to them.

Reading the viewport without drawing anything

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

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

  return next(e)
})

It is there to learn the terminal width, because PromptHint is drawn often and a pane that is closed is not. Hooking a component purely to observe is legitimate and cheap, and the pass-through keeps it invisible.

Interaction

Five events carry what the person did, and each is a fold you can intercept.

ui.press

Buttons carry onPress and the engine raises ui.press. Matching on your plugin gets you your own.

ui.scroll

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

  return {}                     // consumed; the surface does not also scroll
})

e.by is the delta. Returning {} swallows it.

ui.focus

e.element is an element path, a string like 0.1.2. You can redirect a focus move by passing a different one 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 is the one that surprised me. Closing a pane is a dispatch and a hook can deny it:

on('ui.close', { id: PANE_ID }, async ($, e, next) => {
  const isPerson = e.origin.kind === 'person'

  if (isPerson && isInDetailView) {
    backToList()

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

  const result = await next(e)
  const isClosed = result.deny === undefined

  return result
})

That is how the diff pane makes Escape mean "back" inside a file and "close" at the top level, without owning the key. e.origin.kind tells a person's Escape from a programmatic close, which is the distinction the behaviour hangs on.

Client modules

Client is an element that runs a module of yours on the surface itself, for interaction that cannot wait for a round trip. It posts back through ui.message, and a post from one carries next.origin.plugin === "client".

Two hard limits, and exceeding either unmounts the instance rather than truncating it:

  • At most 2,000 nodes in the tree.
  • At most 100,000 characters serialised.

Terminal and desktop have Client. Mobile does not.

Redrawing

$.ui.invalidate('ui.render') asks for a redraw. Nothing redraws on its own, so a pane whose data changed in a timer callback shows stale content until you ask.

Ask sparingly. The diff mod runs three timers and every one of them exists to avoid redrawing: a debounce on refresh, a coalescer on redraw, and a git HEAD poll that checks whether the pane is open first and does nothing when it is closed. A mod that invalidates on every tool call will make the terminal flicker and the box work.

The smaller screen verbs

Call What it does
$.ui.log(text) Writes a visible transcript row
$.ui.notice(text) An informational row
$.ui.toast(text) A transient toast
$.ui.status(text) The status line. $.ui.status(undefined) clears it.
$.ui.ask(...) A question. It is tool.call on AskUserQuestion underneath.

$.ui.log is the only log channel and it is visible. There is nowhere to put a debug line the person does not see, so a chatty mod is a mod that fills somebody's transcript with its own bookkeeping. The engine's own debug log, the one you read with --debug hooks --debug-file, is written by the engine about you and is not a channel you can write to.

One more constraint on output: Claude Code strips graphics escape sequences from hook output, so terminal image protocols are not a way round the element set. Raster is the sanctioned path on the terminal and Svg is the sanctioned path everywhere else.