[FEATURE] Mods: an official "render sites" example mod that labels every place a mod can draw

Status Open
Reported on v2.1.295
Maintainer reply None cached
Activity 0 comments · opened Oct 15, 2026

Summary

Ship an example mod, with the plugin-authoring examples or in the plugin directory, that puts a visible label on every place a mod can draw: each ui.render component, plus $.ui.status, toast, log, ask, notice and open. A developer installs it and sees on their own surface (terminal, desktop, VS Code, mobile) what is drawn, where, and in what form, before writing a line of their own mod.

The problem

The types say on which surfaces a component is raised. They do not say where it is drawn on each surface, or whether a given way of answering it is drawn at all. We found the actual layout only by writing a probe mod and checking it site by site with screenshots. On the desktop Code tab, Claude Code 2.1.295 (Windows), 2026-10-15, we found:

  • SessionMode is raised on the desktop, but a rewritten props.modes is ignored. Only a tree of the mod's own is drawn, and it lands on the row under the model and effort picker, next to $.ui.status (see also #100116);
  • PromptHint draws nothing: not tail (documented as terminal-only), not a rewritten hint, not a tree of the mod's own;
  • AskUserQuestion refuses anything below the dialog ("draws below the dialog; the engine drew its own"). A label above it is drawn;
  • AbovePrompt is drawn, but the next-step box takes its place at the end of a turn (#102249);
  • Spinner is drawn under the "processing" row, but not under a tool step's row;
  • $.ui.notice shows nothing under the permission dialog, whether called once before the dialog opens or repeated every 500 ms while it is open;
  • the $.ui.status row lifts the bottom area and takes height from the transcript;
  • the layout changed within five days, between 2.1.293 and 2.1.295, with the same mod code. A mod's line that sat beside the model picker moved to a row under it.

Each of these cost a round of writing, reloading and asking a person "what do you see, and where?". Every mod author repeats that round, and repeats it after every update, because what they learned may no longer hold.

Proposal

An official example mod, kept in step with each release, that:

  1. wraps every ui.render component the engine raises with a dim label naming it (SITE: AssistantMessage), above or below the engine's own drawing, whichever the site accepts;
  2. calls each $.ui display method once with a labelled text: status, toast, log, open (a pane), ask (behind a slash command), notice (on a permission dialog raised by a classic.PreToolUse { ask });
  3. also tries the other ways of answering where a site has them (SessionMode: rewritten modes and own tree; PromptHint: tail, rewritten hint and own tree), so the author sees which form the surface honours;
  4. ideally prints, in a pane, the matrix the engine itself knows: per surface, which sites are raised and which answer shapes are drawn.

The last point would also make the docs self-checking: if the matrix the mod shows and the docs disagree, one of them is wrong.

Example

Our probe is about 100 lines and could serve as a starting point:

<details>
<summary><code>hooks/register.tsx</code></summary>

import type { Register, RenderComponent } from 'claude-code'

const PANE = 'probe-pane'
const TRIGGER = 'PROBE-NOTICE-TRIGGER'

const WRAPPED: RenderComponent[] = [
  'AskUserQuestion', 'UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult',
  'ToolGroup', 'ToolProgress', 'CommandOutput', 'Spinner', 'TurnDuration',
  'InfoNotice', 'SessionMode', 'PromptHint',
]

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    const result = await next(e)
    $.ui.status('PROBE-STATUS')
    $.ui.toast('PROBE-TOAST')
    void $.ui.open({ id: PANE, title: 'PROBE-PANE-TITLE' })
    $.ui.log('PROBE-LOG')
    await $.command.register({ name: 'probe-ask', description: 'Probe: $.ui.ask dialog' })
    return result
  })

  on('ui.render', async ($, e, next) => {
    if (!WRAPPED.includes(e.component)) return next(e)
    const { Box, Text } = $.ui.resolve(e)
    const label = <Text dimColor>PROBE-{e.component.toUpperCase()}</Text>
    // The question dialog refuses anything drawn below it.
    if (e.component === 'AskUserQuestion') {
      return <Box flexDirection="column">{label}{await next(e)}</Box>
    }
    return <Box flexDirection="column">{await next(e)}{label}</Box>
  })

  on('ui.render', { component: 'AbovePrompt' }, ($, e) => {
    const { Text } = $.ui.resolve(e)
    return <Text dimColor>PROBE-ABOVEPROMPT</Text>
  })

  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
    const { Text } = $.ui.resolve(e)
    return <Text>PROBE-PANE</Text>
  })

  on('command.run', { command: 'probe-ask' }, async $ => {
    const answer = await $.ui.ask('PROBE-ASK?', ['PROBE-YES', 'PROBE-NO'])
    $.ui.log(`PROBE-LOG answer: ${answer}`)
    return { text: `PROBE-COMMAND-TEXT answer: ${answer}` }
  })

  on('classic.PreToolUse', async ($, e, next) => {
    if (e.tool !== 'Bash' || !e.command.startsWith(`echo ${TRIGGER}`)) return next(e)
    return { ask: 'PROBE-ASK-REASON' }
  })

  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    if (!e.command.startsWith(`echo ${TRIGGER}`)) return next(e)
    $.ui.notice(e.tool_use_id, 'PROBE-NOTICE-0')
    const ran = next(e)
    for (let i = 1; i <= 20; i++) {
      await $.clock.sleep(500)
      try { $.ui.notice(e.tool_use_id, `PROBE-NOTICE-${i}`) } catch { break }
    }
    return ran
  })
}

</details>

One trap we hit while writing it, worth avoiding in an official example: on('ui.render', { component }, …) with component taken from a RenderComponent[] loop passes claude plugin validate, but tsc rejects it, because the matcher expects a literal. One matcher-less hook that checks e.component passes both.

Why this is good for Anthropic too

  • fewer issues like the ones linked below, each of which is one author discovering one site's behaviour by hand;
  • a quick visual regression check for the team when a surface's layout changes;
  • it shows authors what each surface honours before they design a mod around a place that is not drawn.

Related

  • #100116 Mods (desktop): PromptHint is never raised, and SessionMode is right-aligned and width-capped
  • #102249 Desktop Code tab: mod AbovePrompt band is hidden while the next-step box is shown
  • #99647 Mods: $.ui.status is always drawn with a ⚠ warning icon and the mod's name as a prefix
  • #98988 Mods: allow styled text on the same row as the native PromptHint line

View original on GitHub ↗