{"page":"ui","title":"Drawing on the screen","blurb":"Panes, components, elements per surface, and how to redraw without making the terminal flicker.","build":"2.1.286","sections":["ui.render","$.ui.resolve","The fifteen components","Elements, and the one that quietly draws nothing","Opening a pane","Interaction","Client modules","Redrawing","The smaller screen verbs"],"limit":8000,"section":null,"offset":0,"count":7902,"total":18168,"next_offset":7902,"next_url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/ui.json?offset=7902","markdown":"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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/overview#where-mods-run) 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.\n\n## ui.render\n\nEvery component the engine draws goes through a fold, and your hook sits in it:\n\n```tsx\non(\"ui.render\", { component: \"ToolUse\" }, async ($, e, next) => {\n  const { Box, Text, Button } = $.ui.resolve(e)\n\n  const drawn = await next(e)                    \/\/ what beneath drew\n\n  return <Box>\n    {drawn}\n    <Text dimColor>{e.props.output.length} chars<\/Text>\n    <Button label=\"copy\" onPress={() => $.ui.toast(\"copied\")} \/>\n  <\/Box>\n})\n```\n\nIt 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.\n\n## $.ui.resolve\n\nYou never import elements. You ask the event for them:\n\n```tsx\nconst { Box, Text, Button, Select, Code } = $.ui.resolve(e)\n```\n\nIt'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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/reference#interface) 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.\n\n## The fifteen components\n\n| Component | What it is |\n|---|---|\n| `AskUserQuestion` | A question put to the person |\n| `UserMessage` | A message from the person |\n| `AssistantMessage` | A message from Claude |\n| `ToolUse` | A tool call as it shows in the transcript |\n| `ToolResult` | Its result |\n| `ToolGroup` | Several collapsed together |\n| `ToolProgress` | A row under a running tool call, today the run-in-background pill. Terminal only. |\n| `CommandOutput` | What a slash command printed |\n| `Spinner` | The working indicator |\n| `TurnDuration` | The elapsed-time readout |\n| `InfoNotice` | An informational row |\n| `SessionMode` | The mode indicator |\n| `PromptHint` | The hint line under the prompt |\n| `AbovePrompt` | The band just above the prompt |\n| `Pane` | A panel beside or above the transcript |\n\n`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.\n\n## Elements, and the one that quietly draws nothing\n\nEach 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:\n\n| Surface | Elements |\n|---|---|\n| `terminal` | Box, Text, Button, Input, Select, Link, Code, Markdown, Client, Raster, Image |\n| `desktop` | Box, Text, Button, Input, Select, Svg, Link, Code, Markdown, Client |\n| `vscode` | Box, Text, Button, Input, Select, Svg, Link, Code, Markdown |\n| `mobile` | Box, Text, Button, Svg, Link, Code, Markdown |\n\nThe 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`.\n\nThe 2.1.287 declarations still list all four surfaces, and the test kit still mounts all four. Anthropic's [elements table](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/reference#elements) 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.\n\n**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.\n\nAnthropic's [elements section](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/interface#build-a-tree-from-elements) 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.\n\nThe 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.\n\nThe 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](\/v\/2.1.285) 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.\n\nProps 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.\n\nA 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.\n\n### One measured rendering bug\n\nAt 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.\n\n## Opening a pane\n\n`$.ui.open` takes seven keys:\n\n```ts\nconst opened = await $.ui.open({\n  id: 'my-pane',\n  title: 'My pane',\n  holdToasts: true,       \/\/ hold toasts back while it's up\n  closeOnEscape: true,\n  rows: 12,               \/\/ height when it sits inline above the prompt\n  columns: 60,            \/\/ width when it's docked beside the transcript\n  focus: true,\n})\n\nif (!opened.isPlaced) $.ui.toast('widen the terminal to see it')\n```\n\nSince 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.\n","previous_page":"events","next_page":"config","url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/ui","index_url":"https:\/\/changelogs.core-directive.com\/reference\/mods.json"}