Dibujar en la interfaz con un mod
Dibuja paneles, una banda sobre el prompt, botones y campos de texto desde un mod de Claude Code, maneja pulsaciones y entradas, y mantén el estado entre redibujos 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 sitio de renderizado, como un panel, la banda sobre el prompt o el spinner. Claude Code genera el evento ui.render cada vez que está a punto de dibujar un sitio de renderizado, y tu hook para ese evento devuelve lo que se debe dibujar allí.
Este mapa muestra dónde un mod puede dibujar en una sesión de terminal:
En una terminal más estrecha, el panel se sitúa sobre el prompt en lugar de junto a la transcripción.
Crea tu primer mod antes de comenzar aquí. Comienza con el ejemplo trabajado, que crea un panel con dos pestañas y un contador, y luego lee la sección de cada parte que quieras cambiar.
Para buscar una propiedad o un límite, consulta la referencia.
Crear 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, en caso contrario, una región enmarcada sobre el prompt. Este panel muestra dos pestañas, y la segunda pestaña tiene un botón que suma uno a un contador. El recuento 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 varias veces y vuelve a la primera pestaña:
Las pestañas son dos botones en una fila. El mod realiza un seguimiento de cuál está activo y dibuja el contenido de esa pestaña bajo la fila.
Crear 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 directorios .claude-plugin y hooks dentro, 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" }
}
Nombra tu punto de entrada en hello-tabs/hooks/hooks.json:
{
"modules": ["./register.js"]
}
Escribir el código
Esta lista indica lo que hace cada hook, en el orden en que aparecen en el código:
- Agrega el comando
/hello-tabsy carga el recuento 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, mantienen 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 recuento guardado de$.store, un almacén de clave-valor que persiste entre sesiones.command.runsolo le dice a Claude Code que el panel existe. Abrir un panel no dibuja nada por sí solo: Claude Code luego generaui.renderpara preguntar qué va en él.ui.renderdevuelve el árbol de elementos, unBoxque contiene otros cuadros, texto y botones, y lo vuelve a generar desdetabycountcada vez que se ejecuta.
Presionar un botón ejecuta su callback onPress, que cambia una variable y llama a redraw. Claude Code luego ejecuta el hook ui.render nuevamente, y el hook genera un nuevo árbol a partir de los nuevos valores. Cada dibujo interactivo utiliza ese ciclo de renderizado: un callback cambia el estado y el hook se renderiza nuevamente desde el nuevo estado.
Abrir 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, luego presiona a, el atajo de teclado para Add one, varias veces. El recuento sube.
Verificar que el recuento fue guardado
Presiona Esc para cerrar el panel, luego sal de la sesión. En tu shell, inicia Claude Code nuevamente con el mismo comando claude --plugin-dir ./hello-tabs y en el prompt de Claude Code ejecuta /hello-tabs. El recuento está donde lo dejaste.
Para borrar el recuento, haz que el mod llame a $.store.delete('count'). Mantener estado cubre cuánto tiempo dura cada tipo de valor.
Elegir dónde dibujar
Un hook ui.render se ejecuta para cada sitio de renderizado a menos que lo reduzcas al que quieres dibujar. Para elegir el sitio de renderizado, pasa un filtro, llamado matcher, como segundo argumento a on. { component: 'Pane' } ejecuta el hook solo para paneles. En el hook, e.component nombra el sitio, e.surface dice qué aplicación está dibujando, y e.props contiene los datos propios del sitio. Para un panel, e.requestId es el id con el que lo abriste.
El panel y la banda 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 una región enmarcada sobre el prompt en otros casos. Con varios paneles abiertos, cada uno obtiene 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' }). Abrir un panel en el momento adecuado cubre los otros campos y cuándo un panel espera una terminal más ancha.
Para dibujar en tu panel, filtra por { component: 'Pane' } y verifica que e.requestId sea tu id.
La banda es una franja directamente sobre la entrada del prompt. Siempre está ahí, y todos los mods la comparten.
Tu hook devuelve un árbol para mostrar algo en la banda, o next(e) para no mostrar nada. Un árbol reemplaza lo que los mods después del tuyo dibujan allí. Para mantener lo suyo, pon el resultado de await next(e) entre los hijos de un Box en tu árbol.
Para dibujar en la banda, filtra por { component: 'AbovePrompt' }.
Cambiar 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 es también un sitio de renderizado, por lo que un mod puede cambiar su estilo o reemplazarla. Para cambiar una, filtra tu hook ui.render por su nombre de esta tabla:
| Sitio | 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 plegado de llamadas |
CommandOutput |
La fila que un comando imprimió |
AskUserQuestion |
El diálogo que Claude abre para hacerte una pregunta |
Spinner, ToolProgress, TurnDuration |
Líneas de estado para 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 logo, las etiquetas de modo en el pie de página y la línea de sugerencia bajo el prompt |
En un sitio 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 de tutorial.
Para mantener el dibujo de Claude Code y cambiar una parte de él, pasa a next una copia del evento con props cambiados. Este hook cambia el texto después de 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 mantiene su animación y su palabra, y tu texto sigue a la palabra:
Thinking · tool calls: 2…
Para dibujar algo propio en el lugar del sitio, 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, tu línea se muestra y el spinner de Claude Code no:
Claude has made 2 tool calls
Para dejar el sitio como Claude Code lo dibuja, devuelve next(e). Un hook a menudo hace eso 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 sitios, 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 de él.
La solicitud de permiso no es un sitio de renderizado, por lo que un mod no puede cambiar lo que muestra. El diálogo de pregunta, AskUserQuestion, sí lo es, por lo que un mod puede cambiarlo. Un árbol para el diálogo debe contener la referencia exactamente una vez, con tus elementos encima de ella. De lo contrario, Claude Code dibuja su propio diálogo.
La terminal y la aplicación de escritorio no generan todos los mismos sitios. Pane, AbovePrompt, Spinner y los sitios de transcripción funcionan en ambos. Algunas otras líneas de estado se generan solo en la terminal. La tabla de sitios de renderizado enumera dónde se genera cada uno.
Abrir un panel en el momento adecuado
Un panel aparece solo cuando tu mod lo abre. Cómo y cuándo lo abres decide si toma el foco del teclado, cuánto espacio solicita y si aparece siquiera 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 pasas de nuevo 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 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 se cierre el panel |
rows |
La altura que se solicita cuando el panel se sitúa sobre el prompt. El valor predeterminado es un tercio del espacio. |
columns |
El ancho que se solicita cuando el panel se sitúa junto a la transcripción |
focus, closeOnEscape y holdToasts son opcionales y solo aceptan true. Para no usar uno, omítelo. Pasar false lanza 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 él, un comando escrito durante un turno espera a que el turno termine.
Cuando un panel espera una terminal más ancha
Un panel que tu mod abre sin que se le pida no aparece en una terminal estrecha, por lo que no puede apoderarse de una pantalla pequeña. Que aparezca o no depende de qué lo abrió:
- Abierto por algo que hizo el usuario, como un comando que ejecutó o un botón que presionó, el panel aparece con cualquier ancho
- Abierto por tu mod actuando por sí solo, como desde un temporizador o un hook
turn.start, el panel aparece solo en una terminal de al menos 144 columnas de ancho. Después de que el usuario haya abierto ese panel una vez por sí mismo, 110 columnas son suficientes.
Cuando aparece el panel, $.ui.open se resuelve en { isPlaced: true }. Cuando el panel está esperando, isPlaced es false y reason es una cadena que dice por qué. Un panel en espera aparece cuando el usuario lo abre o amplía la terminal. Para indicar que algo está disponible sin abrir un panel, llama a $.ui.toast('Your message'), que muestra una notificación toast.
Construir un árbol a partir de elementos
Lo que devuelve un hook ui.render es un árbol de elementos: una descripción de qué dibujar, hecha de cuadros, texto y controles anidados entre sí. Describes el dibujo y Claude Code lo renderiza en la terminal o en la aplicación de escritorio.
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 propiedades y pones los elementos y cadenas que van dentro en children.
Selecciona una pestaña para ver cada uno de los elementos más comunes y cómo la terminal lo dibuja:
Text dibuja una cadena, con estilo opcional como bold y color:
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box organiza lo que hay dentro, en una fila o una columna. Este pone un botón y una línea de texto uno al lado del otro, dos columnas separadas:
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 atajo de teclado:
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 cada elemento:
| Elemento | Qué dibuja | Dónde |
|---|---|---|
Box |
Un contenedor flex. Toma propiedades de diseño como flexDirection, columnGap, padding, borderStyle y width. |
En todas partes |
Text |
Texto con estilo. Toma color, bold, dimColor, italic y wrap. Un color es una clave de 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 formateado de la manera que lo son las respuestas de Claude. Markdown toma su contenido en una propiedad text, no en children, y necesita una key cuando pasas onLinkPress. |
En todas partes |
Input, Select |
Un campo de texto y un selector | Terminal, Escritorio |
Svg |
Un documento SVG | Escritorio |
Client |
Una región dibujada por un segundo archivo tuyo, para animación y entrada de puntero. Ese archivo no obtiene API de mods. Solo llega a tus hooks publicando datos, que llegan como un evento ui.message. |
Terminal, Escritorio |
Raster, Image |
Una cuadrícula de celdas coloreadas y una imagen | Terminal |
Si tu módulo es un archivo .tsx o .jsx, puedes escribir el árbol como JSX. Desestructura primero los elementos de $.ui.resolve(e).
Si un árbol utiliza un elemento que la aplicación no tiene, una propiedad que un elemento no toma 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 transcripción lo indica, como 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 la misma razón. Nada más aparece en la sesión, por lo que cuando un dibujo no aparece, revisa esa línea o el registro.
Dibujar una cuadrícula de celdas coloreadas
Para un mapa de calor, un gráfico de chispa o un tablero de juego en la terminal, dibuja un Raster y no un Box para cada celda. Un Raster toma una key, su tamaño en columns y rows, y cells, una cadena base64 que empaqueta cada celda. 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 de escritorio no tiene Raster, así que revisa 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:
La matriz rows es la parte que cambiarías, y cellsOf la 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 tener un ancho de una celda. 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) }). Repinta ese elemento sin ejecutar tu hook ui.render nuevamente.
Responder a pulsaciones y escritura
Cuando el usuario presiona 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 toma sus propios callbacks:
Button: tomaonPress(e), dondee.surfacees la aplicación de la que proviene la pulsaciónInput: tomaonSubmit(value)yonInput(value)Select: tomaonSelect(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 presiona o escribe en un control por su key, así que dale una a cada control. Cada uso de un control también genera 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 de tu callback, por lo 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 presione el botón de otro mod.
Enfoque del teclado y atajos de teclado
Tu mod nunca lee el teclado por sí mismo. El usuario presiona una tecla, Claude Code decide a cuál de tus controles va dirigida y se ejecuta el callback de ese control. Aparte de un atajo de teclado de dígito en la banda, eso sucede solo mientras tu panel o banda tiene el enfoque del teclado. El resto del tiempo, las teclas van al prompt.
Cómo un panel obtiene enfoque del teclado
Un panel obtiene el enfoque del teclado cuando:
- Tu mod lo abre con
focus: truedesde un comando o una pulsación - El usuario presiona Ctrl+X y luego Tab
- El usuario hace clic en él
Claude Code otorga focus: true solo mientras el prompt está vacío y nada más tiene el enfoque del teclado. Un panel que se abre mientras el usuario está escribiendo no toma sus pulsaciones de teclas.
Qué hace cada tecla
Esta tabla enumera qué hace una tecla mientras tu panel o banda tiene el enfoque del teclado:
| Tecla | Qué hace |
|---|---|
| Tab | Se mueve al siguiente control |
| Arriba y Abajo | Se mueven entre controles mientras tu dibujo cabe. Cuando el panel o banda tiene más filas de las que puede mostrar, lo desplazan. |
| Enter | Presiona el Button enfocado, envía el Input enfocado o elige en un Select |
| Atajo de teclado de un botón | Presiona ese botón. Mientras un Input tiene el enfoque, cada tecla imprimible va al campo. |
| Esc | Devuelve el enfoque del teclado al prompt. Con closeOnEscape: true, también cierra el panel. |
Un mod no puede vincular Tab ni las teclas de flecha a nada más, por lo que un juego se dirige con w, a, s y d.
Establecer un atajo de teclado y el primer enfoque
Estas propiedades de un control deciden cómo lo alcanza el teclado:
hotkey: para permitir que el usuario presione unButtoncon una tecla, dale unhotkeyde un dígito o una letra minúscula, como enhotkey: 'a'autoFocus: para elegir qué control tiene el enfoque cuando se abre el panel, agrégaleautoFocus: true. La propiedad solo aceptatrue, así que omítela en los demás controles.
Cómo se muestra un atajo de teclado depende del botón y la aplicación:
| Botón | En la terminal | En la aplicación de escritorio |
|---|---|---|
| Con corchetes, el predeterminado | [ Add one ], sin atajo de teclado mostrado |
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 entre corchetes, o usa plain: true, para que el usuario pueda ver qué presionar. La referencia de elementos tiene las otras reglas de Button: action, atajos de teclado de dígitos en la banda y dos botones en un mismo atajo de teclado.
Tomar entrada escrita y dibujar una fila para cada 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 presionas Enter para agregarla, y cada nota tiene un botón x que la elimina. Con dos notas agregadas, la terminal dibuja el panel de esta manera:
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
El ejemplo utiliza estas técnicas:
- Tomar entrada escrita: un
Inputllama aonSubmit(value)con el texto del campo cuando el usuario presiona Enter, y aonInput(value)en cada cambio - Dibujar una lista: asigna tus datos a una fila cada uno, 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:
- Agregar una nota: escribe una línea y presiona Enter. La línea aparece como una nueva fila y el campo se vacía.
- Eliminar una nota: presiona Tab hasta que el botón
xde la nota tenga el enfoque, luego presiona Enter. Laxes la etiqueta del botón y no un atajo de teclado, por lo que escribir la letra no lo presiona.
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 propiedad 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 y no las carga. Para recuperarlas en la siguiente sesión, léelas en un hook session.start, de la misma manera que hello-tabs lee count.
Estas propiedades componen la línea del campo, Note: Type a note and press Enter ⏎ add:
| Propiedad | En el ejemplo | Qué es |
|---|---|---|
label |
Note |
El texto antes del campo. La terminal dibuja : después. |
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 dice qué hace Enter |
Enviar un Input no inicia un turno a menos que tu callback llame a $.prompt.submit.
Redibujar 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 ejecuta de nuevo para algunos cambios, y tu mod solicita el resto.
Cuándo Claude Code redibuja sin que se lo pidan
Claude Code ejecuta tu hook ui.render de nuevo cuando cambian las props del sitio o cambia el ancho de la terminal. No ejecuta el hook con un temporizador y no puede saber cuándo cambia una variable en tu módulo.
Redibujar cuando tus datos cambian
Para que tus sitios se redibujen después de que tus propios datos cambien, llama a $.ui.invalidate('ui.render'). Este panel cuenta pulsaciones. La devolución de llamada del botón cambia count y luego solicita un redibujado:
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 mantienes en $.state no necesita la llamada, porque escribir el valor redibuja los sitios que lo leen.
Redibujar con un temporizador
Para mantener actualizado un reloj, una cuenta regresiva o un valor de fuera de la sesión, redibuja según un cronograma. Inicia un temporizador en el hook session.start del módulo. Si el módulo ya tiene uno, como ya lo tiene hello-tabs, agrégale la línea $.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 cuando el módulo se recarga, y la nueva instancia del módulo inicia el suyo propio.
Con qué frecuencia se puede redibujar un sitio
Claude Code limita la frecuencia con la que redibuja un sitio, por lo que tu mod puede llamar a $.ui.invalidate con la frecuencia con que cambien sus datos. Para saber con qué frecuencia se puede redibujar cada sitio, consulta la tabla de límites.
Las llamadas que llegan más rápido que el límite se combinan en un solo redibujado. Ese redibujado ejecuta tu hook una vez, y el hook lee tus datos tal como están en ese momento, por lo que se muestra el valor más reciente y los valores intermedios no se muestran. Una animación no puede ejecutarse más rápido que el límite.
Mantener estado
El lugar donde un mod mantiene un valor decide cuánto tiempo dura el 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 tenga que durar el valor:
| Mantenerlo en | Dura hasta | Úsalo para |
|---|---|---|
| Una variable a nivel de módulo | El módulo se recarga, lo que sucede 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 que deben sobrevivir a una recarga |
$.store |
Tu mod lo elimina, o ninguna sesión lee o 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 bajo ~/.claude/plugins/store/. |
Configuración, historial, cualquier cosa que el usuario espera encontrar la próxima vez |
$.store.get(key) se resuelve en el valor o undefined, y $.store.set(key, value) toma cualquier valor JSON.
Mantener un valor en `$.state`
$.state mantiene valores durante la duración de una sesión, y se redibuja por ti. Es estado reactivo: un hook ui.render que lee un valor se suscribe a él, por lo que Claude Code redibuja 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, lo que una variable no.
Para configurarlo, declara tus valores, apunta tu manifiesto a la declaración, 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 externa es el nombre de tu plugin, y cada entrada bajo 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 permitir que claude plugin validate verifique tu código contra ese archivo, agrega un campo types al manifiesto 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 predeterminado, léelo mientras dibujas y escríbelo desde un callback. atom nombra un valor y su predeterminado, read lo devuelve y update lo escribe. Los tres ayudantes 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 ejecuta el hook nuevamente cada vez que el botón lo escribe.
Estas reglas se aplican al código:
- Escribe
pluginykeycomo cadenas literales:claude plugin validatelas 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 estado y no puede escribirlo, así que escribe desdeonPress,onSubmito un hook de otro evento
Cambiar `hello-tabs` para usar `$.state`
Para mover count en hello-tabs a $.state, cambia cada línea que lo use:
- En la parte superior del módulo: agrega la línea
importy reemplazalet count = 0con la líneaatom - En el hook
ui.render: agrega la líneareadantes detabButtony dibuja'Count: ' + nen elText - En el botón Add one: reemplaza
onPresscon el de Guardar desde más de una sesión, que guarda el recuento además de escribirlo - En el hook
session.start: reemplaza las dos líneas que leensavedcon la llamadaloadCountde Cargar un valor guardado nuevamente después de/clear
Mantén redraw para los botones de pestaña, porque tab sigue siendo una variable.
Cargar un valor guardado nuevamente después de `/clear`
Si tu mod copia un valor guardado de $.store a $.state en session.start, tiene que copiarlo nuevamente después de /clear, /resume o /branch. Esos comandos devuelven cada valor de $.state a su predeterminado, y session.start no se dispara nuevamente. classic.SessionStart sí se dispara después de cada uno de ellos, con e.source establecido en clear, resume o fork, así que copia el valor nuevamente en un hook sobre él. De lo contrario, tu dibujo muestra el predeterminado, y un callback que guarda el valor de $.state escribe el predeterminado sobre lo que almacenaste.
Este código carga count desde ambos hooks. Se basa en la versión de $.state de hello-tabs, donde count es un átomo y update se importa. Pon loadCount arriba de register y agrega la llamada 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 reinicia $.state, por lo que el filtro en source limita el hook a los tres reinicios:
// 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 recuento guardado después de /clear y no 0, y la siguiente pulsación de Add one suma al recuento guardado.
loadCount escribe el valor almacenado sobre el que está en $.state, y session.start se dispara nuevamente cada vez que el módulo se recarga. Para evitar que el almacén quede desactualizado, guarda en cada cambio, como lo hace el botón Add one.
Para verificar la recarga sin una sesión, prueba el dibujo después de /clear.
Guardar desde más de una sesión
Cada sesión en tu máquina que ejecuta tu mod comparte un único $.store. Un get seguido de un set no es atómico. Cuando dos sesiones leen cada una un valor, lo cambian y lo escriben de vuelta, compiten, y la segunda escritura reemplaza la primera.
Para que eso sea menos probable:
- Da a cada elemento su propia clave: un
setcambia solo su propia clave, por lo que las sesiones que escriben claves diferentes no se sobrescriben entre sí - Lee nuevamente justo antes de escribir: para un valor que varias sesiones cambian, 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 aún se pierde si llega entre tugety tuset.
Este botón suma uno a lo que el almacén contiene ahora 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 presionado su propio botón tres veces desde que esta sesión comenzó, esta pulsación muestra y guarda un recuento que incluye esas tres.
Próximos pasos
- Reaccionar a eventos: alimente su dibujo desde llamadas de herramientas y turnos
- Usar la API de mods: alimente su dibujo desde temporizadores y llamadas de modelo
- Probar un dibujo: presione sus botones desde una prueba, en más de una superficie
- Sitios de renderizado y elementos: propiedades de cada sitio y propiedades de cada elemento