Dessiner dans l'interface avec un mod
Dessinez des volets, une bande au-dessus de l'invite, des boutons et des champs de texte à partir d'un mod Claude Code, gérez les appuis et les entrées, et conservez l'état entre les redessinages et les sessions.
Un mod peut dessiner sa propre interface dans Claude Code et modifier des parties de l'interface que Claude Code dessine déjà. Chaque endroit où un mod peut dessiner s'appelle un site de rendu, comme un volet, la bande au-dessus de l'invite, ou le spinner. Claude Code déclenche l'événement ui.render chaque fois qu'il s'apprête à dessiner un site de rendu, et votre hook pour cet événement retourne ce qu'il faut dessiner là.
Cette carte montre où un mod peut dessiner dans une session de terminal :
Dans un terminal plus étroit, le volet se trouve au-dessus de l'invite au lieu de côté de la transcription.
Construisez votre premier mod avant de commencer ici. Commencez par l'exemple travaillé, qui construit un volet avec deux onglets et un compteur, puis lisez la section pour chaque élément que vous voulez modifier.
Pour rechercher une prop ou une limite, consultez la référence.
Construire un volet avec des onglets
Dans cette section, vous construisez un mod qui ajoute une commande /hello-tabs, et la commande ouvre un volet. Un volet est une barre latérale à côté de la transcription dans un terminal plein écran large, ou une région encadrée au-dessus de l'invite sinon. Ce volet affiche deux onglets, et le deuxième onglet a un bouton qui ajoute un au compteur. Le compte est toujours là après que vous redémarriez Claude Code.
Le mod fini ressemble à ceci. L'enregistrement ouvre le volet, bascule vers le deuxième onglet, appuie sur le bouton quelques fois, et revient au premier onglet :
Claude Code n'a pas d'élément d'onglets intégré, donc les onglets sont deux boutons dans une ligne. Le mod garde la trace de celui qui est actif et dessine le contenu de cet onglet sous la ligne.
Créer le plugin
Un mod est un plugin avec un manifeste, un hooks.json qui pointe vers votre code, et le fichier de code. Créer un mod explique chacun. Créez un répertoire nommé hello-tabs avec des répertoires .claude-plugin et hooks à l'intérieur, puis enregistrez les deux premiers fichiers.
Enregistrez le manifeste sous 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" }
}
Nommez votre point d'entrée dans hello-tabs/hooks/hooks.json :
{
"modules": ["./register.js"]
}
Écrire le code
Le code fait trois choses, une dans chaque hook :
- Ajoute la commande
/hello-tabs - Ouvre le volet quand vous exécutez cette commande
- Dessine le contenu du volet : la ligne d'onglets et le corps de l'onglet ouvert
Deux variables au niveau du module, tab et count, conservent l'état du volet.
Enregistrez ceci sous 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,
],
})
})
}
Chaque hook fait aussi quelque chose que le code ne rend pas évident :
session.startlit aussi le compte sauvegardé depuis$.store, un magasin clé-valeur qui persiste entre les sessions.command.rundit seulement à Claude Code que le volet existe. Ouvrir un volet ne dessine rien par lui-même : Claude Code déclenche ensuiteui.renderpour demander ce qu'il faut y mettre.ui.renderretourne l'arbre d'éléments, uneBoxqui contient d'autres boîtes, du texte et des boutons, et le construit à nouveau à partir detabetcountchaque fois qu'il s'exécute.
Appuyer sur un bouton exécute son callback onPress, qui change une variable et appelle redraw. Claude Code exécute ensuite le hook ui.render à nouveau, et le hook construit un nouvel arbre à partir des nouvelles valeurs. Chaque dessin interactif utilise ce cycle de rendu : un callback change l'état, et le hook dessine à nouveau à partir du nouvel état.
Ouvrir le volet
Dans votre shell, démarrez Claude Code avec claude --plugin-dir ./hello-tabs. À l'invite Claude Code, exécutez /hello-tabs. Un volet s'ouvre avec 1: One et 2: Two en haut. Appuyez sur 2, puis appuyez sur a, la touche de raccourci pour Add one, quelques fois. Le compte augmente.
Vérifier que le compte a été sauvegardé
Appuyez sur Esc pour fermer le volet, puis quittez la session. Dans votre shell, démarrez Claude Code à nouveau avec la même commande claude --plugin-dir ./hello-tabs, et à l'invite Claude Code exécutez /hello-tabs. Le compte est où vous l'avez laissé.
Pour effacer le compte, faites appeler au mod $.store.delete('count'). Conserver l'état couvre combien de temps chaque type de valeur dure.
Choisir où dessiner
Un hook ui.render s'exécute pour chaque site de rendu sauf si vous le réduisez à celui que vous voulez dessiner. Pour choisir le site de rendu, passez un filtre, appelé un matcher, comme deuxième argument à on. { component: 'Pane' } exécute le hook seulement pour les volets. Dans le hook, e.component nomme le site, e.surface dit quelle application dessine, et e.props contient les données propres du site. Pour un volet, e.requestId est l'id avec lequel vous l'avez ouvert.
Deux sites sont vides jusqu'à ce qu'un mod les remplisse, le volet et la bande. Sélectionnez un onglet pour voir ce que chacun est et comment dessiner dedans :
Un volet est une barre latérale à côté de la transcription dans un terminal plein écran large, ou une région encadrée au-dessus de l'invite sinon. Avec plusieurs volets ouverts, chacun obtient un onglet qui affiche son titre.
Un volet apparaît quand votre mod appelle $.ui.open avec un id que vous choisissez, comme dans $.ui.open({ id: 'hello-tabs' }). Ouvrir un volet au bon moment couvre les autres champs et quand un volet attend un terminal plus large.
Pour dessiner dans votre volet, filtrez sur { component: 'Pane' } et vérifiez que e.requestId est votre id.
La bande est une bande directement au-dessus de l'entrée d'invite. Elle est toujours là, et chaque mod la partage.
Votre hook retourne un arbre pour afficher quelque chose dans la bande, ou next(e) pour ne rien afficher. Un arbre remplace ce que les mods après le vôtre dessinent là. Pour garder le leur, mettez le résultat de await next(e) parmi les enfants d'une Box dans votre arbre.
Pour dessiner dans la bande, filtrez sur { component: 'AbovePrompt' }.
Modifier ce que Claude Code dessine déjà
Claude Code dessine la plupart de son interface lui-même : les messages, les lignes d'appels d'outils, le spinner, et plus. Chacune de ces parties est aussi un site de rendu, donc un mod peut le restyler ou le remplacer. Pour en modifier un, filtrez votre hook ui.render sur son nom de ce tableau :
| Site | Ce que c'est |
|---|---|
UserMessage, AssistantMessage |
Un message dans la transcription |
ToolUse, ToolResult, ToolGroup |
La ligne d'un appel d'outil, son résultat, et une exécution repliée d'appels |
CommandOutput |
La ligne qu'une commande a imprimée |
AskUserQuestion |
Le dialogue que Claude ouvre pour vous poser une question |
Spinner, ToolProgress, TurnDuration |
Lignes d'état pour un tour : la ligne qui s'anime pendant que Claude travaille, la ligne de progression en direct d'un outil en cours d'exécution, et la ligne qui ferme un tour |
InfoNotice, SessionMode, PromptHint |
Lignes d'état sous le logo, les étiquettes de mode dans le pied de page, et la ligne d'indice sous l'invite |
À un site que Claude Code dessine déjà, votre hook a trois choix : modifier un détail, remplacer le dessin, ou le laisser tranquille. Sélectionnez un onglet pour voir chacun appliqué au spinner. Les exemples lisent une variable calls qu'un autre hook compte, comme dans le mod tutoriel.
Pour garder le dessin de Claude Code et modifier une partie de celui-ci, passez à next une copie de l'événement avec des props modifiées. Ce hook change le texte après le mot du 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 + '…' } })
})
Le spinner garde son animation et son mot, et votre texte suit le mot :
Thinking · tool calls: 2…
Pour dessiner quelque chose de votre propre à la place du site, retournez un arbre et n'appelez pas next. Ce hook dessine une ligne de texte où le spinner serait :
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'] })
})
Pendant que Claude travaille, votre ligne s'affiche et le spinner de Claude Code ne s'affiche pas :
Claude has made 2 tool calls
Pour laisser le site tel que Claude Code le dessine, retournez next(e). Un hook fait souvent cela pour certains événements et pas pour d'autres. Ce hook laisse le spinner tranquille jusqu'à ce qu'il y ait un appel à compter :
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 + '…' } })
})
Avant le premier appel d'outil, le spinner ressemble à la façon dont il le fait sans le mod :
Thinking…
L'invite de permission n'est pas un site de rendu, donc un mod ne peut pas modifier ce qu'il affiche. Le dialogue de question, AskUserQuestion, en est un, donc un mod peut modifier cela.
Le terminal et l'application Desktop ne déclenchent pas tous les mêmes sites. Pane, AbovePrompt, Spinner, et les sites de transcription fonctionnent dans les deux. Quelques autres lignes d'état sont déclenchées seulement dans le terminal. Le tableau des sites de rendu liste où chacun est déclenché.
Ouvrir un volet au bon moment
Un volet n'apparaît que quand votre mod l'ouvre. Comment et quand vous l'ouvrez décide s'il prend le focus clavier, combien d'espace il demande, et s'il s'affiche du tout dans un terminal étroit.
Pour ouvrir un volet, appelez $.ui.open avec un id que vous choisissez. L'id est le nom du volet : votre hook ui.render le vérifie, et vous le passez à nouveau pour fermer le volet.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
Pour fermer le volet, appelez $.ui.close avec l'id avec lequel vous l'avez ouvert :
await $.ui.close({ id: 'hello-tabs' })
En plus de id, $.ui.open prend ces champs optionnels :
| Champ | Ce qu'il fait |
|---|---|
title |
L'étiquette d'onglet du volet quand plus d'un volet est ouvert |
focus |
Demande le focus clavier |
closeOnEscape |
Fait que Esc ferme le volet. Passez true ou laissez le champ de côté, car Claude Code refuse false. |
holdToasts |
Retient les toasts, les petits avis de $.ui.toast, jusqu'à ce que le volet se ferme |
rows |
La hauteur à demander quand le volet se trouve au-dessus de l'invite. La valeur par défaut est un tiers de l'espace. |
columns |
La largeur à demander quand le volet se trouve à côté de la transcription |
Pour laisser une commande ouvrir le volet pendant que Claude travaille, ajoutez immediate: true quand vous enregistrez la commande. Sans cela, une commande tapée pendant un tour attend la fin du tour.
Quand un volet attend un terminal plus large
Un volet que votre mod ouvre sans être demandé n'apparaît pas dans un terminal étroit, donc il ne peut pas prendre le contrôle d'un petit écran. S'il apparaît dépend de ce qui l'a ouvert :
- Ouvert par quelque chose que l'utilisateur a fait, comme une commande qu'il a exécutée ou un bouton qu'il a appuyé, le volet apparaît à n'importe quelle largeur
- Ouvert par votre mod agissant par lui-même, comme à partir d'une minuterie ou d'un hook
turn.start, le volet n'apparaît que dans un terminal d'au moins 144 colonnes de large. Après que l'utilisateur ait ouvert ce volet une fois lui-même, 110 colonnes suffisent.
Quand le volet apparaît, $.ui.open se résout en { isPlaced: true }. Quand le volet attend, isPlaced est false et reason est une chaîne qui dit pourquoi. Un volet en attente apparaît quand l'utilisateur l'ouvre ou élargit le terminal. Pour dire que quelque chose est disponible sans ouvrir un volet, appelez $.ui.toast('Your message'), qui affiche un petit avis qui disparaît après quelques secondes.
Construire un arbre à partir d'éléments
Ce qu'un hook ui.render retourne est un arbre d'éléments : une description de ce qu'il faut dessiner, faite de boîtes, de texte et de contrôles imbriqués les uns dans les autres. Vous décrivez le dessin, et Claude Code le rend dans le terminal ou l'application Desktop.
Pour obtenir les éléments, appelez $.ui.resolve(e) dans votre hook, comme dans const { Box, Text, Button } = $.ui.resolve(e). Chaque élément est une fonction. Vous lui passez des props, et vous mettez les éléments et les chaînes qui vont à l'intérieur dans children.
La plupart des dessins utilisent quatre éléments. Sélectionnez un onglet pour voir chacun et comment le terminal le dessine :
Text dessine une chaîne, avec un style optionnel comme bold et color :
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box arrange ce qui est à l'intérieur, dans une ligne ou une colonne. Celle-ci met un bouton et une ligne de texte côte à côte, deux colonnes à part :
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
[ Add one ] Count: 0
Button est un contrôle que l'utilisateur peut appuyer. Il exécute votre callback onPress. Avec plain: true il n'a pas de crochets et affiche sa touche de raccourci :
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 est un champ de texte. Il exécute votre callback onSubmit avec le texte quand l'utilisateur appuie sur Entrée :
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
Ce tableau liste chaque élément :
| Élément | Ce qu'il dessine | Où |
|---|---|---|
Box |
Un conteneur flex. Prend des props de mise en page comme flexDirection, columnGap, padding, borderStyle, et width. |
Partout |
Text |
Texte stylisé. Prend color, bold, dimColor, italic, et wrap. Une color est une clé de thème ou une couleur comme 'red'. Un wrap est 'wrap', 'truncate', 'truncate-start', 'truncate-middle', ou 'truncate-end'. |
Partout |
Button |
Un contrôle qui appelle onPress |
Partout |
Link, Code, Markdown |
Un lien avec href et une label optionnelle, un bloc de code, et du texte formaté comme les réponses de Claude. Markdown prend son contenu dans une prop text, pas dans children, et a besoin d'une key quand vous passez onLinkPress. |
Partout |
Input, Select |
Un champ de texte et un sélecteur | Terminal, Desktop |
Svg |
Un document SVG | Desktop |
Client |
Une région dessinée par un deuxième fichier du vôtre, pour l'animation et l'entrée au pointeur. Ce fichier n'obtient pas l'API des mods. Il atteint vos hooks seulement en postant des données, qui arrivent comme un événement ui.message. |
Terminal, Desktop |
Raster, Image |
Une grille de cellules colorées, et une image | Terminal |
Si votre module est un fichier .tsx ou .jsx, vous pouvez écrire l'arbre en JSX. Déstructurez les éléments de $.ui.resolve(e) d'abord, car un module de hooks n'a pas de globals d'éléments.
Si un arbre utilise un élément que l'application n'a pas, une prop qu'un élément ne prend pas, ou un enfant où aucun ne va, Claude Code dessine sa propre version du site.
Dans une session démarrée avec --plugin-dir, une ligne de transcription le dit, comme ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. Le journal de débogage l'enregistre comme ui.render (Pane): a hook returned a tree that does not validate avec la même raison. Rien d'autre n'apparaît dans la session, donc quand un dessin ne s'affiche pas, vérifiez cette ligne ou le journal.
Dessiner une grille de cellules colorées
Pour une carte thermique, une sparkline, ou un plateau de jeu dans le terminal, dessinez un Raster et non une Box pour chaque cellule. Un Raster prend une key, sa taille en columns et rows, et cells, qui empaquette chaque cellule dans une chaîne. Chaque cellule est trois nombres : le point de code du caractère, sa couleur et sa couleur de fond. Une couleur est un nombre hexadécimal avec deux chiffres chacun pour le rouge, le vert et le bleu, comme 0xc62828 pour un rouge, ou 0x01000000 pour la valeur par défaut du terminal.
L'application Desktop n'a pas de Raster, donc vérifiez e.surface et dessinez du texte là. Ce corps de volet dessine une carte thermique de trois par deux :
// 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) })],
})
})
Dans le terminal, le volet affiche la grille :
Le tableau rows est la partie que vous changeriez, et cellsOf la transforme en chaîne empaquetée. Le hook dessine seulement dans un volet dont l'id est heat, donc ouvrez-en un avec $.ui.open({ id: 'heat' }) à partir d'une commande, comme l'exemple hello-tabs ouvre son volet.
Chaque caractère doit être large d'une cellule. Pour animer un Raster qui est déjà à l'écran, appelez $.ui.blit avec l'id du volet comme requestId, la key du Raster, la même taille, et de nouvelles cellules. Pour cet exemple, c'est $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }). Il repeint cet élément sans exécuter votre hook ui.render à nouveau.
Répondre aux appuis et à la saisie
Quand l'utilisateur appuie sur un bouton, tape dans un champ, ou choisit dans une liste que votre mod a dessinée, Claude Code appelle la fonction que vous avez donnée à ce contrôle, et elle s'exécute dans votre module. Chaque contrôle prend ses propres callbacks :
Button: prendonPress(e), oùe.surfaceest l'application d'où vient l'appuiInput: prendonSubmit(value)etonInput(value)Select: prendonSelect(value)avec ses choix dansoptions, une liste d'au moins un choix avec des valeurs uniques, comme[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
Un test appuie ou tape dans un contrôle par sa key, donc donnez-en un à chaque contrôle. Chaque utilisation d'un contrôle déclenche aussi ui.press, ui.input, ou ui.select avec la key dans e.element, et un autre mod peut accrocher ces événements. Son hook s'exécute avant votre callback, donc il voit ce que l'utilisateur tape dans votre Input et peut le modifier ou répondre à la place de votre callback. L'API des mods n'a pas de méthode qui appuie sur le bouton d'un autre mod.
Focus clavier et touches de raccourci
Votre mod ne lit jamais le clavier lui-même. L'utilisateur appuie sur une touche, Claude Code décide lequel de vos contrôles c'est, et le callback de ce contrôle s'exécute. À part une touche de raccourci numérique sur la bande, cela ne se produit que pendant que votre volet ou bande a le focus clavier. Le reste du temps, les touches vont à l'invite.
Comment un volet obtient le focus clavier
Un volet obtient le focus clavier de l'une de trois façons :
- Votre mod l'ouvre avec
focus: trueà partir d'une commande ou d'un appui - L'utilisateur appuie sur Ctrl+X puis Tab
- L'utilisateur clique dessus
Claude Code accorde focus: true seulement pendant que l'invite est vide et rien d'autre n'a le focus clavier. Un volet qui s'ouvre pendant que l'utilisateur tape ne prend pas ses frappes.
Ce que chaque touche fait
Ce tableau liste ce qu'une touche fait pendant que votre volet ou bande a le focus clavier :
| Touche | Ce qu'elle fait |
|---|---|
| Tab | Se déplace vers le contrôle suivant |
| Haut et Bas | Se déplacent entre les contrôles pendant que votre dessin s'adapte. Quand le volet ou la bande a plus de lignes qu'il ne peut en afficher, ils le font défiler. |
| Entrée | Appuie sur le Button ciblé, soumet le Input ciblé, ou choisit dans un Select |
| La touche de raccourci d'un bouton | Appuie sur ce bouton. Pendant qu'un Input a le focus, chaque touche imprimable va au champ. |
| Esc | Retourne le focus clavier à l'invite. Avec closeOnEscape: true, il ferme aussi le volet. |
Un mod ne peut pas lier Tab ou les touches fléchées à autre chose, donc un jeu se dirige avec w, a, s, et d.
Définir une touche de raccourci et le premier focus
Deux props sur un contrôle décident comment le clavier l'atteint :
hotkey: pour laisser l'utilisateur appuyer sur unButtonavec une touche, donnez-lui unehotkeyd'un chiffre ou une lettre minuscule, comme danshotkey: 'a'autoFocus: pour choisir quel contrôle a le focus quand le volet s'ouvre, ajoutezautoFocus: trueà celui-ci. Laissez la prop de côté sur les autres, car Claude Code refuseautoFocus: false.
Comment une touche de raccourci s'affiche dépend du bouton et de l'application :
| Bouton | Dans le terminal | Dans l'application Desktop |
|---|---|---|
| Avec crochets, la valeur par défaut | [ Add one ], sans touche de raccourci affichée |
L'étiquette avec une petite touche à côté |
Avec plain: true |
1: One |
L'étiquette avec une petite touche à côté |
Dans le terminal, nommez la touche dans l'étiquette d'un bouton entre crochets, ou utilisez plain: true, pour que l'utilisateur puisse voir ce qu'il faut appuyer. La référence des éléments a les autres règles de Button : action, les touches de raccourci numériques sur la bande, et deux boutons sur une touche de raccourci.
Prendre l'entrée tapée et dessiner une ligne pour chaque élément
De nombreux volets sont un champ de texte avec une liste en dessous. L'exemple de cette section est un volet de notes : vous tapez une note et appuyez sur Entrée pour l'ajouter, et chaque note a un bouton x qui la supprime. Avec deux notes ajoutées, le terminal dessine le volet de cette façon :
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
L'exemple utilise deux techniques :
- Prendre l'entrée tapée : un
InputappelleonSubmit(value)avec le texte du champ quand l'utilisateur appuie sur Entrée, etonInput(value)à chaque changement - Dessiner une liste : mappez vos données à une ligne chacune, et donnez à chaque bouton de ligne sa propre
key
Ce hook dessine le contenu du volet :
// 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] }),
],
}),
),
],
})
})
Pour essayer le volet :
- Ajouter une note : tapez une ligne et appuyez sur Entrée. La ligne apparaît comme une nouvelle ligne, et le champ se vide.
- Supprimer une note : appuyez sur Tab jusqu'à ce que le bouton
xde la note ait le focus, puis appuyez sur Entrée. Lexest l'étiquette du bouton et non une touche de raccourci, donc taper la lettre ne l'appuie pas.
Chaque changement suit le même cycle de rendu que hello-tabs : le callback change notes, appelle redraw, et enregistre la liste dans $.store.
Le champ se vide après chaque soumission à cause de sa prop value. value est le texte que le champ contient quand il est dessiné, et la saisie de l'utilisateur le remplace jusqu'à ce que votre hook dessine le champ à nouveau. L'exemple dessine toujours le champ avec ''.
L'exemple enregistre les notes et ne les charge pas. Pour les ramener dans la session suivante, lisez-les dans un hook session.start, de la même façon que hello-tabs lit count.
Trois props composent la ligne du champ, Note: Type a note and press Enter ⏎ add :
| Prop | Dans l'exemple | Ce que c'est |
|---|---|---|
label |
Note |
Le texte avant le champ. Le terminal dessine : après. |
placeholder |
Type a note and press Enter |
Texte atténué qui s'affiche pendant que le champ est vide |
submitLabel |
add |
Le mot après ⏎ qui dit ce que fait Entrée |
Soumettre un Input ne démarre pas un tour sauf si votre callback appelle $.prompt.submit.
Redessiner un site
Un dessin est un instantané : il affiche ce que votre hook ui.render a retourné la dernière fois que le hook s'est exécuté. Pour afficher quelque chose de nouveau, le hook doit s'exécuter à nouveau. Claude Code l'exécute à nouveau pour certains changements, et votre mod demande le reste.
Quand Claude Code redessine sans être demandé
Claude Code exécute votre hook ui.render à nouveau quand les props du site changent ou la largeur du terminal change. Il n'exécute pas le hook sur une minuterie, et il ne peut pas dire quand une variable dans votre module change.
Redessiner quand vos données changent
Pour avoir vos sites dessinés à nouveau après que vos propres données changent, appelez $.ui.invalidate('ui.render'). Ce volet compte les appuis. Le callback du bouton change count, puis demande un redessin :
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] }),
],
})
})
Chaque appui augmente le nombre dans le volet. L'exemple hello-tabs enveloppe le même appel dans sa fonction redraw.
Une valeur que vous gardez dans $.state n'a pas besoin de l'appel, car écrire la valeur redessine les sites qui la lisent.
Redessiner sur une minuterie
Pour garder une horloge, un compte à rebours, ou une valeur de l'extérieur de la session actuelle, redessinez selon un calendrier. Démarrez une minuterie dans le hook session.start du module. Si le module en a déjà une, comme hello-tabs le fait, ajoutez la ligne $.clock.every à celle-ci :
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 exécute maintenant votre hook ui.render une fois par seconde. La minuterie s'arrête quand le module se recharge, et la nouvelle copie du module démarre la sienne.
À quelle fréquence un site peut redessiner
Claude Code limite la fréquence à laquelle il redessine un site, donc votre mod peut appeler $.ui.invalidate aussi souvent que ses données changent. Le volet visible et la bande ont une limite plus élevée que les autres sites, et le tableau des limites contient les chiffres.
Les appels qui viennent plus vite que la limite sont combinés en un redessin. Ce redessin exécute votre hook une fois, et le hook lit vos données telles qu'elles sont à ce moment, donc la valeur la plus récente s'affiche et les valeurs entre les deux ne s'affichent pas. Une animation ne peut pas s'exécuter plus vite que la limite.
Conserver l'état
Un mod a trois endroits pour garder une valeur, et ils diffèrent dans la durée pendant laquelle la valeur dure : jusqu'à ce que le module se recharge, jusqu'à ce que la session se termine, ou d'une session à l'autre. Choisissez selon la durée pendant laquelle la valeur doit durer :
| La garder dans | Elle dure jusqu'à | L'utiliser pour |
|---|---|---|
| Une variable au niveau du module | Le module se recharge, ce qui se produit chaque fois que vous enregistrez un fichier pendant le développement | Les valeurs que vous pouvez perdre, comme tab dans hello-tabs |
$.state |
La session se termine, ou l'utilisateur exécute /clear, /resume, ou /branch |
Les valeurs sur lesquelles un dessin dépend qui devraient survivre à un rechargement |
$.store |
Votre mod la supprime, ou aucune session ne lit ou n'écrit le magasin pendant cleanupPeriodDays. Le magasin est un magasin clé-valeur, enregistré en tant que fichier JSON de votre propre plugin sous ~/.claude/plugins/store/. |
Les paramètres, l'historique, tout ce que l'utilisateur s'attend à trouver la prochaine fois |
$.store.get(key) se résout en la valeur ou undefined, et $.store.set(key, value) prend n'importe quelle valeur JSON.
Garder une valeur dans `$.state`
$.state contient des valeurs pour la durée d'une session, et il redessine pour vous. C'est un état réactif : un hook ui.render qui lit une valeur s'y abonne, donc Claude Code redessine ce site chaque fois que vous écrivez la valeur, et vous n'appelez pas $.ui.invalidate. Une valeur dans $.state survit aussi à un rechargement du module, ce qu'une variable ne fait pas.
Pour le configurer, déclarez vos valeurs, pointez votre manifeste vers la déclaration, puis définissez et utilisez chaque valeur. Les exemples déplacent le count de hello-tabs dans $.state.
Déclarer les valeurs
Déclarez les valeurs dans un fichier de types. La clé externe est le nom de votre plugin, et chaque entrée en dessous est une valeur et son type. Enregistrez ceci sous hello-tabs/types/index.d.ts :
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
Pointer le manifeste vers la déclaration
Pour laisser claude plugin validate vérifier votre code par rapport à ce fichier, ajoutez un champ types au manifeste avec son chemin :
{
"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"
}
Définir, lire et écrire une valeur
Dans votre module, définissez chaque valeur avec une valeur par défaut, lisez-la pendant le dessin, et écrivez-la à partir d'un callback. atom nomme une valeur et sa valeur par défaut, read la retourne, et update l'écrit. Les trois aides appellent $.state.get et $.state.set pour vous :
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)
Parce que le hook ui.render a lu count, Claude Code exécute le hook à nouveau chaque fois que le bouton l'écrit.
Trois règles s'appliquent au code :
- Écrivez
pluginetkeycomme des chaînes littérales :claude plugin validateles lit de votre source - Déclarez chaque valeur dans le fichier de types : sinon la validation échoue avec
hello-tabs.count is not declared - Écrivez à partir d'un callback ou du hook d'un autre événement : un hook
ui.renderpeut lire l'état et ne peut pas l'écrire, donc écrivez à partir deonPress,onSubmit, ou un hook pour un autre événement
Modifier `hello-tabs` pour utiliser `$.state`
Pour déplacer count dans hello-tabs dans $.state, modifiez chaque ligne qui l'utilise :
- En haut du module : ajoutez la ligne
import, et remplacezlet count = 0par la ligneatom - Dans le hook
ui.render: ajoutez la lignereadavanttabButton, et dessinez'Count: ' + ndans leText - Dans le bouton Add one : remplacez
onPresspar celui dans Enregistrer à partir de plus d'une session, qui enregistre le compte ainsi que l'écrit - Dans le hook
session.start: remplacez les deux lignes qui lisentsavedpar l'appelloadCountde Charger une valeur sauvegardée à nouveau après/clear
Gardez redraw pour les boutons d'onglets, car tab est toujours une variable.
Charger une valeur sauvegardée à nouveau après `/clear`
Si votre mod copie une valeur sauvegardée de $.store dans $.state à session.start, il doit la copier à nouveau après /clear, /resume, ou /branch. Ces commandes remettent chaque valeur $.state à sa valeur par défaut, et session.start ne se déclenche pas à nouveau. classic.SessionStart se déclenche après chacune d'elles, avec e.source défini sur clear, resume, ou fork, donc copiez la valeur à nouveau dans un hook dessus. Sinon votre dessin affiche la valeur par défaut, et un callback qui enregistre la valeur $.state écrit la valeur par défaut sur ce que vous avez stocké.
Ce code charge count à partir des deux hooks. Il s'appuie sur la version $.state de hello-tabs, où count est un atome et update est importé. Mettez loadCount au-dessus de register, et ajoutez l'appel loadCount au hook session.start que vous avez déjà. classic.SessionStart se déclenche aussi au démarrage et après compaction, ce qui ne réinitialise pas $.state, donc le filtre sur source garde le hook aux trois réinitialisations :
// 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)
})
Avec les deux hooks en place, le volet affiche le compte sauvegardé après /clear et non 0, et le prochain appui sur Add one ajoute au compte sauvegardé.
loadCount écrit la valeur stockée sur celle dans $.state, et session.start se déclenche à nouveau chaque fois que le module se recharge. Pour garder le magasin de prendre du retard, enregistrez à chaque changement, comme le bouton Add one le fait.
Pour vérifier le rechargement sans une session, testez le dessin après /clear.
Enregistrer à partir de plus d'une session
Chaque session sur votre machine qui exécute votre mod partage un $.store. Un get suivi d'un set n'est pas atomique. Quand deux sessions lisent chacune une valeur, la modifient et l'écrivent, elles font la course, et la deuxième écriture remplace la première.
Deux choix rendent cela moins probable :
- Donnez à chaque élément sa propre clé : un
setchange seulement sa propre clé, donc les sessions qui écrivent des clés différentes ne s'écrasent pas mutuellement - Lisez à nouveau juste avant d'écrire : pour une valeur que plusieurs sessions changent,
getla clé dans le callback et construisez la nouvelle valeur à partir de cela, pas à partir d'une copie que vous avez chargée àsession.start. L'écriture d'une autre session est toujours perdue si elle atterrit entre votregetet votreset.
Ce bouton ajoute un à ce que le magasin contient maintenant, puis met à jour le dessin :
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 une deuxième session a appuyé sur son propre bouton trois fois depuis le démarrage de cette session, cet appui affiche et enregistre un compte qui inclut ces trois.
Prochaines étapes
- Réagir aux événements : alimentez votre dessin à partir d'appels d'outils et de tours
- Utiliser l'API des mods : alimentez votre dessin à partir de minuteries et d'appels de modèle
- Tester un dessin : appuyez sur vos boutons à partir d'un test, sur plus d'une surface
- Sites de rendu et éléments : les props de chaque site et les props de chaque élément