Disegnare nell'interfaccia con un mod
Disegnare riquadri, una banda sopra il prompt, pulsanti e campi di testo da un mod di Claude Code, gestire pressioni e input, e mantenere lo stato tra i ridisegni e le sessioni.
Un mod può disegnare la propria interfaccia in Claude Code e modificare parti dell'interfaccia che Claude Code disegna già. Ogni luogo in cui un mod può disegnare è chiamato sito di rendering, come un riquadro, la banda sopra il prompt, o lo spinner. Claude Code genera l'evento ui.render ogni volta che sta per disegnare un sito di rendering, e il tuo hook per quell'evento restituisce cosa disegnare lì.
Questa mappa mostra dove un mod può disegnare in una sessione terminale:
In un terminale più stretto, il riquadro si trova sopra il prompt invece che accanto alla trascrizione.
Costruisci il tuo primo mod prima di iniziare qui. Inizia con l'esempio pratico, che costruisce un riquadro con due schede e un contatore, quindi leggi la sezione per ogni parte che desideri modificare.
Per cercare una proprietà o un limite, consulta il riferimento.
Costruire un riquadro con schede
In questa sezione costruisci un mod che aggiunge un comando /hello-tabs e il comando apre un riquadro. Un riquadro è una barra laterale accanto alla trascrizione in un terminale fullscreen ampio, o una regione incorniciata sopra il prompt altrimenti. Questo riquadro mostra due schede, e la seconda scheda ha un pulsante che aggiunge uno a un contatore. Il conteggio è ancora lì dopo aver riavviato Claude Code.
Il mod finito assomiglia a questo. La registrazione apre il riquadro, passa alla seconda scheda, preme il pulsante alcune volte e ritorna alla prima scheda:
Claude Code non ha un elemento schede integrato, quindi le schede sono due pulsanti in una riga. Il mod tiene traccia di quale è attivo e disegna il contenuto di quella scheda sotto la riga.
Creare il plugin
Un mod è un plugin con un manifesto, un hooks.json che punta al tuo codice, e il file di codice. Creare un mod spiega ognuno. Crea una directory denominata hello-tabs con directory .claude-plugin e hooks al suo interno, quindi salva i primi due file.
Salva il manifesto come 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" }
}
Nomina il tuo punto di ingresso in hello-tabs/hooks/hooks.json:
{
"modules": ["./register.js"]
}
Scrivere il codice
Il codice fa tre lavori, uno in ogni hook:
- Aggiunge il comando
/hello-tabs - Apre il riquadro quando esegui quel comando
- Disegna il contenuto del riquadro: la riga di schede e il corpo della scheda aperta
Due variabili a livello di modulo, tab e count, mantengono lo stato del riquadro.
Salva questo come hello-tabs/hooks/register.js:
// L'id del riquadro, utilizzato per aprire il riquadro e per riconoscerlo durante il disegno
const PANE = 'hello-tabs'
// Cosa mostra il riquadro: quale scheda è aperta e il valore del contatore
let tab = 'one'
let count = 0
export function register(on) {
// Viene eseguito prima del tuo primo prompt, e di nuovo dopo un ricaricamento
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// Carica il conteggio che una sessione precedente ha salvato, se ce n'è uno
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// Viene eseguito quando digiti /hello-tabs
on('command.run', { command: 'hello-tabs' }, async ($) => {
// Apri il riquadro, dagli la tastiera, e lascia che Esc lo chiuda
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// Non stampare nulla nella trascrizione
return {}
})
// Viene eseguito ogni volta che Claude Code disegna un riquadro
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Lascia in pace i riquadri degli altri mod
if (e.requestId !== PANE) return next(e)
// Ottieni gli elementi che questa app può disegnare
const { Box, Text, Button } = $.ui.resolve(e)
// Chiedi a Claude Code di eseguire di nuovo questo hook
const redraw = () => $.ui.invalidate('ui.render')
// Una scheda: un pulsante che passa a quella scheda quando viene premuto
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// Attenua la scheda che non è aperta
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// Cosa va sotto le schede, a seconda di quale è aperta
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()
// Salva il conteggio in modo che sia lì dopo un riavvio
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// L'intero riquadro: la riga di schede, una riga vuota, quindi il corpo
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}
Ogni hook fa anche qualcosa che il codice non rende evidente:
session.startlegge anche il conteggio salvato da$.store, un archivio chiave-valore che persiste tra le sessioni.command.rundice solo a Claude Code che il riquadro esiste. Aprire un riquadro non disegna nulla di per sé: Claude Code quindi generaui.renderper chiedere cosa metterci.ui.renderrestituisce l'albero degli elementi, unBoxche contiene altri box, testo e pulsanti, e lo costruisce di nuovo databecountogni volta che viene eseguito.
Premere un pulsante esegue il suo callback onPress, che cambia una variabile e chiama redraw. Claude Code quindi esegue di nuovo l'hook ui.render, e l'hook costruisce un nuovo albero dai nuovi valori. Ogni disegno interattivo utilizza quel ciclo di rendering: un callback cambia lo stato, e l'hook esegue il rendering di nuovo dal nuovo stato.
Aprire il riquadro
Nella tua shell, avvia Claude Code con claude --plugin-dir ./hello-tabs. Al prompt di Claude Code, esegui /hello-tabs. Un riquadro si apre con 1: One e 2: Two nella parte superiore. Premi 2, quindi premi a, la scorciatoia da tastiera per Add one, alcune volte. Il conteggio aumenta.
Verificare che il conteggio sia stato salvato
Premi Esc per chiudere il riquadro, quindi esci dalla sessione. Nella tua shell, avvia di nuovo Claude Code con lo stesso comando claude --plugin-dir ./hello-tabs, e al prompt di Claude Code esegui /hello-tabs. Il conteggio è dove l'hai lasciato.
Per cancellare il conteggio, fai in modo che il mod chiami $.store.delete('count'). Mantenere lo stato spiega quanto tempo dura ogni tipo di valore.
Scegliere dove disegnare
Un hook ui.render viene eseguito per ogni sito di rendering a meno che non lo restringi a quello che desideri disegnare. Per scegliere il sito di rendering, passa un filtro, chiamato matcher, come secondo argomento a on. { component: 'Pane' } esegue l'hook solo per i riquadri. Nell'hook, e.component nomina il sito, e.surface dice quale app sta disegnando, e e.props contiene i dati propri del sito. Per un riquadro, e.requestId è l'id con cui l'hai aperto.
Due siti sono vuoti finché un mod non li riempie, il riquadro e la banda. Seleziona una scheda per vedere cosa è ognuno e come disegnare in esso:
Un riquadro è una barra laterale accanto alla trascrizione in un terminale fullscreen ampio, o una regione incorniciata sopra il prompt altrimenti. Con più riquadri aperti, ognuno ottiene una scheda che mostra il suo titolo.
Un riquadro appare quando il tuo mod chiama $.ui.open con un id che scegli, come in $.ui.open({ id: 'hello-tabs' }). Aprire un riquadro al momento giusto copre gli altri campi e quando un riquadro aspetta un terminale più ampio.
Per disegnare nel tuo riquadro, filtra su { component: 'Pane' } e verifica che e.requestId sia il tuo id.
La banda è una striscia direttamente sopra l'input del prompt. È sempre lì, e ogni mod la condivide.
Il tuo hook restituisce un albero per mostrare qualcosa nella banda, o next(e) per non mostrare nulla. Un albero sostituisce quello che i mod dopo il tuo disegnano lì. Per mantenere il loro, metti il risultato di await next(e) tra i figli di un Box nel tuo albero.
Per disegnare nella banda, filtra su { component: 'AbovePrompt' }.
Modificare quello che Claude Code disegna già
Claude Code disegna la maggior parte della sua interfaccia da solo: messaggi, righe di chiamate di strumenti, lo spinner, e altro. Ognuna di quelle parti è un sito di rendering anche, quindi un mod può ridisegnarlo o sostituirlo. Per modificarne uno, filtra il tuo hook ui.render sul suo nome da questa tabella:
| Sito | Cosa è |
|---|---|
UserMessage, AssistantMessage |
Un messaggio nella trascrizione |
ToolUse, ToolResult, ToolGroup |
La riga di una chiamata di strumento, il suo risultato, e un'esecuzione piegata di chiamate |
CommandOutput |
La riga che un comando ha stampato |
AskUserQuestion |
La finestra di dialogo che Claude apre per farti una domanda |
Spinner, ToolProgress, TurnDuration |
Righe di stato per un turno: la riga che si anima mentre Claude lavora, la riga di progresso dal vivo di uno strumento in esecuzione, e la riga che chiude un turno |
InfoNotice, SessionMode, PromptHint |
Righe di stato sotto il logo, le etichette della modalità nel piè di pagina, e la riga di suggerimento sotto il prompt |
In un sito che Claude Code disegna già, il tuo hook ha tre scelte: modificare un dettaglio, sostituire il disegno, o lasciarlo in pace. Seleziona una scheda per vedere ognuno applicato allo spinner. Gli esempi leggono una variabile calls che un altro hook conta, come nel mod del tutorial.
Per mantenere il disegno di Claude Code e modificare una parte di esso, passa a next una copia dell'evento con props modificati. Questo hook cambia il testo dopo la parola dello spinner:
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Mantieni lo spinner di Claude Code, e cambia il testo dopo la sua parola
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
Lo spinner mantiene la sua animazione e la sua parola, e il tuo testo segue la parola:
Thinking · tool calls: 2…
Per disegnare qualcosa di tuo al posto del sito, restituisci un albero e non chiamare next. Questo hook disegna una riga di testo dove sarebbe lo spinner:
on('ui.render', { component: 'Spinner' }, async ($, e) => {
const { Text } = $.ui.resolve(e)
// Nessuna chiamata a next, quindi questa riga viene disegnata al posto dello spinner
return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
})
Mentre Claude lavora, la tua riga si mostra e lo spinner di Claude Code non lo fa:
Claude has made 2 tool calls
Per lasciare il sito come Claude Code lo disegna, restituisci next(e). Un hook spesso lo fa per alcuni eventi e non per altri. Questo hook lascia lo spinner in pace finché non c'è una chiamata da contare:
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Niente da mostrare ancora, quindi passa l'evento invariato
if (calls === 0) return next(e)
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
Prima della prima chiamata di strumento, lo spinner appare come farebbe senza il mod:
Thinking…
Il prompt di autorizzazione non è un sito di rendering, quindi un mod non può modificare quello che mostra. La finestra di dialogo della domanda, AskUserQuestion, è uno, quindi un mod può modificare quello.
Il terminale e l'app Desktop non generano tutti gli stessi siti. Pane, AbovePrompt, Spinner, e i siti della trascrizione funzionano in entrambi. Poche altre righe di stato vengono generate solo nel terminale. La tabella dei siti di rendering elenca dove viene generato ognuno.
Aprire un riquadro al momento giusto
Un riquadro appare solo quando il tuo mod lo apre. Come e quando lo apri decide se prende il focus della tastiera, quanto spazio chiede, e se appare affatto in un terminale stretto.
Per aprire un riquadro, chiama $.ui.open con un id che scegli. L'id è il nome del riquadro: il tuo hook ui.render lo controlla, e lo passi di nuovo per chiudere il riquadro.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
Per chiudere il riquadro, chiama $.ui.close con l'id con cui l'hai aperto:
await $.ui.close({ id: 'hello-tabs' })
Oltre a id, $.ui.open accetta questi campi opzionali:
| Campo | Cosa fa |
|---|---|
title |
L'etichetta della scheda del riquadro quando più di un riquadro è aperto |
focus |
Richiede il focus della tastiera |
closeOnEscape |
Fa chiudere il riquadro da Esc. Passa true o lascia il campo fuori, perché Claude Code rifiuta false. |
holdToasts |
Tiene i toast, i piccoli avvisi da $.ui.toast, finché il riquadro non si chiude |
rows |
L'altezza da chiedere quando il riquadro si trova sopra il prompt. L'impostazione predefinita è un terzo dello spazio. |
columns |
La larghezza da chiedere quando il riquadro si trova accanto alla trascrizione |
Per lasciare che un comando apra il riquadro mentre Claude sta lavorando, aggiungi immediate: true quando registri il comando. Senza di esso, un comando digitato durante un turno aspetta che il turno finisca.
Quando un riquadro aspetta un terminale più ampio
Un riquadro che il tuo mod apre senza essere chiesto non appare in un terminale stretto, quindi non può prendere il controllo di uno schermo piccolo. Se appare dipende da cosa l'ha aperto:
- Aperto da qualcosa che l'utente ha fatto, come un comando che ha eseguito o un pulsante che ha premuto, il riquadro appare a qualsiasi larghezza
- Aperto dal tuo mod che agisce da solo, come da un timer o un hook
turn.start, il riquadro appare solo in un terminale di almeno 144 colonne di larghezza. Dopo che l'utente ha aperto quel riquadro una volta da solo, 110 colonne sono sufficienti.
Quando il riquadro appare, $.ui.open si risolve in { isPlaced: true }. Quando il riquadro è in attesa, isPlaced è false e reason è una stringa che dice perché. Un riquadro in attesa appare quando l'utente lo apre o allarga il terminale. Per dire che qualcosa è disponibile senza aprire un riquadro, chiama $.ui.toast('Your message'), che mostra un piccolo avviso che scompare dopo pochi secondi.
Costruire un albero da elementi
Quello che un hook ui.render restituisce è un albero di elementi: una descrizione di cosa disegnare, fatta di box, testo e controlli annidati l'uno dentro l'altro. Descrivi il disegno, e Claude Code lo renderizza nel terminale o nell'app Desktop.
Per ottenere gli elementi, chiama $.ui.resolve(e) nel tuo hook, come in const { Box, Text, Button } = $.ui.resolve(e). Ogni elemento è una funzione. Passi le proprietà, e metti gli elementi e le stringhe che vanno dentro di esso in children.
La maggior parte dei disegni usa quattro elementi. Seleziona una scheda per vedere ognuno e come il terminale lo disegna:
Text disegna una stringa, con stile opzionale come bold e color:
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box arrangia quello che è dentro di esso, in una riga o una colonna. Questo mette un pulsante e una riga di testo uno accanto all'altro, due colonne a parte:
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
[ Add one ] Count: 0
Button è un controllo che l'utente può premere. Esegue il tuo callback onPress. Con plain: true non ha parentesi e mostra la sua scorciatoia da tastiera:
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 è un campo di testo. Esegue il tuo callback onSubmit con il testo quando l'utente preme Invio:
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
Questa tabella elenca ogni elemento:
| Elemento | Cosa disegna | Dove |
|---|---|---|
Box |
Un contenitore flex. Accetta proprietà di layout come flexDirection, columnGap, padding, borderStyle, e width. |
Ovunque |
Text |
Testo stilizzato. Accetta color, bold, dimColor, italic, e wrap. Un color è una chiave di tema o un colore come 'red'. Un wrap è 'wrap', 'truncate', 'truncate-start', 'truncate-middle', o 'truncate-end'. |
Ovunque |
Button |
Un controllo che chiama onPress |
Ovunque |
Link, Code, Markdown |
Un link con href e un label opzionale, un blocco di codice, e testo formattato come le risposte di Claude. Markdown accetta il suo contenuto in una proprietà text, non in children, e ha bisogno di una key quando passi onLinkPress. |
Ovunque |
Input, Select |
Un campo di testo e un selettore | Terminale, Desktop |
Svg |
Un documento SVG | Desktop |
Client |
Una regione disegnata da un secondo file tuo, per animazione e input del puntatore. Quel file non ottiene l'API dei mod. Raggiunge i tuoi hook solo postando dati, che arrivano come evento ui.message. |
Terminale, Desktop |
Raster, Image |
Una griglia di celle colorate, e un'immagine | Terminale |
Se il tuo modulo è un file .tsx o .jsx, puoi scrivere l'albero come JSX. Destruttura gli elementi da $.ui.resolve(e) per primo, perché un modulo di hook non ha globali di elementi.
Se un albero usa un elemento che l'app non ha, una proprietà che un elemento non accetta, o un figlio dove nessuno va, Claude Code disegna la sua versione del sito.
In una sessione avviata con --plugin-dir, una riga di trascrizione lo dice, come ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. Il log di debug lo registra come ui.render (Pane): a hook returned a tree that does not validate con la stessa ragione. Nient'altro appare nella sessione, quindi quando un disegno non si mostra, controlla quella riga o il log.
Disegnare una griglia di celle colorate
Per una mappa di calore, una sparkline, o una tavola di gioco nel terminale, disegna un Raster e non un Box per ogni cella. Un Raster accetta una key, la sua dimensione in columns e rows, e cells, che compatta ogni cella in una stringa. Ogni cella è tre numeri: il punto di codice del carattere, il suo colore, e il colore di sfondo. Un colore è un numero esadecimale con due cifre ciascuno per rosso, verde e blu, come 0xc62828 per un rosso, o 0x01000000 per il default del terminale.
L'app Desktop non ha Raster, quindi controlla e.surface e disegna testo lì. Questo corpo del riquadro disegna una mappa di calore tre per due:
// Il valore che significa "usa il colore predefinito del terminale"
const DEFAULT_COLOR = 0x01000000
// Compatta righe di coppie [carattere, colore] nella stringa che un Raster accetta
// Una cella è tre numeri: il punto di codice del carattere, il suo colore, e lo sfondo
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) => {
// Disegna solo nel riquadro aperto con l'id 'heat'
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// Due righe di tre celle, ognuna un carattere di blocco e il suo colore
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) })],
})
})
Nel terminale, il riquadro mostra la griglia:
L'array rows è la parte che cambieresti, e cellsOf lo trasforma nella stringa compatta. L'hook disegna solo in un riquadro il cui id è heat, quindi aprine uno con $.ui.open({ id: 'heat' }) da un comando, come l'esempio hello-tabs apre il suo riquadro.
Ogni carattere deve essere largo una cella. Per animare un Raster che è già sullo schermo, chiama $.ui.blit con l'id del riquadro come requestId, la key del Raster, la stessa dimensione, e celle nuove. Per questo esempio, è $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }). Ridipinge solo quell'elemento senza eseguire di nuovo il tuo hook ui.render.
Rispondere a pressioni e digitazione
Quando l'utente preme un pulsante, digita in un campo, o sceglie da un elenco che il tuo mod ha disegnato, Claude Code chiama la funzione che hai dato a quel controllo, e viene eseguita nel tuo modulo. Ogni controllo accetta i suoi callback:
Button: accettaonPress(e), dovee.surfaceè l'app da cui viene la pressioneInput: accettaonSubmit(value)eonInput(value)Select: accettaonSelect(value)con le sue scelte inoptions, un elenco di almeno una scelta con valori unici, come[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
Un test preme o digita in un controllo per la sua key, quindi dagli uno. Ogni uso di un controllo genera anche ui.press, ui.input, o ui.select con la key in e.element, e un altro mod può agganciare quegli eventi. Il suo hook viene eseguito prima del tuo callback, quindi vede quello che l'utente digita nel tuo Input e può cambiarlo o rispondere al posto del tuo callback. L'API dei mod non ha un metodo che preme il pulsante di un altro mod.
Focus della tastiera e scorciatoie da tastiera
Il tuo mod non legge mai la tastiera da solo. L'utente preme un tasto, Claude Code decide quale dei tuoi controlli è per esso, e il callback di quel controllo viene eseguito. A parte una scorciatoia da tastiera con cifra sulla banda, questo accade solo mentre il tuo riquadro o banda ha il focus della tastiera. Il resto del tempo, i tasti vanno al prompt.
Come un riquadro ottiene il focus della tastiera
Un riquadro ottiene il focus della tastiera in uno di tre modi:
- Il tuo mod lo apre con
focus: trueda un comando o una pressione - L'utente preme Ctrl+X poi Tab
- L'utente lo fa clic
Claude Code concede focus: true solo mentre il prompt è vuoto e nient'altro ha il focus della tastiera. Un riquadro che si apre mentre l'utente sta digitando non prende i suoi tasti.
Cosa fa ogni tasto
Questa tabella elenca cosa fa un tasto mentre il tuo riquadro o banda ha il focus della tastiera:
| Tasto | Cosa fa |
|---|---|
| Tab | Si sposta al controllo successivo |
| Su e Giù | Si spostano tra i controlli mentre il tuo disegno si adatta. Quando il riquadro o la banda ha più righe di quante possa mostrare, lo scorrono. |
| Invio | Preme il Button focalizzato, invia il Input focalizzato, o sceglie in un Select |
| La scorciatoia da tastiera di un pulsante | Preme quel pulsante. Mentre un Input ha il focus, ogni tasto stampabile va al campo. |
| Esc | Restituisce il focus della tastiera al prompt. Con closeOnEscape: true, chiude anche il riquadro. |
Un mod non può associare Tab o i tasti freccia a nient'altro, quindi un gioco si muove con w, a, s, e d.
Impostare una scorciatoia da tastiera e il primo focus
Due proprietà su un controllo decidono come la tastiera lo raggiunge:
hotkey: per lasciare che l'utente preme unButtoncon un tasto, dagli unahotkeydi una cifra o una lettera minuscola, come inhotkey: 'a'autoFocus: per scegliere quale controllo ha il focus quando il riquadro si apre, aggiungiautoFocus: truead esso. Lascia la proprietà fuori dagli altri, perché Claude Code rifiutaautoFocus: false.
Come una scorciatoia da tastiera si mostra dipende dal pulsante e dall'app:
| Pulsante | Nel terminale | Nell'app Desktop |
|---|---|---|
| Con parentesi, il default | [ Add one ], senza scorciatoia da tastiera mostrata |
L'etichetta con un piccolo tasto accanto |
Con plain: true |
1: One |
L'etichetta con un piccolo tasto accanto |
Nel terminale, nomina il tasto nell'etichetta di un pulsante tra parentesi, o usa plain: true, così l'utente può vedere cosa premere. Il riferimento degli elementi ha le altre regole di Button: action, scorciatoie da tastiera con cifra sulla banda, e due pulsanti su una scorciatoia da tastiera.
Prendere input digitato e disegnare una riga per ogni elemento
Molti riquadri sono un campo di testo con un elenco sotto. L'esempio in questa sezione è un riquadro di note: digiti una nota e premi Invio per aggiungerla, e ogni nota ha un pulsante x che la elimina. Con due note aggiunte, il terminale disegna il riquadro in questo modo:
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
L'esempio usa due tecniche:
- Prendere input digitato: un
InputchiamaonSubmit(value)con il testo del campo quando l'utente preme Invio, eonInput(value)ad ogni cambio - Disegnare un elenco: mappa i tuoi dati a una riga ciascuno, e dai a ogni pulsante della riga la sua
key
Questo hook disegna il contenuto del riquadro:
// L'elenco che il riquadro disegna
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Disegna solo nel riquadro aperto con l'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',
// Disegna il campo vuoto ogni volta, che lo cancella dopo un invio
value: '',
submitLabel: 'add',
autoFocus: true,
// Viene eseguito quando premi Invio nel campo
onSubmit: async (value) => {
// Ignora una riga vuota
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// Una riga per ogni nota: un pulsante di eliminazione, quindi il testo della nota
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// Una key propria, così il pulsante di ogni riga può essere distinto
key: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})
Per provare il riquadro:
- Aggiungi una nota: digita una riga e premi Invio. La riga appare come una nuova riga, e il campo si svuota.
- Elimina una nota: premi Tab finché il pulsante
xdella nota non ha il focus, quindi premi Invio. Laxè l'etichetta del pulsante e non una scorciatoia da tastiera, quindi digitare la lettera non lo preme.
Ogni cambio segue lo stesso ciclo di rendering di hello-tabs: il callback cambia notes, chiama redraw, e salva l'elenco in $.store.
Il campo si svuota dopo ogni invio a causa della sua proprietà value. value è il testo che il campo contiene quando viene disegnato, e la digitazione dell'utente lo sostituisce finché il tuo hook non disegna il campo di nuovo. L'esempio disegna sempre il campo con ''.
L'esempio salva le note e non le carica. Per riportarle nella sessione successiva, leggile in un hook session.start, come hello-tabs legge count.
Tre proprietà compongono la riga del campo, Note: Type a note and press Enter ⏎ add:
| Proprietà | Nell'esempio | Cosa è |
|---|---|---|
label |
Note |
Il testo prima del campo. Il terminale disegna : dopo di esso. |
placeholder |
Type a note and press Enter |
Testo attenuato che si mostra mentre il campo è vuoto |
submitLabel |
add |
La parola dopo ⏎ che dice cosa fa Invio |
Inviare un Input non avvia un turno a meno che il tuo callback non chiami $.prompt.submit.
Ridisegnare un sito
Un disegno è un'istantanea: mostra quello che il tuo hook ui.render ha restituito l'ultima volta che l'hook è stato eseguito. Per mostrare qualcosa di nuovo, l'hook deve essere eseguito di nuovo. Claude Code lo esegue di nuovo per alcuni cambiamenti, e il tuo mod chiede il resto.
Quando Claude Code ridisegna senza essere chiesto
Claude Code esegue di nuovo il tuo hook ui.render quando le proprietà del sito cambiano o la larghezza del terminale cambia. Non esegue l'hook su un timer, e non può dire quando una variabile nel tuo modulo cambia.
Ridisegnare quando i tuoi dati cambiano
Per avere i tuoi siti disegnati di nuovo dopo che i tuoi dati cambiano, chiama $.ui.invalidate('ui.render'). Questo riquadro conta le pressioni. Il callback del pulsante cambia count, quindi chiede un ridisegno:
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
// I dati sono cambiati, quindi chiedi a Claude Code di disegnare il riquadro di nuovo
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})
Ogni pressione alza il numero nel riquadro. L'esempio hello-tabs avvolge la stessa chiamata nella sua funzione redraw.
Un valore che mantieni in $.state non ha bisogno della chiamata, perché scrivere il valore ridisegna i siti che lo leggono.
Ridisegnare su un timer
Per mantenere un orologio, un conto alla rovescia, o un valore da fuori la sessione attuale, ridisegna su un programma. Avvia un timer nell'hook session.start del modulo. Se il modulo ne ha già uno, come hello-tabs, aggiungi la riga $.clock.every ad esso:
on('session.start', async ($, e, next) => {
// Ogni 1000 millisecondi, chiedi a Claude Code di disegnare di nuovo i tuoi siti
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
Claude Code ora esegue il tuo hook ui.render una volta al secondo. Il timer si ferma quando il modulo si ricarica, e la nuova copia del modulo avvia il suo.
Quanto spesso un sito può ridisegnare
Claude Code limita quanto spesso ridisegna un sito, quindi il tuo mod può chiamare $.ui.invalidate quanto spesso i suoi dati cambiano. Il riquadro visibile e la banda hanno un limite più alto rispetto ad altri siti, e la tabella dei limiti contiene i numeri.
Le chiamate che arrivano più velocemente del limite vengono combinate in un ridisegno. Quel ridisegno esegue il tuo hook una volta, e l'hook legge i tuoi dati come sono in quel momento, quindi il valore più recente si mostra e i valori in mezzo no. Un'animazione non può essere eseguita più velocemente del limite.
Mantenere lo stato
Un mod ha tre posti per mantenere un valore, e differiscono in quanto tempo il valore dura: finché il modulo non si ricarica, finché la sessione non finisce, o da una sessione all'altra. Scegli in base a quanto tempo il valore deve durare:
| Mantienilo in | Dura fino a | Usalo per |
|---|---|---|
| Una variabile a livello di modulo | Il modulo si ricarica, che accade ogni volta che salvi un file durante lo sviluppo | Valori che puoi perdere, come tab è in hello-tabs |
$.state |
La sessione finisce, o l'utente esegue /clear, /resume, o /branch |
Valori su cui un disegno dipende che dovrebbero sopravvivere a un ricaricamento |
$.store |
Il tuo mod lo elimina, o nessuna sessione legge o scrive lo store per cleanupPeriodDays. Lo store è un archivio chiave-valore, salvato come file JSON del tuo plugin sotto ~/.claude/plugins/store/. |
Impostazioni, cronologia, qualsiasi cosa l'utente si aspetta di trovare la prossima volta |
$.store.get(key) si risolve nel valore o undefined, e $.store.set(key, value) accetta qualsiasi valore JSON.
Mantenere un valore in `$.state`
$.state contiene valori per la durata di una sessione, e ridisegna per te. È uno stato reattivo: un hook ui.render che legge un valore si iscrive ad esso, quindi Claude Code ridisegna quel sito ogni volta che scrivi il valore, e non chiami $.ui.invalidate. Un valore in $.state sopravvive anche a un ricaricamento del modulo, che una variabile non fa.
Per configurarlo, dichiara i tuoi valori, punta il tuo manifesto alla dichiarazione, quindi definisci e usa ogni valore. Gli esempi spostano il count da hello-tabs in $.state.
Dichiarare i valori
Dichiara i valori in un file di tipi. La chiave esterna è il nome del tuo plugin, e ogni voce sotto di esso è un valore e il suo tipo. Salva questo come hello-tabs/types/index.d.ts:
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
Puntare il manifesto alla dichiarazione
Per lasciare che claude plugin validate controlli il tuo codice contro quel file, aggiungi un campo types al manifesto con il suo percorso:
{
"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"
}
Definire, leggere e scrivere un valore
Nel tuo modulo, definisci ogni valore con un default, leggilo mentre disegni, e scrivilo da un callback. atom nomina un valore e il suo default, read lo restituisce, e update lo scrive. I tre helper chiamano $.state.get e $.state.set per te:
import { atom, read, update } from 'claude-code'
// In cima al modulo: nomina il valore e dai il suo default
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// Nell'hook ui.render: leggi il valore per disegnarlo
const n = await read($, count)
// In un Button: scrivi un nuovo valore dal vecchio
onPress: () => update($, count, (value) => value + 1)
Perché l'hook ui.render ha letto count, Claude Code esegue l'hook di nuovo ogni volta che il pulsante lo scrive.
Tre regole si applicano al codice:
- Scrivi
pluginekeycome stringhe letterali:claude plugin validatele legge dal tuo sorgente - Dichiara ogni valore nel file di tipi: altrimenti la validazione fallisce con
hello-tabs.count is not declared - Scrivi da un callback o dall'hook di un altro evento: un hook
ui.renderpuò leggere lo stato e non può scriverlo, quindi scrivi daonPress,onSubmit, o un hook per un altro evento
Cambiare `hello-tabs` per usare `$.state`
Per spostare count in hello-tabs in $.state, cambia ogni riga che lo usa:
- In cima al modulo: aggiungi la riga
import, e sostituiscilet count = 0con la rigaatom - Nell'hook
ui.render: aggiungi la rigareadprima ditabButton, e disegna'Count: ' + nnelText - Nel pulsante Add one: sostituisci
onPresscon quello in Salvare da più di una sessione, che salva il conteggio così come lo scrive - Nell'hook
session.start: sostituisci le due righe che leggonosavedcon la chiamataloadCountda Caricare un valore salvato di nuovo dopo/clear
Mantieni redraw per i pulsanti delle schede, perché tab è ancora una variabile.
Caricare un valore salvato di nuovo dopo `/clear`
Se il tuo mod copia un valore salvato da $.store in $.state a session.start, deve copiarlo di nuovo dopo /clear, /resume, o /branch. Questi comandi rimettono ogni valore $.state al suo default, e session.start non viene generato di nuovo. classic.SessionStart viene generato dopo ognuno di essi, con e.source impostato a clear, resume, o fork, quindi copia il valore di nuovo in un hook su di esso. Altrimenti il tuo disegno mostra il default, e un callback che salva il valore $.state scrive il default su quello che hai archiviato.
Questo codice carica count da entrambi gli hook. Si basa sulla versione $.state di hello-tabs, dove count è un atom e update è importato. Metti loadCount sopra register, e aggiungi la chiamata loadCount all'hook session.start che hai già. classic.SessionStart viene generato anche all'avvio e dopo la compattazione, che non ripristina $.state, quindi il filtro su source mantiene l'hook ai tre ripristini:
// Copia il conteggio salvato da $.store in $.state, o 0 se nulla è salvato
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// Viene eseguito prima del tuo primo prompt, e di nuovo dopo un ricaricamento
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// Viene eseguito di nuovo dopo /clear, /resume, e /branch, che riporta fork
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
await loadCount($)
return next(e)
})
Con entrambi gli hook in posizione, il riquadro mostra il conteggio salvato dopo /clear e non 0, e la prossima pressione di Add one aggiunge al conteggio salvato.
loadCount scrive il valore archiviato su quello in $.state, e session.start viene generato di nuovo ogni volta che il modulo si ricarica. Per mantenere lo store dal rimanere indietro, salva ad ogni cambio, come il pulsante Add one fa.
Per controllare il ricaricamento senza una sessione, testa il disegno dopo /clear.
Salvare da più di una sessione
Ogni sessione sulla tua macchina che esegue il tuo mod condivide uno $.store. Un get seguito da un set non è atomico. Quando due sessioni leggono ciascuna un valore, lo cambiano, e lo scrivono di nuovo, corrono, e la seconda scrittura sostituisce la prima.
Due scelte lo rendono meno probabile:
- Dai a ogni elemento la sua chiave: un
setcambia solo la sua chiave, quindi le sessioni che scrivono chiavi diverse non si sovrascrivono a vicenda - Leggi di nuovo subito prima di scrivere: per un valore che più sessioni cambiano,
getla chiave nel callback e costruisci il nuovo valore da quello, non da una copia che hai caricato asession.start. Un'altra scrittura della sessione è ancora persa se atterra tra il tuogete il tuoset.
Questo pulsante aggiunge uno a quello che lo store contiene ora, quindi aggiorna il disegno:
onPress: async () => {
// Leggi quello che lo store contiene ora, che un'altra sessione potrebbe aver cambiato
const saved = Number((await $.store.get('count')) ?? 0)
// Salva il nuovo conteggio, quindi mostralo
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
Se una seconda sessione ha premuto il suo pulsante tre volte da quando questa sessione è iniziata, questa pressione mostra e salva un conteggio che include quei tre.
Prossimi passi
- Reagire agli eventi: alimenta il tuo disegno da chiamate di strumenti e turni
- Usare l'API dei mod: alimenta il tuo disegno da timer e chiamate di modello
- Testare un disegno: premi i tuoi pulsanti da un test, su più di una superficie
- Siti di rendering e elementi: le proprietà di ogni sito e le proprietà di ogni elemento