Pular para o conteúdo
MÓDULO 3.3

🪟 Painéis e botões

Quando uma linha não basta, o mod abre um painel: uma área com moldura, ao lado da conversa ou acima do prompt, que ele desenha inteira. Aqui o Rafa abre um painel com os arquivos que o Claude editou, põe botões com atalho e aprende a fechar sem deixar nada pendurado.

6
Tópicos
~35
Minutos
Médio
Nível
Prático
Tipo
0 de 60%
Versão conferida: $.ui.open, as props de Pane e de Button foram conferidos no claude-code.d.ts do Claude Code 2.1.289, em 05/10/2026. O mod de exemplo passou no claude plugin validate e respondeu o /tocados num claude -p nessa versão.
1

Abra um painel com ui.open

Um painel tem duas metades. $.ui.open({ id, title }) pede o espaço; um hook ui.render em { component: 'Pane', requestId: id } desenha o conteúdo. Um painel por id: abrir de novo um id aberto só troca o título.

Quem decide onde ele fica é a superfície. No terminal em tela cheia, ele encaixa ao lado da conversa (dock); fora disso, abre acima do prompt (inline). O painel existe em todas as superfícies, inclusive no celular.

🆕 Novo aqui? Pedido e não pedido

Um painel é pedido quando um comando, um prompt ou um botão da pessoa está por trás da abertura. Pedido, ele é colocado em qualquer largura. Não pedido (aberto pelo mod sozinho, num session.start), só aparece a partir de 144 colunas; abaixo disso fica na fila sem ser desenhado (isPlaced: false) até a pessoa abrir ou a tela alargar.

Por isso a regra do kit INEMA (pegadinha 9): abra por comando ou botão. Abrir sozinho só vira barra lateral com a tela larga e em tela cheia (e.viewport?.isFullscreen).

ModAbre quandoResultado numa tela estreita
examples/pane.tsx (oficial)no session.start (void $.ui.open(...)) e no /tool-callsa abertura automática espera; o comando abre
inema-mods/mods/recibo-sessaosó no /reciboabre sempre que pedido

O que olhar na tabela: o exemplo oficial faz as duas coisas para mostrar que dá. O curso segue o recibo: abrir só por pedido evita um painel tomando a tela de quem não pediu.

🎯 Objetivo: um painel com os arquivos editados, aberto por /tocados

Crie a pasta meu-painel/ com estes quatro arquivos (o caminho de cada um está no comentário):

// meu-painel/.claude-plugin/plugin.json
{
  "name": "meu-painel",
  "version": "0.1.0",
  "description": "Painel do Rafa: arquivos editados nesta sessão (/tocados)",
  "author": { "name": "Rafa" },
  "types": "./types/index.d.ts"
}

// meu-painel/hooks/hooks.json
{ "modules": ["./register.tsx"] }

// meu-painel/types/index.d.ts
export type Caminho = string

declare module 'claude-code' {
  interface PluginState {
    'meu-painel': { arquivos: string[] }
  }
}

// meu-painel/hooks/register.tsx
import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'

const PAINEL = 'meu-painel'
const arquivos = atom({ plugin: 'meu-painel', key: 'arquivos' } as const, [])

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    await $.command.register({ name: 'tocados', description: 'Abre o painel dos arquivos editados' })
    return next(e)
  })

  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
    const ran = await next(e)
    if (ran.deny === undefined && ran.isError !== true) {
      await update($, arquivos, lista => [...lista.filter(p => p !== e.file_path), e.file_path].slice(-50))
    }
    return ran
  })

  on('command.run', { command: 'tocados' }, async $ => {
    const aberto = await $.ui.open({ id: PAINEL, title: 'Arquivos tocados' })
    return { text: aberto.isPlaced ? 'Painel aberto.' : 'Painel esperando espaço na tela.' }
  })

  on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
    const { Box, Text, Button } = $.ui.resolve(e)
    const lista = await read($, arquivos)
    const largura = e.props.bodyColumns
    const corta = (s: string) => (s.length > largura ? `…${s.slice(-(largura - 1))}` : s)

    return (
      <Box flexDirection="column">
        {lista.length === 0 && <Text dimColor>Nenhuma edição ainda.</Text>}
        {lista.map(p => <Text>{corta(p)}</Text>)}
        <Box key="botoes">
          <Button key="limpar" hotkey="l" label="limpar" onPress={() => update($, arquivos, () => [])} />
          <Button key="fechar" hotkey="f" label="fechar" role="dismiss" onPress={() => $.ui.close({ id: PAINEL })} />
        </Box>
      </Box>
    )
  })
}
Como verificar: rode claude plugin validate meu-painel. Rodado aqui (2.1.289): calls: lista $.ui.open, $.ui.close e $.ui.resolve, e a última linha é ✔ Validation passed. (Os comentários de caminho não fazem parte dos arquivos.)
2

Dimensione pelo bodyColumns

O painel encaixado ao lado da conversa é mais estreito que a tela. Se o Rafa medir por e.viewport.columns, a linha passa da moldura. A medida certa é e.props.bodyColumns: as colunas dentro da moldura, sem nenhuma célula embaixo da marca de fechar.

Uma mudança de largura redesenha sozinha, então uma árvore medida por bodyColumns fica certa. Mudança só de altura não redesenha nada.

conversa > _ placement: dock bodyColumns tela cheia, larga terminal em tela cheia conversa placement: inline bodyColumns > _ tela comum ou estreita

Como ler o desenho: o mesmo painel em duas telas. A seta azul é o bodyColumns em cada uma: muito menor que a tela à esquerda. O placement chega nas props e é só leitura.

Prop do PaneO que diz
bodyColumnscolunas úteis dentro da moldura
scroll.bodyRowslinhas visíveis do corpo (o mapa-calor usa para saber quantas pastas cabem)
placementdock ou inline
isFocuseda pessoa deu o teclado ao painel
titlerótulo da aba quando há mais de um painel
3

Ponha botões com hotkey

No terminal, um Button aparece como [ label ]. A hotkey (um dígito ou uma letra minúscula) aperta o botão enquanto o painel ou a faixa tem o teclado: depois de ctrl+x tab, de um clique, ou se o painel abriu com focus.

Sempre dê key aos botões. É pela key que o engine sabe qual foi apertado e que os testes acham o botão (módulo 4.2).

📄 inema-mods/mods/mapa-calor/hooks/register.tsx (trecho)
<Box key="botoes">
  <Button key="alternar" hotkey="t" label={modo === 'bytes' ? 'ver por arquivos' : 'ver por tamanho'} onPress={alternar} />
  <Text> </Text>
  <Button key="reler" hotkey="r" label="ler de novo" onPress={reler} />
</Box>
O que olhar: o rótulo do primeiro botão muda com o estado, a hotkey não. E o <Text> </Text> no meio é só um espaço entre os dois.
Prop do ButtonEfeito
variant="primary"marca a ação principal; o terminal pinta o [ label ] na cor de destaque
plainsem colchetes (1: label com hotkey); vence o variant
role="dismiss"o botão que fecha; só uma dica de desenho (o desktop usa o fechar nativo)
actionliga o botão a uma ação de atalho do engine; o atalho da pessoa aperta direto do prompt
autoFocuscomeça com o anel de foco

✓ Botão bem feito

  • ✓ key fixa e descritiva
  • ✓ hotkey de uma letra, sem repetir no mesmo painel
  • ✓ Um só variant="primary" por painel

✗ Botão que confunde

  • ✗ hotkey="Enter" ou maiúscula: não é um dígito nem letra minúscula
  • ✗ Contar com role="dismiss" para fechar: quem fecha é o seu onPress
  • ✗ Botão sem key
4

Trate press, input e select

Os handlers ficam no próprio plugin, como closures da árvore: onPress no Button, onSubmit e onInput no Input, onSelect no Select. A superfície só manda de volta "apertaram tal key".

No caminho, cada gesto vira um evento: ui.press, ui.input, ui.select. Outro mod pode se pendurar neles (por exemplo, para registrar cliques). No seu mod, quase sempre basta o handler no elemento.

🆕 Novo aqui? O que cada handler recebe

  • onPress(e) — e.element (a key), e.requestId, e.surface. Passe e.surface adiante quando precisar, como em $.ui.copy.
  • onSubmit(value, e) — Enter no campo (kind: 'submit'). onInput(value, e) recebe cada edição (kind: 'change').
  • onSelect(value, e) — a opção escolhida num Select com options.
📄 Exemplo curto (nomes conferidos no d.ts): um filtro no painel do Rafa
const { Box, Text, Input } = $.ui.resolve(e)
// ...
<Input
  key="filtro"
  placeholder="filtrar por pasta"
  onSubmit={valor => update($, filtro, () => valor)}
/>
O que olhar: o handler escreve no $.state (um atom filtro declarado no contrato); o render lê e redesenha. A mesma regra do módulo 3.1: render lê, handler escreve.

⚠️ Celular não tem campo

O mobile não tem Input nem Select. Se o painel precisa abrir lá, estreite e.surface e troque o campo por botões (ou por um argumento do comando: /tocados src).

5

Faça um painel virar diálogo

Às vezes o Rafa quer uma pergunta rápida que a pessoa responde e fecha: "limpar a lista?". Três opções de $.ui.open juntas transformam o painel num diálogo: focus, closeOnEscape e holdToasts.

Com elas, o painel pega o teclado, Tab e setas andam pelos botões, Esc fecha, e todo toast (de qualquer plugin) espera atrás dele. rows pede a altura certa para um diálogo curto aparecer inteiro.

1

Abra como diálogo

await $.ui.open({ id: 'confirma', title: 'Limpar?', focus: true, closeOnEscape: true, holdToasts: true, rows: 3 })

2

Desenhe a pergunta e dois botões

O seguro com autoFocus; o destrutivo com variant="primary" só se ele for a ação principal.

3

Cada botão fecha ao terminar

O onPress faz o trabalho e chama $.ui.close({ id: 'confirma' }). Esc fecha sem fazer nada.

✓ Diálogo

  • ✓ focus, closeOnEscape, holdToasts e rows
  • ✓ Pergunta curta, dois ou três botões
  • ✓ Fecha assim que a pessoa responde

✗ Painel que fica

  • ✗ holdToasts ligado num painel permanente
  • ✗ Diálogo sem botão que feche
  • ✗ focus num painel que abre sozinho

💡 São pedidos, não garantias

focus só é atendido se o prompt tem o teclado e está vazio: com texto digitado, um diálogo do engine ou uma pesquisa aberta, o painel abre sem foco. rows perde para o tamanho que a pessoa arrastou. Desenhe pensando que pode não ter vindo.

⚠️ holdToasts só em diálogo

Num painel que fica aberto (como o /tocados), holdToasts segura os avisos de todos os mods enquanto ele estiver à vista. Use só no painel que a pessoa responde e fecha.

6

Feche e libere o painel

$.ui.close({ id }) fecha um painel do seu plugin; um id que não está aberto é ignorado. Todo fechamento passa pelo evento ui.close, com e.origin dizendo de quem foi: plugin (a sua chamada), person (a marca de fechar, ctrl+x x ou Esc com closeOnEscape) ou unload (o engine descarregou o plugin).

Um hook em ui.close que responde sem next mantém o painel aberto, menos no unload. Use com cuidado: segurar o painel contra a vontade da pessoa é irritante. Para saber o que está aberto, $.ui.panes() lista os seus painéis com isPlaced.

plugin person unload ui.closehooks dos mods next(e): painel fecha sem next: fica aberto fecha sempre

Como ler o desenho: os fechamentos do plugin e da pessoa passam pelo ui.close, onde um hook pode segurar. O unload (tracejado) não pode ser segurado: quando o engine descarrega o plugin, o painel fecha de qualquer jeito.

🔌
plugin

$.ui.close

🙋
person

marca, ctrl+x x, Esc

♻️
unload

não dá para segurar

📋
$.ui.panes

o que está aberto

🎯 Objetivo: abrir, usar e fechar o painel do Rafa

Na pasta que contém meu-painel/ (do tópico 1):

claude --plugin-dir meu-painel

Na sessão: peça uma edição num arquivo de teste, digite /tocados, dê o teclado ao painel (ctrl+x tab) e aperte f.

Como verificar: rodado aqui com claude -p "/tocados" --plugin-dir meu-painel, a resposta foi meu-painel: Painel aberto.. Na sessão interativa (não medido aqui), esperado: o painel lista o arquivo editado com [ limpar ] e [ fechar ] embaixo, e o f fecha o painel. Rodando /tocados de novo, ele volta com a mesma lista (o estado mora no $.state, não no painel).

Teste rápido (opcional): o Rafa abre o painel no session.start, sem comando. No notebook dele (terminal de 100 colunas) o painel nunca aparece. Por quê?

🎓 Resumo do módulo

✓
Painel = ui.open + render no Pane — um por id.
✓
Abra por comando — não pedido, só a partir de 144 colunas.
✓
Largura é bodyColumns — dock ou inline, nunca viewport.columns.
✓
Botão com key e hotkey — handlers ficam no plugin.
✓
Diálogo = focus + closeOnEscape + holdToasts — e fecha com $.ui.close.

Próximo módulo:

3.4 — Comandos, ferramentas e configuração