Dibujar en la interfaz con un mod
Dibuje paneles, una banda sobre el símbolo del sistema, botones y campos de texto desde un mod de Claude Code, maneje pulsaciones e entrada, y mantenga 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 símbolo del sistema o el spinner. Claude Code genera el evento ui.render cada vez que está a punto de dibujar un sitio de renderizado, y su 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 símbolo del sistema en lugar de junto a la transcripción.
Construya su primer mod antes de comenzar aquí. Comience con el ejemplo trabajado, que construye un panel con dos pestañas y un contador, luego lea la sección para cada parte que desee cambiar.
Para buscar una propiedad o límite, consulte la referencia.
Construir un panel con pestañas
En esta sección construye 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 símbolo del sistema de otra manera. 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:
Claude Code no tiene un elemento de pestañas integrado, por lo que 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 su código y el archivo de código. Crear un mod explica cada uno. Cree un directorio llamado hello-tabs con directorios .claude-plugin y hooks dentro, luego guarde los dos primeros archivos.
Guarde 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" }
}
Nombre su punto de entrada en hello-tabs/hooks/hooks.json:
{
"modules": ["./register.js"]
}
Escribir el código
El código realiza tres trabajos, uno en cada hook:
- Agrega el comando
/hello-tabs - Abre el panel cuando ejecuta 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.
Guarde 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 construye nuevamente 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 construye 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 su shell, inicie Claude Code con claude --plugin-dir ./hello-tabs. En el símbolo del sistema de Claude Code, ejecute /hello-tabs. Se abre un panel con 1: One y 2: Two en la parte superior. Presione 2, luego presione a, el atajo de teclado para Add one, varias veces. El recuento sube.
Verificar que el recuento fue guardado
Presione Esc para cerrar el panel, luego salga de la sesión. En su shell, inicie Claude Code nuevamente con el mismo comando claude --plugin-dir ./hello-tabs y en el símbolo del sistema de Claude Code ejecute /hello-tabs. El recuento está donde lo dejó.
Para borrar el recuento, haga 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 reduzca al que desea dibujar. Para elegir el sitio de renderizado, pase 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 abrió.
Dos sitios están vacíos hasta que un mod los llena, el panel y la banda. Seleccione 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 símbolo del sistema de otra manera. Con varios paneles abiertos, cada uno obtiene una pestaña que muestra su título.
Un panel aparece cuando su mod llama a $.ui.open con un id que elige, 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 su panel, filtre en { component: 'Pane' } y verifique que e.requestId sea su id.
La banda es una franja directamente sobre la entrada del símbolo del sistema. Siempre está ahí, y cada mod la comparte.
Su 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 suyo dibujan allí. Para mantener el suyo, ponga el resultado de await next(e) entre los hijos de un Box en su árbol.
Para dibujar en la banda, filtre en { component: 'AbovePrompt' }.
Cambiar lo que Claude Code ya dibuja
Claude Code dibuja la mayoría de su interfaz a sí mismo: mensajes, filas de llamadas de herramientas, el spinner y más. Cada una de esas partes es un sitio de renderizado también, por lo que un mod puede cambiar el estilo o reemplazarlo. Para cambiar uno, filtre su hook ui.render en su nombre de esta tabla:
| Sitio | Qué es |
|---|---|
UserMessage, AssistantMessage |
Un mensaje en la transcripción |
ToolUse, ToolResult, ToolGroup |
La fila de una llamada de herramienta, su resultado y una ejecución plegada de llamadas |
CommandOutput |
La fila que un comando imprimió |
AskUserQuestion |
El diálogo que Claude abre para hacerle 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 símbolo del sistema |
En un sitio que Claude Code ya dibuja, su hook tiene tres opciones: cambiar un detalle, reemplazar el dibujo o dejarlo solo. Seleccione una pestaña para ver cada una 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, pase 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 su texto sigue a la palabra:
Thinking · tool calls: 2…
Para dibujar algo propio en el lugar del sitio, devuelva un árbol y no llame 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, su 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, devuelva next(e). Un hook a menudo hace eso para algunos eventos y no para otros. Este hook deja el spinner solo hasta que hay una llamada para 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 de herramienta, el spinner se ve de la manera que lo hace sin el mod:
Thinking…
El símbolo del sistema 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, es uno, por lo que un mod puede cambiar eso.
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 su mod lo abre. Cómo y cuándo lo abre decide si toma el enfoque del teclado, cuánto espacio solicita y si aparece en absoluto en una terminal estrecha.
Para abrir un panel, llame a $.ui.open con un id que elija. El id es el nombre del panel: su hook ui.render lo verifica y lo pasa nuevamente para cerrar el panel.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
Para cerrar el panel, llame a $.ui.close con el id con el que lo abrió:
await $.ui.close({ id: 'hello-tabs' })
Además de id, $.ui.open toma estos campos opcionales:
| Campo | Qué hace |
|---|---|
title |
La etiqueta de pestaña del panel cuando hay más de un panel abierto |
focus |
Solicita enfoque del teclado |
closeOnEscape |
Hace que Esc cierre el panel. Pase true o deje el campo fuera, porque Claude Code rechaza false. |
holdToasts |
Mantiene las notificaciones, los pequeños avisos de $.ui.toast, hasta que se cierre el panel |
rows |
La altura a solicitar cuando el panel se sitúa sobre el símbolo del sistema. El valor predeterminado es un tercio del espacio. |
columns |
El ancho a solicitar cuando el panel se sitúa junto a la transcripción |
Para permitir que un comando abra el panel mientras Claude está trabajando, agregue immediate: true cuando registre 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 su mod abre sin ser solicitado no aparece en una terminal estrecha, por lo que no puede ocupar una pantalla pequeña. Si aparece depende de lo que lo abrió:
- Abierto por algo que hizo el usuario, como un comando que ejecutó o un botón que presionó, el panel aparece en cualquier ancho
- Abierto por su 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 es suficiente.
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 decir que algo está disponible sin abrir un panel, llame a $.ui.toast('Your message'), que muestra un pequeño aviso que desaparece después de unos segundos.
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í. Describe el dibujo y Claude Code lo renderiza en la terminal o en la aplicación de escritorio.
Para obtener los elementos, llame a $.ui.resolve(e) en su hook, como en const { Box, Text, Button } = $.ui.resolve(e). Cada elemento es una función. Pasa sus propiedades y pone los elementos y cadenas que van dentro en children.
La mayoría de los dibujos utilizan cuatro elementos. Seleccione una pestaña para ver cada uno 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 su 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 su 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 ⏎ add
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 pasa 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 suyo, para animación e entrada de puntero. Ese archivo no obtiene API de mods. Solo llega a sus 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 su módulo es un archivo .tsx o .jsx, puede escribir el árbol como JSX. Desestructure los elementos de $.ui.resolve(e) primero, porque un módulo de hooks no tiene globales de elementos.
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 dice, 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, verifique 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, dibuje un Raster y no un Box para cada celda. Un Raster toma una key, su tamaño en columns y rows, y cells, que empaqueta cada celda en una cadena. 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 número hexadecimal con dos dígitos cada uno para rojo, verde y azul, como 0xc62828 para un rojo, o 0x01000000 para el predeterminado de la terminal.
La aplicación de escritorio no tiene Raster, por lo que verifique e.surface y dibuje 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ía, y cellsOf la convierte en la cadena empaquetada. El hook dibuja solo en un panel cuyo id es heat, por lo que abra 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, llame 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 su 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 su mod dibujó, Claude Code llama a la función que le dio a ese control, y se ejecuta en su 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 dé a cada control uno. 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 enganchar esos eventos. Su hook se ejecuta antes de su callback, por lo que ve lo que el usuario escribe en su Input y puede cambiarlo o responder en lugar de su callback. La API de mods no tiene método que presione el botón de otro mod.
Enfoque del teclado y atajos de teclado
Su mod nunca lee el teclado a sí mismo. El usuario presiona una tecla, Claude Code decide cuál de sus controles es para, y se ejecuta el callback de ese control. Aparte de un atajo de teclado de dígito en la banda, eso sucede solo mientras su panel o banda tiene enfoque del teclado. El resto del tiempo, las teclas van al símbolo del sistema.
Cómo un panel obtiene enfoque del teclado
Un panel obtiene enfoque del teclado de una de tres maneras:
- Su 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 símbolo del sistema está vacío y nada más tiene 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 su panel o banda tiene enfoque del teclado:
| Tecla | Qué hace |
|---|---|
| Tab | Se mueve al siguiente control |
| Arriba y Abajo | Se mueven entre controles mientras su dibujo cabe. Cuando el panel o banda tiene más filas de las que puede mostrar, los 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 símbolo del sistema. Con closeOnEscape: true, también cierra el panel. |
Un mod no puede vincular Tab o 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
Dos propiedades en un control deciden cómo el teclado lo alcanza:
hotkey: para permitir que el usuario presione unButtoncon una tecla, dé 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, agregueautoFocus: truea él. Deje la propiedad fuera de los otros, porque Claude Code rechazaautoFocus: false.
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, nombre la tecla en la etiqueta de un botón entre corchetes, o use 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 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 en esta sección es un panel de notas: escribe una nota y presiona 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 dos técnicas:
- Tomar entrada escrita: un
Inputllama aonSubmit(value)con el texto del campo cuando el usuario presiona Enter, yonInput(value)en cada cambio - Dibujar una lista: asigne sus datos a una fila cada uno, y dé a cada botón de 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: escriba una línea y presione Enter. La línea aparece como una nueva fila y el campo se vacía.
- Eliminar una nota: presione Tab hasta que el botón
xde la nota tenga el enfoque, luego presione 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 el campo contiene cuando se dibuja, y la escritura del usuario lo reemplaza hasta que su hook dibuja el campo nuevamente. El ejemplo siempre dibuja el campo con ''.
El ejemplo guarda las notas y no las carga. Para traerlas de vuelta en la siguiente sesión, léalas en un hook session.start, de la manera que hello-tabs lee count.
Tres 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 su callback llame a $.prompt.submit.
Redibujar un sitio
Un dibujo es una instantánea: muestra lo que devolvió su 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 su mod solicita el resto.
Cuándo Claude Code redibuja sin ser solicitado
Claude Code ejecuta su hook ui.render de nuevo cuando cambian los props del sitio o cambia el ancho de la terminal. No ejecuta el hook en un temporizador y no puede saber cuándo cambia una variable en su módulo.
Redibujar cuando sus datos cambian
Para que sus sitios se redibjen después de que sus propios datos cambien, llame 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 mantiene en $.state no necesita la llamada, porque escribir el valor redibuja los sitios que lo leen.
Redibujar en un temporizador
Para mantener un reloj, una cuenta atrás o un valor de fuera de la sesión actual, redibuje según un cronograma. Inicie un temporizador en el hook session.start del módulo. Si el módulo ya tiene uno, como lo hace hello-tabs, agregue la línea $.clock.every a él:
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 su hook ui.render una vez por segundo. El temporizador se detiene cuando el módulo se recarga, y la nueva copia 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 su mod puede llamar a $.ui.invalidate con la frecuencia que cambien sus datos. El panel visible y la banda tienen un límite más alto que otros sitios, y la tabla de límites tiene los números.
Las llamadas que llegan más rápido que el límite se combinan en un redibujado. Ese redibujado ejecuta su hook una vez, y el hook lee sus 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
Un mod tiene tres lugares para mantener un valor, y difieren en 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. Elija según cuánto tiempo tenga que durar el valor:
| Mantenerlo en | Dura hasta | Úselo para |
|---|---|---|
| Una variable a nivel de módulo | El módulo se recarga, lo que sucede cada vez que guarda un archivo durante el desarrollo | Valores que puede 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 |
Su 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 de su propio 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 usted. 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 escribe el valor, y no llama a $.ui.invalidate. Un valor en $.state también sobrevive a una recarga del módulo, lo que una variable no.
Para configurarlo, declare sus valores, apunte su manifiesto a la declaración, luego defina y use cada valor. Los ejemplos mueven el count de hello-tabs a $.state.
Declarar los valores
Declare los valores en un archivo de tipos. La clave externa es el nombre de su plugin, y cada entrada bajo ella es un valor y su tipo. Guarde 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 su código contra ese archivo, agregue 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 su módulo, defina cada valor con un predeterminado, léalo mientras dibuja y escríbalo 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 usted:
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)
Porque el hook ui.render leyó count, Claude Code ejecuta el hook nuevamente cada vez que el botón lo escribe.
Tres reglas se aplican al código:
- Escriba
pluginykeycomo cadenas literales:claude plugin validatelas lee de su fuente - Declare cada valor en el archivo de tipos: de lo contrario, la validación falla con
hello-tabs.count is not declared - Escriba desde un callback u otro hook de evento: un hook
ui.renderpuede leer estado y no puede escribirlo, así que escriba desdeonPress,onSubmitu otro hook de evento
Cambiar `hello-tabs` para usar `$.state`
Para mover count en hello-tabs a $.state, cambie cada línea que lo use:
- En la parte superior del módulo: agregue la línea
importy reemplacelet count = 0con la líneaatom - En el hook
ui.render: agregue la líneareadantes detabButtony dibuje'Count: ' + nen elText - En el botón Add one: reemplace
onPresscon el de Guardar desde más de una sesión, que guarda el recuento además de escribirlo - En el hook
session.start: reemplace las dos líneas que leensavedcon la llamadaloadCountde Cargar un valor guardado nuevamente después de/clear
Mantenga redraw para los botones de pestaña, porque tab sigue siendo una variable.
Cargar un valor guardado nuevamente después de `/clear`
Si su 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 se dispara después de cada uno de ellos, con e.source establecido en clear, resume o fork, así que copie el valor nuevamente en un hook en él. De lo contrario, su dibujo muestra el predeterminado, y un callback que guarda el valor de $.state escribe el predeterminado sobre lo que almacenó.
Este código carga count de ambos hooks. Se basa en la versión de $.state de hello-tabs, donde count es un átomo y update se importa. Ponga loadCount arriba de register y agregue la llamada loadCount al hook session.start que ya tiene. 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 mantiene el hook a los tres reiniciados:
// 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 mantener el almacén actualizado, guarde en cada cambio, como lo hace el botón Add one.
Para verificar la recarga sin una sesión, pruebe el dibujo después de /clear.
Guardar desde más de una sesión
Cada sesión en su máquina que ejecuta su mod comparte un $.store. Un get seguido de un set no es atómico. Cuando dos sesiones leen un valor, lo cambian y lo escriben de vuelta, compiten, y la segunda escritura reemplaza la primera.
Dos opciones hacen que sea menos probable:
- Dé 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í - Lea nuevamente justo antes de escribir: para un valor que varias sesiones cambian,
getla clave en el callback y construya el nuevo valor a partir de eso, no de una copia que cargó ensession.start. La escritura de otra sesión se pierde si llega entre sugety suset.
Este botón suma uno a lo que el almacén contiene ahora, 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 esos 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