Desenhar na interface com um mod
Desenhe painéis, uma faixa acima do prompt, botões e campos de texto a partir de um mod Claude Code, manipule pressionamentos e entrada, e mantenha o estado entre redesenhos e sessões.
Um mod pode desenhar sua própria interface no Claude Code e alterar partes da interface que o Claude Code já desenha. Cada lugar onde um mod pode desenhar é chamado de site de renderização, como um painel, a faixa acima do prompt ou o spinner. O Claude Code dispara o evento ui.render cada vez que está prestes a desenhar um site de renderização, e seu hook para esse evento retorna o que desenhar lá.
Este mapa mostra onde um mod pode desenhar em uma sessão de terminal:
Em um terminal mais estreito, o painel fica acima do prompt em vez de ao lado da transcrição.
Construa seu primeiro mod antes de começar aqui. Comece com o exemplo trabalhado, que constrói um painel com duas abas e um contador, depois leia a seção para cada parte que você deseja alterar.
Para procurar uma propriedade ou limite, consulte a referência.
Construir um painel com abas
Nesta seção você constrói um mod que adiciona um comando /hello-tabs e o comando abre um painel. Um painel é uma barra lateral ao lado da transcrição em um terminal fullscreen amplo, ou uma região enquadrada acima do prompt caso contrário. Este painel mostra duas abas, e a segunda aba tem um botão que adiciona um ao contador. A contagem ainda está lá depois que você reinicia o Claude Code.
O mod finalizado se parece com isto. A gravação abre o painel, muda para a segunda aba, pressiona o botão algumas vezes e retorna à primeira aba:
O Claude Code não tem um elemento de abas integrado, então as abas são dois botões em uma linha. O mod acompanha qual está ativo e desenha o conteúdo dessa aba sob a linha.
Criar o plugin
Um mod é um plugin com um manifesto, um hooks.json que aponta para seu código, e o arquivo de código. Criar um mod explica cada um. Crie um diretório chamado hello-tabs com diretórios .claude-plugin e hooks dentro dele, depois salve os dois primeiros arquivos.
Salve o manifesto 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" }
}
Nomeie seu ponto de entrada em hello-tabs/hooks/hooks.json:
{
"modules": ["./register.js"]
}
Escrever o código
O código faz três trabalhos, um em cada hook:
- Adiciona o comando
/hello-tabs - Abre o painel quando você executa esse comando
- Desenha o conteúdo do painel: a linha de abas e o corpo da aba aberta
Duas variáveis no nível do módulo, tab e count, mantêm o estado do painel.
Salve isto 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 também faz algo que o código não deixa claro:
session.starttambém lê a contagem salva de$.store, um armazenamento de chave-valor que persiste entre sessões.command.runapenas diz ao Claude Code que o painel existe. Abrir um painel não desenha nada por si só: o Claude Code então disparaui.renderpara perguntar o que colocar nele.ui.renderretorna a árvore de elementos, umaBoxque contém outras caixas, texto e botões, e a constrói novamente a partir detabecountcada vez que é executada.
Pressionar um botão executa seu callback onPress, que altera uma variável e chama redraw. O Claude Code então executa o hook ui.render novamente, e o hook constrói uma nova árvore a partir dos novos valores. Cada desenho interativo usa esse ciclo de renderização: um callback altera o estado e o hook renderiza novamente a partir do novo estado.
Abrir o painel
Em seu shell, inicie o Claude Code com claude --plugin-dir ./hello-tabs. No prompt Claude Code, execute /hello-tabs. Um painel abre com 1: One e 2: Two na parte superior. Pressione 2, depois pressione a, o atalho de teclado para Add one, algumas vezes. A contagem sobe.
Verificar se a contagem foi salva
Pressione Esc para fechar o painel, depois saia da sessão. Em seu shell, inicie o Claude Code novamente com o mesmo comando claude --plugin-dir ./hello-tabs e no prompt Claude Code execute /hello-tabs. A contagem está onde você a deixou.
Para limpar a contagem, faça o mod chamar $.store.delete('count'). Manter estado cobre quanto tempo cada tipo de valor dura.
Escolher onde desenhar
Um hook ui.render é executado para cada site de renderização a menos que você o restrinja ao que deseja desenhar. Para escolher o site de renderização, passe um filtro, chamado de matcher, como o segundo argumento para on. { component: 'Pane' } executa o hook apenas para painéis. No hook, e.component nomeia o site, e.surface diz qual app está desenhando, e e.props contém os dados do próprio site. Para um painel, e.requestId é o id que você abriu com.
Dois sites estão vazios até um mod preenchê-los, o painel e a faixa. Selecione uma aba para ver o que cada um é e como desenhar nele:
Um painel é uma barra lateral ao lado da transcrição em um terminal fullscreen amplo, ou uma região enquadrada acima do prompt caso contrário. Com vários painéis abertos, cada um recebe uma aba que mostra seu título.
Um painel aparece quando seu mod chama $.ui.open com um id que você escolhe, como em $.ui.open({ id: 'hello-tabs' }). Abrir um painel no momento certo cobre os outros campos e quando um painel espera por um terminal mais amplo.
Para desenhar em seu painel, filtre em { component: 'Pane' } e verifique se e.requestId é seu id.
A faixa é uma tira diretamente acima da entrada do prompt. Ela está sempre lá, e cada mod a compartilha.
Seu hook retorna uma árvore para mostrar algo na faixa, ou next(e) para não mostrar nada. Uma árvore substitui o que os mods depois do seu desenham lá. Para manter o deles, coloque o resultado de await next(e) entre os filhos de uma Box em sua árvore.
Para desenhar na faixa, filtre em { component: 'AbovePrompt' }.
Alterar o que o Claude Code já desenha
O Claude Code desenha a maior parte de sua interface por si só: mensagens, linhas de chamada de ferramenta, o spinner e muito mais. Cada uma dessas partes é um site de renderização também, então um mod pode restylar ou substituí-la. Para alterar uma, filtre seu hook ui.render em seu nome desta tabela:
| Site | O que é |
|---|---|
UserMessage, AssistantMessage |
Uma mensagem na transcrição |
ToolUse, ToolResult, ToolGroup |
A linha de uma chamada de ferramenta, seu resultado e uma execução dobrada de chamadas |
CommandOutput |
A linha que um comando imprimiu |
AskUserQuestion |
O diálogo que o Claude abre para fazer uma pergunta a você |
Spinner, ToolProgress, TurnDuration |
Linhas de status para uma volta: a linha que anima enquanto o Claude trabalha, a linha de progresso ao vivo de uma ferramenta em execução e a linha que fecha uma volta |
InfoNotice, SessionMode, PromptHint |
Linhas de status sob o logo, os rótulos de modo no rodapé e a linha de dica sob o prompt |
Em um site que o Claude Code já desenha, seu hook tem três escolhas: alterar um detalhe, substituir o desenho ou deixá-lo em paz. Selecione uma aba para ver cada um aplicado ao spinner. Os exemplos leem uma variável calls que outro hook conta, como no mod do tutorial.
Para manter o desenho do Claude Code e alterar uma parte dele, passe para next uma cópia do evento com props alteradas. Este hook altera o texto após a palavra do 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 + '…' } })
})
O spinner mantém sua animação e sua palavra, e seu texto segue a palavra:
Thinking · tool calls: 2…
Para desenhar algo do seu próprio no lugar do site, retorne uma árvore e não chame next. Este hook desenha uma linha de texto onde o spinner seria:
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'] })
})
Enquanto o Claude trabalha, sua linha mostra e o spinner do Claude Code não:
Claude has made 2 tool calls
Para deixar o site como o Claude Code o desenha, retorne next(e). Um hook frequentemente faz isso para alguns eventos e não para outros. Este hook deixa o spinner em paz até haver uma chamada 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 da primeira chamada de ferramenta, o spinner se parece com a forma como é sem o mod:
Thinking…
O prompt de permissão não é um site de renderização, então um mod não pode alterar o que mostra. O diálogo de pergunta, AskUserQuestion, é um, então um mod pode alterar isso.
O terminal e o app Desktop não disparam todos os mesmos sites. Pane, AbovePrompt, Spinner e os sites de transcrição funcionam em ambos. Algumas outras linhas de status são disparadas apenas no terminal. A tabela de sites de renderização lista onde cada um é disparado.
Abrir um painel no momento certo
Um painel aparece apenas quando seu mod o abre. Como e quando você o abre decide se ele toma o foco do teclado, quanto espaço ele pede e se aparece em um terminal estreito.
Para abrir um painel, chame $.ui.open com um id que você escolhe. O id é o nome do painel: seu hook ui.render verifica, e você o passa novamente para fechar o painel.
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
Para fechar o painel, chame $.ui.close com o id que você abriu com:
await $.ui.close({ id: 'hello-tabs' })
Além de id, $.ui.open leva estes campos opcionais:
| Campo | O que faz |
|---|---|
title |
O rótulo da aba do painel quando mais de um painel está aberto |
focus |
Solicita foco do teclado |
closeOnEscape |
Faz Esc fechar o painel. Passe true ou deixe o campo de fora, porque o Claude Code recusa false. |
holdToasts |
Mantém toasts, os pequenos avisos de $.ui.toast, até o painel fechar |
rows |
A altura para pedir quando o painel fica acima do prompt. O padrão é um terço do espaço. |
columns |
A largura para pedir quando o painel fica ao lado da transcrição |
Para deixar um comando abrir o painel enquanto o Claude está trabalhando, adicione immediate: true quando você registra o comando. Sem isso, um comando digitado durante uma volta espera a volta terminar.
Quando um painel espera por um terminal mais amplo
Um painel que seu mod abre sem ser solicitado não aparece em um terminal estreito, então não pode assumir uma tela pequena. Se aparece depende do que o abriu:
- Aberto por algo que o usuário fez, como um comando que executou ou um botão que pressionou, o painel aparece em qualquer largura
- Aberto por seu mod agindo por si só, como de um timer ou um hook
turn.start, o painel aparece apenas em um terminal com pelo menos 144 colunas de largura. Depois que o usuário abriu esse painel uma vez por si só, 110 colunas é suficiente.
Quando o painel aparece, $.ui.open resolve para { isPlaced: true }. Quando o painel está esperando, isPlaced é false e reason é uma string que diz por quê. Um painel esperando aparece quando o usuário o abre ou amplia o terminal. Para dizer que algo está disponível sem abrir um painel, chame $.ui.toast('Your message'), que mostra um pequeno aviso que desaparece após alguns segundos.
Construir uma árvore a partir de elementos
O que um hook ui.render retorna é uma árvore de elementos: uma descrição do que desenhar, feita de caixas, texto e controles aninhados um dentro do outro. Você descreve o desenho, e o Claude Code o renderiza no terminal ou no app Desktop.
Para obter os elementos, chame $.ui.resolve(e) em seu hook, como em const { Box, Text, Button } = $.ui.resolve(e). Cada elemento é uma função. Você passa propriedades para ela, e coloca os elementos e strings que vão dentro dela em children.
A maioria dos desenhos usa quatro elementos. Selecione uma aba para ver cada um e como o terminal o desenha:
Text desenha uma string, com estilo opcional como bold e color:
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box organiza o que está dentro dela, em uma linha ou uma coluna. Esta coloca um botão e uma linha de texto lado a lado, duas colunas separadas:
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
[ Add one ] Count: 0
Button é um controle que o usuário pode pressionar. Ele executa seu callback onPress. Com plain: true não tem colchetes e mostra seu atalho 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 é um campo de texto. Ele executa seu callback onSubmit com o texto quando o usuário pressiona 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 tabela lista cada elemento:
| Elemento | O que desenha | Onde |
|---|---|---|
Box |
Um contêiner flex. Leva propriedades de layout como flexDirection, columnGap, padding, borderStyle e width. |
Em todos os lugares |
Text |
Texto estilizado. Leva color, bold, dimColor, italic e wrap. Uma color é uma chave de tema ou uma cor como 'red'. Um wrap é 'wrap', 'truncate', 'truncate-start', 'truncate-middle' ou 'truncate-end'. |
Em todos os lugares |
Button |
Um controle que chama onPress |
Em todos os lugares |
Link, Code, Markdown |
Um link com href e um label opcional, um bloco de código e texto formatado da forma que as respostas do Claude são. Markdown leva seu conteúdo em uma propriedade text, não em children, e precisa de uma key quando você passa onLinkPress. |
Em todos os lugares |
Input, Select |
Um campo de texto e um seletor | Terminal, Desktop |
Svg |
Um documento SVG | Desktop |
Client |
Uma região desenhada por um segundo arquivo seu, para animação e entrada de ponteiro. Esse arquivo não recebe API de mods. Ele alcança seus hooks apenas postando dados, que chegam como um evento ui.message. |
Terminal, Desktop |
Raster, Image |
Uma grade de células coloridas e uma imagem | Terminal |
Se seu módulo é um arquivo .tsx ou .jsx, você pode escrever a árvore como JSX. Desestruture os elementos de $.ui.resolve(e) primeiro, porque um módulo de hooks não tem globais de elementos.
Se uma árvore usa um elemento que o app não tem, uma propriedade que um elemento não leva, ou um filho onde nenhum vai, o Claude Code desenha sua própria versão do site.
Em uma sessão iniciada com --plugin-dir, uma linha de transcrição diz assim, como ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own. O log de depuração registra como ui.render (Pane): a hook returned a tree that does not validate com a mesma razão. Nada mais aparece na sessão, então quando um desenho não aparece, verifique essa linha ou o log.
Desenhar uma grade de células coloridas
Para um mapa de calor, um sparkline ou um tabuleiro de jogo no terminal, desenhe um Raster e não uma Box para cada célula. Um Raster leva uma key, seu tamanho em columns e rows, e cells, que empacota cada célula em uma string. Cada célula é três números: o ponto de código do caractere, sua cor e sua cor de fundo. Uma cor é um número hexadecimal com dois dígitos cada para vermelho, verde e azul, como 0xc62828 para um vermelho, ou 0x01000000 para o padrão do terminal.
O app Desktop não tem Raster, então verifique e.surface e desenhe texto lá. Este corpo de painel desenha um mapa de calor de três por dois:
// 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) })],
})
})
No terminal, o painel mostra a grade:
O array rows é a parte que você alteraria, e cellsOf a transforma na string empacotada. O hook desenha apenas em um painel cujo id é heat, então abra um com $.ui.open({ id: 'heat' }) de um comando, como o exemplo hello-tabs abre seu painel.
Cada caractere tem que ser uma célula de largura. Para animar um Raster que já está na tela, chame $.ui.blit com o id do painel como requestId, a key do Raster, o mesmo tamanho e novas células. Para este exemplo, é $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }). Ele repinta apenas esse elemento sem executar seu hook ui.render novamente.
Responder a pressionamentos e digitação
Quando o usuário pressiona um botão, digita em um campo ou escolhe de uma lista que seu mod desenhou, o Claude Code chama a função que você deu a esse controle, e ela é executada em seu módulo. Cada controle leva seus próprios callbacks:
Button: levaonPress(e), ondee.surfaceé o app de onde veio o pressionamentoInput: levaonSubmit(value)eonInput(value)Select: levaonSelect(value)com suas escolhas emoptions, uma lista de pelo menos uma escolha com valores únicos, como[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
Um teste pressiona ou digita em um controle por sua key, então dê a cada um uma. Cada uso de um controle também dispara ui.press, ui.input ou ui.select com a key em e.element, e outro mod pode fazer hook nesses eventos. Seu hook é executado antes de seu callback, então vê o que o usuário digita em seu Input e pode alterá-lo ou responder no lugar de seu callback. A API de mods não tem método que pressione o botão de outro mod.
Foco do teclado e atalhos de teclado
Seu mod nunca lê o teclado por si só. O usuário pressiona uma tecla, o Claude Code decide qual de seus controles é para, e o callback desse controle é executado. Além de um atalho de teclado de dígito na faixa, isso acontece apenas enquanto seu painel ou faixa tem foco do teclado. O resto do tempo, as teclas vão para o prompt.
Como um painel obtém foco do teclado
Um painel obtém foco do teclado de uma de três maneiras:
- Seu mod o abre com
focus: truede um comando ou um pressionamento - O usuário pressiona Ctrl+X depois Tab
- O usuário clica nele
O Claude Code concede focus: true apenas enquanto o prompt está vazio e nada mais tem foco do teclado. Um painel que abre enquanto o usuário está digitando não toma seus pressionamentos de tecla.
O que cada tecla faz
Esta tabela lista o que uma tecla faz enquanto seu painel ou faixa tem foco do teclado:
| Tecla | O que faz |
|---|---|
| Tab | Move para o próximo controle |
| Para cima e Para baixo | Movem entre controles enquanto seu desenho cabe. Quando o painel ou faixa tem mais linhas do que pode mostrar, eles o rolam. |
| Enter | Pressiona o Button focado, envia o Input focado ou escolhe em um Select |
| Atalho de teclado de um botão | Pressiona esse botão. Enquanto um Input tem o foco, cada tecla imprimível vai para o campo. |
| Esc | Retorna o foco do teclado para o prompt. Com closeOnEscape: true, também fecha o painel. |
Um mod não pode vincular Tab ou as setas para nada mais, então um jogo direciona com w, a, s e d.
Definir um atalho de teclado e o primeiro foco
Duas propriedades em um controle decidem como o teclado o alcança:
hotkey: para deixar o usuário pressionar umButtoncom uma tecla, dê a ele umhotkeyde um dígito ou uma letra minúscula, como emhotkey: 'a'autoFocus: para escolher qual controle tem o foco quando o painel abre, adicioneautoFocus: truea ele. Deixe a propriedade de fora dos outros, porque o Claude Code recusaautoFocus: false.
Como um atalho de teclado mostra depende do botão e do app:
| Botão | No terminal | No app Desktop |
|---|---|---|
| Com colchetes, o padrão | [ Add one ], sem atalho de teclado mostrado |
O rótulo com uma pequena tecla ao lado |
Com plain: true |
1: One |
O rótulo com uma pequena tecla ao lado |
No terminal, nomeie a tecla no rótulo de um botão entre colchetes, ou use plain: true, para que o usuário possa ver o que pressionar. A referência de elementos tem as outras regras de Button: action, atalhos de teclado de dígito na faixa e dois botões em um atalho de teclado.
Tomar entrada digitada e desenhar uma linha para cada item
Muitos painéis são um campo de texto com uma lista sob ele. O exemplo nesta seção é um painel de notas: você digita uma nota e pressiona Enter para adicioná-la, e cada nota tem um botão x que a deleta. Com duas notas adicionadas, o terminal desenha o painel desta forma:
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
O exemplo usa duas técnicas:
- Tomar entrada digitada: um
InputchamaonSubmit(value)com o texto do campo quando o usuário pressiona Enter, eonInput(value)em cada mudança - Desenhar uma lista: mapeie seus dados para uma linha cada, e dê a cada botão de linha sua própria
key
Este hook desenha o conteúdo do painel:
// 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 tentar o painel:
- Adicionar uma nota: digite uma linha e pressione Enter. A linha aparece como uma nova linha e o campo esvazia.
- Deletar uma nota: pressione Tab até o botão
xda nota ter o foco, depois pressione Enter. Oxé o rótulo do botão e não um atalho de teclado, então digitar a letra não o pressiona.
Cada mudança segue o mesmo ciclo de renderização que hello-tabs: o callback altera notes, chama redraw e salva a lista em $.store.
O campo esvazia após cada envio por causa de sua propriedade value. value é o texto que o campo contém quando é desenhado, e a digitação do usuário o substitui até seu hook desenhar o campo novamente. O exemplo sempre desenha o campo com ''.
O exemplo salva as notas e não as carrega. Para trazê-las de volta na próxima sessão, leia-as em um hook session.start, da forma que hello-tabs lê count.
Três propriedades compõem a linha do campo, Note: Type a note and press Enter ⏎ add:
| Propriedade | No exemplo | O que é |
|---|---|---|
label |
Note |
O texto antes do campo. O terminal desenha : após ele. |
placeholder |
Type a note and press Enter |
Texto fraco que mostra enquanto o campo está vazio |
submitLabel |
add |
A palavra após ⏎ que diz o que Enter faz |
Enviar um Input não inicia uma volta a menos que seu callback chame $.prompt.submit.
Redesenhar um site
Um desenho é um instantâneo: mostra o que seu hook ui.render retornou a última vez que o hook foi executado. Para mostrar algo novo, o hook tem que ser executado novamente. O Claude Code o executa novamente para algumas mudanças, e seu mod pede o resto.
Quando o Claude Code redesenha sem ser solicitado
O Claude Code executa seu hook ui.render novamente quando as propriedades do site mudam ou a largura do terminal muda. Ele não executa o hook em um timer, e não pode dizer quando uma variável em seu módulo muda.
Redesenhar quando seus dados mudam
Para ter seus sites desenhados novamente após suas próprias mudanças de dados, chame $.ui.invalidate('ui.render'). Este painel conta pressionamentos. O callback do botão altera count, depois pede um redesenho:
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 pressionamento levanta o número no painel. O exemplo hello-tabs envolve a mesma chamada em sua função redraw.
Um valor que você mantém em $.state não precisa da chamada, porque escrever o valor redesenha os sites que o leem.
Redesenhar em um timer
Para manter um relógio, uma contagem regressiva ou um valor de fora da sessão atual, redesenhe em um cronograma. Inicie um timer no hook session.start do módulo. Se o módulo já tiver um, como hello-tabs tem, adicione a linha $.clock.every a ele:
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)
})
O Claude Code agora executa seu hook ui.render uma vez por segundo. O timer para quando o módulo recarrega, e a nova cópia do módulo inicia o seu próprio.
Com que frequência um site pode redesenhar
O Claude Code limita com que frequência redesenha um site, então seu mod pode chamar $.ui.invalidate com a frequência que seus dados mudam. O painel visível e a faixa têm um limite mais alto do que outros sites, e a tabela de limites tem os números.
Chamadas que vêm mais rápido que o limite são combinadas em um redesenho. Esse redesenho executa seu hook uma vez, e o hook lê seus dados como estão naquele momento, então o valor mais recente mostra e os valores no meio não. Uma animação não pode ser executada mais rápido que o limite.
Manter estado
Um mod tem três lugares para manter um valor, e diferem em quanto tempo o valor dura: até o módulo recarregar, até a sessão terminar ou de uma sessão para a próxima. Escolha por quanto tempo o valor tem que durar:
| Mantê-lo em | Dura até | Use para |
|---|---|---|
| Uma variável no nível do módulo | O módulo recarrega, o que acontece toda vez que você salva um arquivo durante o desenvolvimento | Valores que você pode perder, como tab é em hello-tabs |
$.state |
A sessão termina, ou o usuário executa /clear, /resume ou /branch |
Valores que um desenho depende que devem sobreviver a um recarregamento |
$.store |
Seu mod o deleta, ou nenhuma sessão lê ou escreve o armazenamento por cleanupPeriodDays. O armazenamento é um armazenamento de chave-valor, salvo como um arquivo JSON do seu próprio plugin sob ~/.claude/plugins/store/. |
Configurações, histórico, qualquer coisa que o usuário espera encontrar na próxima vez |
$.store.get(key) resolve para o valor ou undefined, e $.store.set(key, value) leva qualquer valor JSON.
Manter um valor em `$.state`
$.state mantém valores pela duração de uma sessão, e redesenha para você. É estado reativo: um hook ui.render que lê um valor se inscreve nele, então o Claude Code redesenha esse site cada vez que você escreve o valor, e você não chama $.ui.invalidate. Um valor em $.state também sobrevive a um recarregamento do módulo, o que uma variável não faz.
Para configurá-lo, declare seus valores, aponte seu manifesto para a declaração, depois defina e use cada valor. Os exemplos movem o count de hello-tabs para $.state.
Declarar os valores
Declare os valores em um arquivo de tipos. A chave externa é o nome do seu plugin, e cada entrada sob ela é um valor e seu tipo. Salve isto como hello-tabs/types/index.d.ts:
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
Apontar o manifesto para a declaração
Para deixar claude plugin validate verificar seu código contra esse arquivo, adicione um campo types ao manifesto com seu caminho:
{
"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, ler e escrever um valor
Em seu módulo, defina cada valor com um padrão, leia-o enquanto desenha e escreva-o de um callback. atom nomeia um valor e seu padrão, read o retorna e update o escreve. Os três ajudantes chamam $.state.get e $.state.set para você:
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 o hook ui.render leu count, o Claude Code executa o hook novamente cada vez que o botão o escreve.
Três regras se aplicam ao código:
- Escreva
pluginekeycomo strings literais:claude plugin validateas lê de sua fonte - Declare cada valor no arquivo de tipos: caso contrário a validação falha com
hello-tabs.count is not declared - Escreva de um callback ou hook de outro evento: um hook
ui.renderpode ler estado e não pode escrevê-lo, então escreva deonPress,onSubmitou um hook para outro evento
Alterar `hello-tabs` para usar `$.state`
Para mover count em hello-tabs para $.state, altere cada linha que o usa:
- No topo do módulo: adicione a linha
importe substitualet count = 0pela linhaatom - No hook
ui.render: adicione a linhareadantes detabButtone desenhe'Count: ' + nnoText - No botão Add one: substitua
onPresspelo da Salvar de mais de uma sessão, que salva a contagem bem como escreve-a - No hook
session.start: substitua as duas linhas que leemsavedpela chamadaloadCountde Carregar um valor salvo novamente após/clear
Mantenha redraw para os botões de aba, porque tab ainda é uma variável.
Carregar um valor salvo novamente após `/clear`
Se seu mod copia um valor salvo de $.store para $.state em session.start, tem que copiá-lo novamente após /clear, /resume ou /branch. Esses comandos colocam cada valor $.state de volta ao seu padrão, e session.start não dispara novamente. classic.SessionStart dispara após cada um deles, com e.source definido como clear, resume ou fork, então copie o valor novamente em um hook nele. Caso contrário seu desenho mostra o padrão, e um callback que salva o valor $.state escreve o padrão sobre o que você armazenou.
Este código carrega count de ambos os hooks. Ele se baseia na versão $.state de hello-tabs, onde count é um atom e update é importado. Coloque loadCount acima de register e adicione a chamada loadCount ao hook session.start que você já tem. classic.SessionStart também dispara na inicialização e após compactação, que não redefine $.state, então o filtro em source mantém o hook aos três resets:
// 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)
})
Com ambos os hooks em vigor, o painel mostra a contagem salva após /clear e não 0, e o próximo pressionamento de Add one adiciona à contagem salva.
loadCount escreve o valor armazenado sobre o em $.state, e session.start dispara novamente cada vez que o módulo recarrega. Para manter o armazenamento de ficar para trás, salve em cada mudança, como o botão Add one faz.
Para verificar o recarregamento sem uma sessão, teste o desenho após /clear.
Salvar de mais de uma sessão
Cada sessão em sua máquina que executa seu mod compartilha um $.store. Um get seguido por um set não é atômico. Quando duas sessões cada uma lê um valor, o altera e o escreve de volta, elas correm, e a segunda escrita substitui a primeira.
Duas escolhas tornam isso menos provável:
- Dê a cada item sua própria chave: um
setaltera apenas sua própria chave, então sessões que escrevem chaves diferentes não sobrescrevem uma à outra - Leia novamente logo antes de escrever: para um valor que várias sessões alteram,
geta chave no callback e construa o novo valor a partir disso, não de uma cópia que você carregou emsession.start. Outra escrita de sessão ainda é perdida se cair entre seugete seuset.
Este botão adiciona um ao que o armazenamento contém agora, depois atualiza o desenho:
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)
}
Se uma segunda sessão pressionou seu próprio botão três vezes desde que esta sessão começou, este pressionamento mostra e salva uma contagem que inclui esses três.
Próximos passos
- Reagir a eventos: alimente seu desenho de chamadas de ferramenta e voltas
- Use a API de mods: alimente seu desenho de timers e chamadas de modelo
- Teste um desenho: pressione seus botões de um teste, em mais de uma superfície
- Sites de renderização e elementos: propriedades de cada site e propriedades de cada elemento