Dibuja en la interfaz con un mod
Dibuja paneles, una franja sobre el prompt, botones y campos de texto desde un mod de Claude Code, gestiona pulsaciones y entradas, y conserva el estado entre redibujados y sesiones.
Un mod puede dibujar su propia interfaz en Claude Code y cambiar partes de la interfaz que Claude Code ya dibuja. Cada lugar donde un mod puede dibujar se llama punto de renderizado, como un panel, la franja sobre el prompt o el spinner. Claude Code dispara el evento ui.render cada vez que está a punto de dibujar un punto de renderizado, y tu hook para ese evento devuelve lo que se debe dibujar allí.
Este mapa muestra dónde puede dibujar un mod en una sesión de terminal:
En una terminal más angosta, el panel se ubica sobre el prompt en lugar de junto a la transcripción.
Crea tu primer mod antes de empezar aquí. Comienza con el ejemplo práctico, que crea un panel con dos pestañas y un contador, y luego lee la sección de cada parte que quieras cambiar.
[!NOTE]
Para consultar una prop o un límite específico, consulta la referencia.
Crea un panel con pestañas
En esta sección creas un mod que agrega un comando /hello-tabs, y el comando abre un panel. Un panel es una barra lateral junto a la transcripción en una terminal de pantalla completa ancha, o una región enmarcada sobre el prompt en otros casos. Este panel muestra dos pestañas, y la segunda pestaña tiene un botón que suma uno a un contador. El conteo sigue ahí después de reiniciar Claude Code.
El mod terminado se ve así. La grabación abre el panel, cambia a la segunda pestaña, presiona el botón algunas veces y vuelve a la primera pestaña:
Las pestañas son dos botones en una fila. El mod registra cuál está activa y dibuja el contenido de esa pestaña debajo de la fila.
1. Crea el plugin
Un mod es un plugin con un manifiesto, un hooks.json que apunta a tu código y el archivo de código. Crear un mod explica cada uno. Crea un directorio llamado hello-tabs con los directorios .claude-plugin y hooks dentro, y luego guarda los dos primeros archivos.
Guarda el manifiesto como hello-tabs/.claude-plugin/plugin.json:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" }
}
Indica tu punto de entrada en hello-tabs/hooks/hooks.json:
{
"modules": ["./register.js"]
}
2. Escribe el código
Esta lista indica qué hace cada hook, en el orden en que aparecen en el código:
- Agrega el comando
/hello-tabsy carga el conteo que guardó una sesión anterior - Abre el panel cuando ejecutas ese comando
- Dibuja el contenido del panel: la fila de pestañas y el cuerpo de la pestaña abierta
Dos variables a nivel de módulo, tab y count, contienen el estado del panel.
Guarda esto como hello-tabs/hooks/register.js:
// The pane's id, used to open the pane and to recognize it when drawing
const PANE = 'hello-tabs'
// What the pane shows: which tab is open, and the counter's value
let tab = 'one'
let count = 0
export function register(on) {
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// Load the count an earlier session saved, if there is one
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// Runs when you type /hello-tabs
on('command.run', { command: 'hello-tabs' }, async ($) => {
// Open the pane, give it the keyboard, and let Esc close it
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// Print nothing in the transcript
return {}
})
// Runs each time Claude Code draws a pane
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Leave other mods' panes alone
if (e.requestId !== PANE) return next(e)
// Get the elements this app can draw
const { Box, Text, Button } = $.ui.resolve(e)
// Ask Claude Code to run this hook again
const redraw = () => $.ui.invalidate('ui.render')
// One tab: a button that switches to its tab when pressed
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// Dim the tab that isn't open
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// What goes under the tabs, depending on which one is open
const body =
tab === 'one'
? [Text({ children: ['This is the first tab.'] })]
: [
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
hotkey: 'a',
onPress: async () => {
count += 1
redraw()
// Save the count so it's there after a restart
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// The whole pane: the row of tabs, a blank line, then the body
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}
Cada hook también hace algo que el código no deja claro:
session.starttambién lee el conteo guardado desde$.store, un almacén de clave-valor que persiste entre sesiones.command.runsolo le indica a Claude Code que el panel existe. Abrir un panel no dibuja nada por sí solo: Claude Code luego disparaui.renderpara preguntar qué va dentro.ui.renderdevuelve el árbol de elementos, unBoxque contiene otras cajas, texto y botones, y lo vuelve a construir a partir detabycountcada vez que se ejecuta.
Presionar un botón ejecuta su callback onPress, que cambia una variable y llama a redraw. Claude Code luego vuelve a ejecutar el hook ui.render, y el hook construye un árbol nuevo a partir de los valores nuevos. Todo dibujo interactivo usa ese ciclo de renderizado: un callback cambia el estado y el hook vuelve a renderizar a partir del nuevo estado.
3. Abre el panel
En tu shell, inicia Claude Code con claude --plugin-dir ./hello-tabs. En el prompt de Claude Code, ejecuta /hello-tabs. Se abre un panel con 1: One y 2: Two en la parte superior. Presiona 2 y luego presiona a, la tecla de acceso rápido de Add one, algunas veces. El conteo sube.
4. Comprueba que el conteo se guardó
Presiona Esc para cerrar el panel y luego sal de la sesión. En tu shell, vuelve a iniciar Claude Code con el mismo comando claude --plugin-dir ./hello-tabs y, en el prompt de Claude Code, ejecuta /hello-tabs. El conteo está donde lo dejaste.
Para borrar el conteo, haz que el mod llame a $.store.delete('count'). Conservar el estado explica cuánto dura cada tipo de valor.
Elige dónde dibujar
Un hook ui.render se ejecuta para cada punto de renderizado, a menos que lo restrinjas al punto en el que quieres dibujar. Para elegir el punto de renderizado, pasa un filtro, llamado matcher, como segundo argumento de on. { component: 'Pane' } ejecuta el hook solo para paneles. En el hook, e.component indica el punto, e.surface indica qué aplicación está dibujando y e.props contiene los datos propios del punto. Para un panel, e.requestId es el id con el que lo abriste.
El panel y la franja están vacíos hasta que un mod los llena. Selecciona una pestaña para ver qué es cada uno y cómo dibujar en él:
[tab] Panel
Un panel es una barra lateral junto a la transcripción en una terminal de pantalla completa ancha o, en caso contrario, una región enmarcada encima del prompt. Con varios paneles abiertos, cada uno tiene una pestaña que muestra su título.
Un panel aparece cuando tu mod llama a
$.ui.opencon unidque eliges, como en$.ui.open({ id: 'hello-tabs' }). Abre un panel en el momento adecuado describe los demás campos y cuándo un panel espera a que la terminal sea más ancha.Para dibujar en tu panel, filtra por
{ component: 'Pane' }y comprueba quee.requestIdsea tuid.
[tab] Franja encima del prompt
La franja es una tira directamente encima de la entrada del prompt. Siempre está ahí y todos los mods la comparten.
Tu hook devuelve un árbol para mostrar algo en la franja, o
next(e)para no mostrar nada. Un árbol reemplaza lo que los mods posteriores al tuyo dibujan ahí. Para conservar lo suyo, coloca el resultado deawait next(e)entre los hijos de unBoxen tu árbol.Para dibujar en la franja, filtra por
{ component: 'AbovePrompt' }.
Cambia lo que Claude Code ya dibuja
Claude Code dibuja por sí mismo la mayor parte de su interfaz: mensajes, filas de llamadas a herramientas, el spinner y más. Cada una de esas partes también es un punto de renderizado, así que un mod puede cambiar su estilo o reemplazarla. Para cambiar una, filtra tu hook ui.render por su nombre de esta tabla:
| Punto | Qué es |
|---|---|
UserMessage, AssistantMessage |
Un mensaje en la transcripción |
ToolUse, ToolResult, ToolGroup |
La fila de una llamada a herramienta, su resultado y un grupo contraído de llamadas |
CommandOutput |
La fila que imprimió un comando |
AskUserQuestion |
El diálogo que Claude abre para hacerte una pregunta |
Spinner, ToolProgress, TurnDuration |
Líneas de estado de un turno: la línea que se anima mientras Claude trabaja, la línea de progreso en vivo de una herramienta en ejecución y la línea que cierra un turno |
InfoNotice, SessionMode, PromptHint |
Líneas de estado bajo el logotipo, las etiquetas de modo en el pie y la línea de sugerencia bajo el prompt |
Pane, AskUserQuestion, Spinner, UserMessage, AssistantMessage y los puntos de llamadas a herramientas funcionan en la terminal, la aplicación Desktop y la extensión de VS Code. La tabla de puntos de renderizado indica dónde se genera cada uno de los demás.
En un punto que Claude Code ya dibuja, tu hook puede cambiar un detalle, reemplazar el dibujo o dejarlo como está. Selecciona una pestaña para ver cada opción aplicada al spinner. Los ejemplos leen una variable calls que otro hook cuenta, como en el mod del tutorial.
[tab] Cambiar un detalle
Para conservar el dibujo de Claude Code y cambiar una parte, pasa a
nextuna copia del evento con laspropsmodificadas. Este hook cambia el texto que sigue a la palabra del spinner:on('ui.render', { component: 'Spinner' }, async ($, e, next) => { // Keep Claude Code's spinner, and change the text after its word return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } }) })El spinner conserva su animación y su palabra, y tu texto va después de la palabra:
Thinking · tool calls: 2…
[tab] Reemplazar el dibujo
Para dibujar algo propio en lugar del punto, devuelve un árbol y no llames a
next. Este hook dibuja una línea de texto donde estaría el spinner:on('ui.render', { component: 'Spinner' }, async ($, e) => { const { Text } = $.ui.resolve(e) // No call to next, so this line is drawn in the spinner's place return Text({ children: ['Claude has made ' + calls + ' tool calls'] }) })Mientras Claude trabaja, se muestra tu línea y no el spinner de Claude Code:
Claude has made 2 tool calls
[tab] Dejarlo como está
Para dejar el punto tal como lo dibuja Claude Code, devuelve
next(e). Un hook suele hacerlo para algunos eventos y no para otros. Este hook deja el spinner como está hasta que haya una llamada que contar:on('ui.render', { component: 'Spinner' }, async ($, e, next) => { // Nothing to show yet, so pass the event on unchanged if (calls === 0) return next(e) return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } }) })Antes de la primera llamada a herramienta, el spinner se ve igual que sin el mod:
Thinking…
En estos puntos, next(e) devuelve una referencia al dibujo de Claude Code, { type: 'engine', ref }, a menos que un mod que se ejecuta después del tuyo haya devuelto un árbol propio. Para cambiar lo que hay en ese dibujo, pasa a next una copia del evento con props diferentes, como hace la pestaña Cambiar un detalle. Puedes devolver la referencia tal cual o colocarla en un Box junto a elementos propios:
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
const { Box, Text } = $.ui.resolve(e)
const theirs = await next(e)
return Box({ flexDirection: 'column', children: [theirs, Text({ children: ['under the spinner'] })] })
})
Mientras Claude trabaja, el spinner se anima como antes y under the spinner aparece debajo.
La solicitud de permiso no es un punto de renderizado, así que un mod no puede cambiar lo que muestra. El diálogo de preguntas, AskUserQuestion, sí lo es, así que un mod puede cambiarlo. Un árbol para el diálogo debe contener la referencia exactamente una vez, con tus elementos encima. De lo contrario, Claude Code dibuja su propio diálogo.
Abre un panel en el momento adecuado
Un panel solo aparece cuando tu mod lo abre. Cómo y cuándo lo abres determina si toma el foco del teclado, cuánto espacio solicita y si llega a mostrarse en una terminal estrecha.
Para abrir un panel, llama a $.ui.open con un id que elijas. El id es el nombre del panel: tu hook ui.render lo comprueba y lo vuelves a pasar para cerrar el panel.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
Para cerrar el panel, llama a $.ui.close con el id con el que lo abriste:
await $.ui.close({ id: 'hello-tabs' })
Además de id, $.ui.open acepta estos campos opcionales:
| Campo | Qué hace |
|---|---|
title |
La etiqueta de la pestaña del panel cuando hay más de un panel abierto |
focus |
Solicita el foco del teclado |
closeOnEscape |
Hace que Esc cierre el panel |
holdToasts |
En la terminal, retiene los toasts mientras este panel es el que se muestra. Consulta Retén los toasts detrás de un diálogo. |
rows |
La altura que se solicita cuando el panel está encima del prompt. El valor predeterminado es un tercio del espacio. |
columns |
El ancho que se solicita cuando el panel está junto a la transcripción, como máximo el 45% del ancho de la terminal. Una vez que el usuario ha cambiado el tamaño de un panel junto a la transcripción, en esta sesión o en una anterior, los paneles se abren ahí con el ancho que estableció. El mismo límite del 45% se aplica a ese ancho. |
La extensión de VS Code ignora rows y columns y ajusta el tamaño del panel por sí misma.
focus, closeOnEscape y holdToasts son opcionales y solo aceptan true. Para no usar uno, omítelo. Pasar false genera un error como ui.open: focus is true or left out. Para establecer uno de ellos de forma condicional, agrega el campo solo cuando se cumpla la condición. Esta llamada solicita el foco del teclado solo cuando items no está vacío:
const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)
Para permitir que un comando abra el panel mientras Claude está trabajando, agrega immediate: true cuando registres el comando. Sin eso, un comando escrito durante un turno espera a que el turno termine.
Retén los toasts detrás de un diálogo
Pasa holdToasts: true a $.ui.open cuando el panel sea un diálogo que el usuario responde y luego deja, para que no aparezcan toasts mientras decide. En la terminal, la retención dura mientras ese panel es el que se muestra, y un toast generado durante ese tiempo espera hasta que termine la retención.
Claude Code retiene los toasts de otros mods y sus propias notificaciones de corta duración, además de los que tu mod genera con $.ui.toast. No uses el campo en un panel que permanece abierto, para que el usuario siga viéndolos.
Cuándo un panel espera a que la terminal sea más ancha
Un panel que tu mod abre sin que se lo pidan no aparece en una terminal estrecha, para que no pueda ocupar una pantalla pequeña. Que aparezca o no depende de qué lo abrió:
- Si lo abrió algo que hizo el usuario, como un comando que ejecutó o un botón que presionó, el panel aparece con cualquier ancho
- Si lo abrió tu mod por su cuenta, por ejemplo desde un temporizador o un hook
turn.start, el panel solo aparece en una terminal de al menos 144 columnas de ancho. Después de que el usuario haya abierto ese panel por sí mismo una vez, bastan 110 columnas.
Cuando el panel aparece, $.ui.open se resuelve como { isPlaced: true }. Cuando el panel está en espera, isPlaced es false y reason es una cadena que explica el motivo. Un panel en espera aparece cuando el usuario lo abre o amplía la terminal. Para avisar que algo está disponible sin abrir un panel, llama a $.ui.toast('Your message'), que muestra una notificación toast.
Dibuja en la extensión de VS Code
En la extensión de VS Code, lo que dibuja un mod aparece en el panel de chat con la versión 2.1.299 o posterior de la extensión. Una sesión que no obtiene feature flags, como una en Amazon Bedrock, necesita la versión 2.1.304 o posterior. En un hook ui.render para el panel, e.surface es vscode. Para comprobar qué puntos y elementos dibuja el panel, y qué props ignora, consulta las tablas de puntos de renderizado y de elementos.
Para que un mod funcione en el panel además de en la terminal, ten en cuenta estas diferencias:
- Paneles: en un panel de 800 píxeles de ancho o más, los paneles se acoplan junto a la conversación,
e.props.placementes'dock'ye.viewport.isFullscreenestrue. En un panel más estrecho, los paneles se dibujan encima del cuadro del prompt,placementes'inline'y un panel muestra 12 filas antes de desplazarse. - Toasts y líneas de estado: se ubican en una sola pila directamente encima del cuadro del prompt, junto con los paneles cuando el panel es estrecho, con una línea de estado por mod. La pila se oculta mientras se muestra una solicitud de permiso o un diálogo.
- Etiquetas: el panel enmarca lo que un mod dibuja o reescribe y lo etiqueta con el nombre del plugin, como en From the token-chart plugin o Edited by the token-chart plugin.
- Teclado: los botones no tienen teclas de acceso rápido, así que el usuario hace clic en un botón o presiona Enter sobre él una vez que tiene el foco.
- Foco del teclado: el panel concede la solicitud
focus: truede un panel solo cuando el panel tiene el foco, Claude está inactivo sin ninguna pregunta pendiente y el cuadro del prompt está vacío. - El diálogo de preguntas: en
AskUserQuestion, el panel conserva su propia tarjeta de pregunta y dibuja tus elementos encima de ella. Las props que pasas anextno cambian la tarjeta. - Sugerencias de prompt:
$.prompt.suggestse resuelve conisShown: falsey no muestra nada, porque el panel reserva el texto de sugerencia atenuado del cuadro del prompt para las sugerencias propias de Claude. - Límites de texto: el panel corta una línea de estado a los 200 caracteres o dos filas, y un toast a los 500 caracteres.
Construye un árbol a partir de elementos
Lo que devuelve un hook ui.render es un árbol de elementos: una descripción de lo que se debe dibujar, hecha de cajas, texto y controles anidados unos dentro de otros. Tú describes el dibujo, y Claude Code lo renderiza en la aplicación que el usuario tenga abierta: la terminal, la aplicación Desktop o la extensión de VS Code.
Para obtener los elementos, llama a $.ui.resolve(e) en tu hook, como en const { Box, Text, Button } = $.ui.resolve(e). Cada elemento es una función. Le pasas props, y pones los elementos y cadenas que van dentro de él en children.
Selecciona una pestaña para ver cada uno de los elementos más comunes y cómo los dibuja la terminal:
[tab] Text
Textdibuja una cadena, con estilos opcionales comoboldycolor:Text({ children: ['This is the first tab.'] })This is the first tab.
[tab] Box
Boxorganiza lo que contiene, en una fila o una columna. Este coloca un botón y una línea de texto uno al lado del otro, separados por dos columnas:Box({ flexDirection: 'row', columnGap: 2, children: [ Button({ key: 'more', label: 'Add one', onPress: addOne }), Text({ children: ['Count: 0'] }), ], })[ Add one ] Count: 0
[tab] Button
Buttones un control que el usuario puede presionar. Ejecuta tu callbackonPress. Conplain: trueno tiene corchetes y muestra su tecla de acceso rápido:Button({ key: 'more', label: 'Add one', onPress: addOne }) Button({ key: 'tab-one', label: 'One', hotkey: '1', plain: true, onPress: showTabOne })[ Add one ] 1: One
[tab] Input
Inputes un campo de texto. Ejecuta tu callbackonSubmitcon el texto cuando el usuario presiona Enter:Input({ key: 'new-note', label: 'Note', placeholder: 'Type a note and press Enter', value: '', submitLabel: 'add', onSubmit: addNote, })Note: Type a note and press EnterEstablece
rowsen 2 o más para tener un campo que admita varias líneas.
La galería de interfaces tiene ejemplos y capturas de pantalla de la mayoría de los elementos. Esta tabla enumera todos los elementos:
| Elemento | Qué dibuja | Dónde |
|---|---|---|
Box |
Un contenedor flex que acepta props de diseño como flexDirection, borderStyle y width. Las props de espaciado como columnGap y padding aceptan un número o un nombre de paso de 'xs' a 'xl'. |
En todas partes |
Text |
Texto con estilo. Acepta color, bold, dimColor, italic, wrap y otras como weight. Un color es una clave del tema o un color como 'red'. Un wrap es 'wrap', 'truncate', 'truncate-start', 'truncate-middle' o 'truncate-end'. |
En todas partes |
Button |
Un control que llama a onPress |
En todas partes |
Link, Code, Markdown |
Un enlace con href y una label opcional, un bloque de código y texto con el mismo formato que las respuestas de Claude. Markdown recibe su contenido en una prop text, no en children, y necesita una key cuando pasas onLinkPress. |
En todas partes |
Input, Select |
Un campo de texto y una lista desplegable | Terminal, Desktop, VS Code |
Svg |
Un documento SVG | Desktop, VS Code |
Client |
Una región dibujada por un segundo archivo tuyo, para animación y entrada del puntero. Ese archivo no recibe la API de mods. Llega a tus hooks publicando datos, que llegan como un evento ui.message. Si no logra cargarse, dibujarse o ejecutarse, tus hooks reciben un evento ui.fault. |
Terminal, Desktop |
Raster, Image |
Una cuadrícula de celdas de colores y una imagen | Terminal |
Si tu módulo es un archivo .tsx o .jsx, puedes escribir el árbol como JSX. Primero desestructura los elementos de $.ui.resolve(e).
Si tu dibujo no aparece, revisa si su árbol tiene una prop que un elemento no acepta o un hijo donde no va ninguno. Un hook que devuelve un árbol así falla, y una línea hook skipped indica el nombre de tu mod y la regla que el árbol incumplió. Para un Text en un panel con una prop llamada bogusProp, esa línea dice:
my-mod: ui.render hook skipped: returned the wrong shape (a tree Pane refuses: Text prop "bogusProp" is not allowed)
Link en la aplicación Desktop y VS Code
En la aplicación Desktop y la extensión de VS Code, un Link se dibuja como texto sin formato a menos que su href cumpla estos requisitos:
- Esquema y host: una URL
https:, o una URLhttp://localhostcomohttp://localhost:3000 - Sin
@: escribe un@en la ruta o la consulta como%40 - Escritura: lo que devuelve
new URL(href).href, salvo por una/faltante después del host. Eso excluye un host en mayúsculas, un espacio y:443en una URLhttps:.
En la terminal, estos requisitos no se aplican.
Cuando un Client falla
En la terminal, cuando falla el archivo que ejecuta un Client, una línea atenuada como my-mod: Client client/spinner.js: boom ocupa el lugar del Client, y el resto de tu dibujo se sigue mostrando.
Si tu mod maneja ui.fault, Claude Code entonces vuelve a dibujar el sitio.
Dibuja una cuadrícula de celdas de colores
Para un mapa de calor, un minigráfico o un tablero de juego en la terminal, dibuja un solo Raster y no un Box por cada celda. Un Raster recibe una key, su tamaño en columns y rows, y cells, una cadena base64 que empaqueta todas las celdas. Cada celda son tres números: el punto de código del carácter, su color y su color de fondo. Un color es un valor RGB de 24 bits en hexadecimal, como 0xc62828 para un rojo. El valor 0x01000000, uno por encima de ese rango, significa el predeterminado de la terminal.
Las aplicaciones distintas de la terminal no tienen Raster, así que comprueba e.surface y dibuja texto allí. Este cuerpo de panel dibuja un mapa de calor de tres por dos:
// The value that means "use the terminal's default color"
const DEFAULT_COLOR = 0x01000000
// Pack rows of [character, color] pairs into the one string a Raster takes
// One cell is three numbers: the character's code point, its color, and its background
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Draw only in the pane opened with the id 'heat'
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// Two rows of three cells, each a block character and its color
const rows = [
[['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
[['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
]
if (e.surface !== 'terminal') {
return Text({ children: ['The heat map needs the terminal.'] })
}
return Box({
flexDirection: 'column',
children: [Raster({ key: 'grid', columns: 3, rows: 2, cells: cellsOf(rows) })],
})
})
En la terminal, el panel muestra la cuadrícula:
El arreglo rows es la parte que cambiarías, y cellsOf lo convierte en la cadena empaquetada. El hook dibuja solo en un panel cuyo id es heat, así que abre uno con $.ui.open({ id: 'heat' }) desde un comando, como el ejemplo hello-tabs abre su panel.
Cada carácter tiene que ocupar una celda de ancho. Para animar un Raster que ya está en pantalla, llama a $.ui.blit con el id del panel como requestId, la key del Raster, el mismo tamaño y celdas nuevas. Para este ejemplo, eso es $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }). Vuelve a pintar ese único elemento sin ejecutar de nuevo tu hook ui.render.
Responder a pulsaciones y escritura
Cuando el usuario pulsa un botón, escribe en un campo o elige de una lista que tu mod dibujó, Claude Code llama al callback de ese control, que se ejecuta en tu módulo. Cada control acepta sus propios callbacks:
Button: aceptaonPress(e), dondee.surfacees la app de la que vino la pulsaciónInput: aceptaonSubmit(value)yonInput(value)Select: aceptaonSelect(value)con sus opciones enoptions, una lista de al menos una opción con valores únicos, como[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
Qué controles necesitan una key
Dale a cada Input y Select una prop key, como key: 'size'. Un Button puede prescindir de una key cuando tiene una prop label o un único hijo de tipo cadena. Su key es entonces el valor de label o esa cadena.
Las pruebas y otros mods identifican un control por su key:
- Pruebas: una prueba pulsa o escribe en un control por su
key. - Otros mods: Claude Code dispara
ui.press,ui.inputoui.selectcon lakeydel control ene.elementantes de ejecutar el callback del control. El hook de otro mod para ese evento ve lo que el usuario escribió en tuInputy puede cambiar ese texto o impedir que tu callback se ejecute. La API de mods no le da a ese mod ningún método para pulsar tus botones.
Si un mensaje dice needs a key o is drawn twice, encuentra la causa a partir de ese mensaje.
Foco del teclado y teclas de acceso rápido
Tu mod nunca lee el teclado por sí mismo. El usuario pulsa una tecla, Claude Code decide a cuál de tus controles va dirigida y se ejecuta el callback de ese control. Aparte de una tecla de acceso rápido numérica en la banda o un atajo de teclado de action, eso solo ocurre mientras tu panel o banda tiene el foco del teclado. El resto del tiempo, las teclas van al prompt.
En la extensión de VS Code, los botones no tienen teclas de acceso rápido: el usuario hace clic en un botón o pulsa Enter sobre él, y escribe en los campos.
Cómo obtiene un panel el foco del teclado
Un panel obtiene el foco del teclado cuando:
- Tu mod lo abre con
focus: truedesde un comando o una pulsación - El usuario pulsa Ctrl+X y luego Tab
- El usuario hace clic en él, lo que en la terminal requiere el renderizado en pantalla completa
Claude Code concede focus: true solo mientras el prompt está vacío y nada más tiene el foco del teclado. Un panel que se abre mientras el usuario está escribiendo no se queda con sus pulsaciones de teclas.
Qué hace cada tecla
Esta tabla indica qué hace una tecla mientras tu panel o banda tiene el foco del teclado:
| Tecla | Qué hace |
|---|---|
| Tab | Pasa al siguiente control |
| Arriba y Abajo | Se mueven entre controles mientras tu dibujo cabe. Cuando el panel o la banda tiene más filas de las que puede mostrar, lo desplazan. En un Input en la terminal, se mueven entre las filas del campo, y desde su primera o última fila pasan al control anterior o siguiente. |
| Enter | Pulsa el Button con el foco, envía el Input con el foco o elige en un Select |
| La tecla de acceso rápido de un botón | Pulsa ese botón. Mientras un Input tiene el foco, toda tecla imprimible va al campo. |
| Re Pág, Av Pág, Inicio y Fin | Desplazan tu panel o banda cuando tiene más filas de las que puede mostrar |
| Ctrl+X y luego una flecha | Cambia el tamaño de tu panel. Izquierda o Arriba le da más espacio, y Derecha o Abajo lo devuelve. Un panel junto a la transcripción deja de ensancharse al 45 % del ancho de la terminal. |
| Ctrl+X y luego X | Cierra tu panel, incluso mientras uno de sus campos tiene el foco |
| Ctrl+X y luego Ctrl+A | Contrae la banda a una fila mientras un mod dibuja en ella, o la vuelve a expandir. Estas teclas también funcionan mientras el prompt tiene el foco. |
| Esc | Devuelve el foco del teclado al prompt. Con closeOnEscape: true, también cierra el panel. |
Un mod no puede asignar Tab ni las flechas a ninguna otra cosa, así que un juego se controla con w, a, s y d.
Establecer una tecla de acceso rápido y el primer foco
hotkey: para que el usuario pueda pulsar unButtoncon una sola tecla, dale unahotkeyde un dígito o una letra minúscula, como enhotkey: 'a'autoFocus: para elegir qué control tiene el foco cuando se abre el panel, añádeleautoFocus: true. En la extensión de VS Code,autoFocusno hace nada: el control se dibuja como si lo hubieras omitido, y no necesitas cambiar nada. La prop solo aceptatrue, así que omítela en los demás controles.
Cómo se muestra una tecla de acceso rápido depende del botón y de la app:
| Botón | En la terminal | En la app de escritorio | En la extensión de VS Code |
|---|---|---|---|
| Con corchetes, el predeterminado | [ Add one ], sin mostrar la tecla de acceso rápido |
La etiqueta con una pequeña tecla al lado | La etiqueta, y la tecla de acceso rápido no hace nada |
Con plain: true |
1: One |
La etiqueta con una pequeña tecla al lado | La etiqueta, y la tecla de acceso rápido no hace nada |
En la terminal, nombra la tecla en la etiqueta de un botón con corchetes, o usa plain: true, para que el usuario vea qué pulsar. La referencia de elementos tiene las demás reglas de Button: action, las teclas de acceso rápido numéricas en la banda y dos botones con la misma tecla de acceso rápido.
Cuando el usuario contrae la banda
En la terminal, mientras un mod dibuja en la banda, el usuario puede contraer la banda a una fila para quitarla de en medio:
- Hacer clic en
[-]: mientras la banda está expandida, Claude Code dibuja esta marca en el extremo derecho de su primera fila. En el renderizado en pantalla completa, el usuario hace clic en ella para contraer la banda. - Pulsar Ctrl+X y luego Ctrl+A: esto funciona tanto si el prompt, la banda o un panel tiene el foco del teclado. El usuario puede asignarle otras teclas mediante la acción
abovePrompt:toggle.
Una banda contraída oculta todo lo que los mods dibujan en ella y muestra en su lugar una fila atenuada, que nombra las teclas que la expanden según las tenga asignadas el usuario:
▸ plugin panel hidden · ctrl+x ctrl+a or click to show
Las mismas teclas, o en el renderizado en pantalla completa un clic en la fila, vuelven a expandir la banda. Hasta entonces, nada pulsa un botón en ella: ni una tecla de acceso rápido, ni un dígito escrito en un prompt vacío, ni un atajo de teclado para la action de un botón. Si la banda tiene el foco del teclado cuando el usuario la contrae, el foco vuelve al prompt, y Ctrl+X y luego Tab omite la banda mientras está contraída.
La banda permanece contraída hasta que el usuario la expande, incluso después de /clear, y vuelve a estar expandida la próxima vez que se inicia Claude Code. Tu mod no puede expandirla ni saber que está contraída: el hook ui.render sigue ejecutándose, y nada en e.props lo indica. No hagas de la banda el único lugar donde tu mod muestra algo que el usuario tiene que responder.
Recibir texto escrito y dibujar una fila por elemento
Muchos paneles son un campo de texto con una lista debajo. El ejemplo de esta sección es un panel de notas: escribes una nota y pulsas Enter para añadirla, y cada nota tiene un botón x que la elimina. Con dos notas añadidas, la terminal dibuja el panel así:
╭────────────────────────────────────────────────────────✕─╮
│ Note: Type a note and press Enter ⏎ add │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
La ✕ del borde superior es la marca propia de Claude Code para cerrar el panel.
El ejemplo usa estas técnicas:
- Recibir texto escrito: un
Inputllama aonSubmit(value)con el texto del campo cuando el usuario pulsa Enter, y aonInput(value)en cada cambio - Dibujar una lista: asigna a cada dato una fila y dale al botón de cada fila su propia
key
Este hook dibuja el contenido del panel:
// The list the pane draws
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Draw only in the pane opened with the id 'notes'
if (e.requestId !== 'notes') return next(e)
const { Box, Text, Button, Input } = $.ui.resolve(e)
const redraw = () => $.ui.invalidate('ui.render')
return Box({
flexDirection: 'column',
children: [
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
// The text the field starts with
value: '',
submitLabel: 'add',
autoFocus: true,
// Runs when you press Enter in the field
onSubmit: async (value) => {
// Ignore an empty line
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// One row for each note: a delete button, then the note's text
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// A key of its own, so each row's button can be told apart
key: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})
Para probar el panel:
- Añadir una nota: escribe una línea y pulsa Enter. La línea aparece como una fila nueva y el campo se vacía.
- Eliminar una nota: pulsa Tab hasta que el botón
xde la nota tenga el foco y luego pulsa Enter. Laxes la etiqueta del botón y no una tecla de acceso rápido, así que escribir la letra no lo pulsa.
Cada cambio sigue el mismo ciclo de renderizado que hello-tabs: el callback cambia notes, llama a redraw y guarda la lista en $.store. Si tu hook vuelve a dibujar el panel mientras el usuario está escribiendo una nota, el campo conserva su texto siempre que el hook pase el mismo value cada vez. Para reemplazar su texto por el tuyo, pasa un value distinto.
El ejemplo guarda las notas pero no las carga. Para recuperarlas en la siguiente sesión, léelas en un hook session.start, de la misma forma en que hello-tabs lee count.
Estas props forman la línea del campo, Note: Type a note and press Enter ⏎ add:
| Prop | En el ejemplo | Qué es |
|---|---|---|
label |
Note |
El texto antes del campo. La terminal dibuja : después de él. |
placeholder |
Type a note and press Enter |
Texto atenuado que se muestra mientras el campo está vacío |
value |
'' |
El texto con el que empieza el campo. Aquí está vacío, así que se muestra el texto de marcador de posición. |
submitLabel |
add |
La palabra después de ⏎ que indica qué hace Enter |
Enviar un Input no inicia un turno a menos que tu callback llame a $.prompt.submit.
Volver a dibujar un sitio
Un dibujo es una instantánea: muestra lo que devolvió tu hook ui.render la última vez que se ejecutó. Para mostrar algo nuevo, el hook tiene que ejecutarse de nuevo. Claude Code lo vuelve a ejecutar ante algunos cambios, y tu mod lo solicita para el resto.
Cuándo Claude Code vuelve a dibujar sin que se lo pidas
Claude Code vuelve a ejecutar tu hook ui.render cuando cambian las props del sitio o cuando cambia el ancho de la terminal. Cuando un Client del sitio falla y tu mod maneja ui.fault, Claude Code ejecuta el hook una vez más después de que tus hooks ui.fault retornan, para que tu hook ui.render pueda omitir el Client. No ejecuta el hook con un temporizador, y no puede saber cuándo cambia una variable de tu módulo.
Volver a dibujar cuando cambian tus datos
Para que tus sitios se vuelvan a dibujar después de que cambien tus propios datos, llama a $.ui.invalidate('ui.render'). Este panel cuenta pulsaciones. El callback del botón cambia count y luego solicita que se vuelva a dibujar:
let count = 0
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== 'counter') return next(e)
const { Box, Text, Button } = $.ui.resolve(e)
return Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
onPress: () => {
count += 1
// The data changed, so ask Claude Code to draw the pane again
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})
Cada pulsación aumenta el número en el panel. El ejemplo hello-tabs envuelve la misma llamada en su función redraw.
Un valor que guardas en $.state no necesita la llamada, porque al escribir el valor se vuelven a dibujar los sitios que lo leen.
Volver a dibujar con un temporizador
Para mantener actualizado un reloj, una cuenta regresiva o un valor externo a la sesión, vuelve a dibujar de forma programada. Inicia un temporizador en el hook session.start del módulo. Si el módulo ya tiene uno, como ocurre con hello-tabs, agrégale la línea de $.clock.every:
on('session.start', async ($, e, next) => {
// Every 1000 milliseconds, ask Claude Code to draw your sites again
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
Claude Code ahora ejecuta tu hook ui.render una vez por segundo. El temporizador se detiene después de que el módulo se recarga, y la nueva instancia del módulo inicia el suyo propio.
Con qué frecuencia puede volver a dibujarse un sitio
Claude Code limita la frecuencia con la que se vuelve a dibujar un sitio, así que tu mod puede llamar a $.ui.invalidate tan seguido como cambien sus datos. Para saber con qué frecuencia puede volver a dibujarse cada sitio, consulta la tabla de límites.
Las llamadas que llegan más rápido que el límite se agrupan en un solo redibujado. Ese redibujado ejecuta tu hook una vez, y el hook lee tus datos tal como están en ese momento, así que se muestra el valor más reciente y no los intermedios. Una animación no puede ejecutarse más rápido que el límite.
Conservar el estado
El lugar donde un mod guarda un valor decide cuánto dura ese valor: hasta que el módulo se recarga, hasta que termina la sesión o de una sesión a la siguiente. Elige según cuánto tiempo tiene que durar el valor:
| Guárdalo en | Dura hasta que | Úsalo para |
|---|---|---|
| Una variable a nivel de módulo | El módulo se recarga, lo que ocurre cada vez que guardas un archivo durante el desarrollo | Valores que puedes perder, como tab en hello-tabs |
$.state |
La sesión termina, o el usuario ejecuta /clear, /resume o /branch |
Valores de los que depende un dibujo y que deben sobrevivir a una recarga |
$.store |
Tu mod lo elimina, o ninguna sesión lee ni escribe el almacén durante cleanupPeriodDays. El almacén es un almacén de clave-valor, guardado como un archivo JSON propio de tu plugin en ~/.claude/plugins/store/. |
Ajustes, historial, cualquier cosa que el usuario espera encontrar la próxima vez |
$.store.get(key) se resuelve con el valor o undefined, y $.store.set(key, value) acepta cualquier valor JSON.
Guardar un valor en $.state
$.state conserva valores durante una sesión y vuelve a dibujar por ti. Es estado reactivo: un hook ui.render que lee un valor se suscribe a él, así que Claude Code vuelve a dibujar ese sitio cada vez que escribes el valor, y no llamas a $.ui.invalidate. Un valor en $.state también sobrevive a una recarga del módulo, cosa que una variable no hace.
Para configurarlo, declara tus valores, apunta tu manifiesto a la declaración y luego define y usa cada valor. Los ejemplos mueven el count de hello-tabs a $.state.
Declarar los valores
Declara los valores en un archivo de declaración de tipos. La clave exterior es el nombre de tu plugin, y cada entrada debajo de ella es un valor y su tipo. Guarda esto como hello-tabs/types/index.d.ts:
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
Apuntar el manifiesto a la declaración
Para que claude plugin validate compruebe tu código con ese archivo, agrega al manifiesto un campo types con su ruta:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" },
"types": "./types/index.d.ts"
}
Definir, leer y escribir un valor
En tu módulo, define cada valor con un valor predeterminado, léelo mientras dibujas y escríbelo desde un callback. atom nombra un valor y su valor predeterminado, read lo devuelve y update lo escribe. Los tres helpers llaman a $.state.get y $.state.set por ti:
import { atom, read, update } from 'claude-code'
// At the top of the module: name the value and give its default
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// In the ui.render hook: read the value to draw it
const n = await read($, count)
// In a Button: write a new value from the old one
onPress: () => update($, count, (value) => value + 1)
Como el hook ui.render leyó count, Claude Code vuelve a ejecutar el hook cada vez que el botón lo escribe.
Estas reglas se aplican al código:
- Escribe
pluginykeycomo literales de cadena:claude plugin validatelos lee de tu código fuente - Guarda el resultado de cada llamada a
atomen unaconst: si declarascountconlet, la validación falla contakes a source the scan can read - Declara cada valor en el archivo de declaración de tipos: de lo contrario, la validación falla con
hello-tabs.count is not declared - Escribe desde un callback o desde el hook de otro evento: un hook
ui.renderpuede leer el estado pero no puede escribirlo, así que escribe desdeonPress,onSubmito un hook para otro evento
Cambiar hello-tabs para usar $.state
Para mover count de hello-tabs a $.state, cambia cada línea que lo usa:
- Al principio del módulo: agrega la línea
importy reemplazalet count = 0por la líneaatom - En el hook
ui.render: agrega la líneareadantes detabButtony dibuja'Count: ' + nen elText - En el botón Add one: reemplaza
onPresspor el de Guardar desde más de una sesión, que guarda el conteo además de escribirlo - En el hook
session.start: reemplaza las dos líneas que leensavedpor la llamada aloadCountde Volver a cargar un valor guardado después de/clear
Conserva redraw para los botones de pestaña, porque tab sigue siendo una variable.
Volver a cargar un valor guardado después de /clear
Si tu mod copia un valor guardado de $.store a $.state en session.start, tiene que volver a copiarlo después de /clear, /resume o /branch. Esos comandos restablecen cada valor de $.state a su valor predeterminado, y session.start no se vuelve a disparar. classic.SessionStart sí se dispara después de cada uno de ellos, con e.source establecido en clear, resume o fork, así que vuelve a copiar el valor en un hook sobre él. De lo contrario, tu dibujo muestra el valor predeterminado, y un callback que guarda el valor de $.state escribe el valor predeterminado encima de lo que almacenaste.
Este código carga count desde ambos hooks. Se basa en la versión de hello-tabs con $.state, donde count es un atom y update está importado. Coloca loadCount encima de register y agrega la llamada a loadCount al hook session.start que ya tienes. classic.SessionStart también se dispara al inicio y después de la compactación, que no restablece $.state, así que el filtro sobre source limita el hook a los tres restablecimientos:
// Copy the saved count from $.store into $.state, or 0 if nothing is saved
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// Runs again after /clear, /resume, and /branch, which reports fork
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
await loadCount($)
return next(e)
})
Con ambos hooks en su lugar, el panel muestra el conteo guardado después de /clear y no 0, y la siguiente pulsación de Add one suma al conteo guardado.
loadCount escribe el valor almacenado encima del que está en $.state, y session.start se vuelve a disparar cada vez que el módulo se recarga. Para evitar que el almacén se quede atrás, guarda en cada cambio, como hace el botón Add one.
Para comprobar la recarga sin una sesión, prueba el dibujo después de /clear.
Guardar desde más de una sesión
Todas las sesiones de tu máquina que ejecutan tu mod comparten un mismo $.store. Un get seguido de un set no es atómico. Cuando dos sesiones leen cada una un valor, lo cambian y lo vuelven a escribir, compiten entre sí, y la segunda escritura reemplaza a la primera.
Para que eso sea menos probable:
- Dale a cada elemento su propia clave: un
setcambia solo su propia clave, así que las sesiones que escriben claves distintas no se sobrescriben entre sí - Vuelve a leer justo antes de escribir: para un valor que cambian varias sesiones, haz
getde la clave en el callback y construye el nuevo valor a partir de eso, no de una copia que cargaste ensession.start. La escritura de otra sesión igual se pierde si ocurre entre tugety tuset.
Este botón suma uno a lo que el almacén contenga en ese momento y luego actualiza el dibujo:
onPress: async () => {
// Read what the store holds now, which another session may have changed
const saved = Number((await $.store.get('count')) ?? 0)
// Save the new count, then show it
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
Si una segunda sesión ha pulsado su propio botón tres veces desde que comenzó esta sesión, esta pulsación muestra y guarda un conteo que incluye esas tres.
Próximos pasos
- Reacciona a eventos: alimenta tu dibujo a partir de llamadas a herramientas y turnos
- Usa la API de mods: alimenta tu dibujo a partir de temporizadores y llamadas al modelo
- Prueba un dibujo: presiona tus botones desde una prueba, en más de una superficie
- Puntos de renderizado y elementos: las props de cada punto de renderizado y las props de cada elemento