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.