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.
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.
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"]
}
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.
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.
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:
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.open con un id que 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 que e.requestId sea tu id.
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 de await next(e) entre los hijos de un Box en 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 |
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.
Para conservar el dibujo de Claude Code y cambiar una parte, pasa a next una copia del evento con las props modificadas. 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…
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
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.
La terminal y la aplicación Desktop no generan los mismos puntos. Pane, AbovePrompt, Spinner y los puntos de la transcripción funcionan en ambas. Algunas otras líneas de estado solo se generan en la terminal. La tabla de puntos de renderizado indica dónde se genera cada uno.
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 |
Retiene los toasts, los pequeños avisos de $.ui.toast, hasta que el panel se cierre |
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 |
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.
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.
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 terminal o en la aplicación Desktop.
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:
Text dibuja una cadena, con estilos opcionales como bold y color:
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box organiza 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
Button es un control que el usuario puede presionar. Ejecuta tu callback onPress. Con plain: true no 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
Input es un campo de texto. Ejecuta tu callback onSubmit con 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 Enter
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. Acepta props de diseño como flexDirection, columnGap, padding, borderStyle y width. |
En todas partes |
Text |
Texto con estilo. Acepta color, bold, dimColor, italic y wrap. 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 |
Svg |
Un documento SVG | Desktop |
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. Solo llega a tus hooks publicando datos, que llegan como un evento ui.message. |
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 un árbol usa un elemento que la aplicación no tiene, una prop que un elemento no acepta o un hijo donde no va ninguno, Claude Code dibuja su propia versión del sitio.
En una sesión iniciada con --plugin-dir, una línea de la transcripción lo indica, por ejemplo ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. El registro de depuración lo registra como ui.render (Pane): a hook returned a tree that does not validate con el mismo motivo. No aparece nada más en la sesión, así que cuando un dibujo no se muestre, revisa esa línea o el registro.
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.
La aplicación Desktop no tiene 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' }]
Una prueba pulsa o escribe en un control por su key, así que dale una a cada control. Cada uso de un control también dispara ui.press, ui.input o ui.select con la key en e.element, y otro mod puede manejar esos eventos. Su hook se ejecuta antes que tu callback, así que ve lo que el usuario escribe en tu Input y puede cambiarlo o responder en lugar de tu callback. La API de mods no tiene ningún método que pulse el botón de otro mod.
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, eso solo ocurre mientras tu panel o banda tiene el foco del teclado. El resto del tiempo, las teclas van al prompt.
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
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. |
| 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. |
| Ctrl+X y luego X | Cierra tu panel, incluso mientras uno de sus campos 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
Estas props de un control deciden cómo llega el teclado a él:
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. 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 |
|---|---|---|
| Con corchetes, el predeterminado | [ Add one ], sin mostrar la tecla de acceso rápido |
La etiqueta con una pequeña tecla al lado |
Con plain: true |
1: One |
La etiqueta con una pequeña tecla al lado |
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.
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 │
╰──────────────────────────────────────────────────────────╯
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',
// Draw the field empty each time, which clears it after a submit
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.
El campo se vacía después de cada envío debido a su prop value. value es el texto que contiene el campo cuando se dibuja, y lo que escribe el usuario lo reemplaza hasta que tu hook vuelve a dibujar el campo. El ejemplo siempre dibuja el campo con ''.
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 |
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. 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)
})
Ahora Claude Code ejecuta tu hook ui.render una vez por segundo. El temporizador se detiene cuando el módulo se recarga, y la nueva instancia del módulo inicia el suyo.
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 - 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