$.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.
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).
| Mod | Abre quando | Resultado numa tela estreita |
|---|---|---|
examples/pane.tsx (oficial) | no session.start (void $.ui.open(...)) e no /tool-calls | a abertura automática espera; o comando abre |
inema-mods/mods/recibo-sessao | só no /recibo | abre 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.
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>
)
})
}
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.)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.
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 Pane | O que diz |
|---|---|
bodyColumns | colunas úteis dentro da moldura |
scroll.bodyRows | linhas visíveis do corpo (o mapa-calor usa para saber quantas pastas cabem) |
placement | dock ou inline |
isFocused | a pessoa deu o teclado ao painel |
title | rótulo da aba quando há mais de um painel |
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).
<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>
hotkey não. E o <Text> </Text> no meio é só um espaço entre os dois.| Prop do Button | Efeito |
|---|---|
variant="primary" | marca a ação principal; o terminal pinta o [ label ] na cor de destaque |
plain | sem 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) |
action | liga o botão a uma ação de atalho do engine; o atalho da pessoa aperta direto do prompt |
autoFocus | começa com o anel de foco |
✓ Botão bem feito
- ✓
keyfixa e descritiva - ✓
hotkeyde 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 seuonPress - ✗ Botão sem
key
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. Passee.surfaceadiante 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 numSelectcomoptions.
const { Box, Text, Input } = $.ui.resolve(e)
// ...
<Input
key="filtro"
placeholder="filtrar por pasta"
onSubmit={valor => update($, filtro, () => valor)}
/>
$.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).
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.
Abra como diálogo
await $.ui.open({ id: 'confirma', title: 'Limpar?', focus: true, closeOnEscape: true, holdToasts: true, rows: 3 })
Desenhe a pergunta e dois botões
O seguro com autoFocus; o destrutivo com variant="primary" só se ele for a ação principal.
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,holdToastserows - ✓ Pergunta curta, dois ou três botões
- ✓ Fecha assim que a pessoa responde
✗ Painel que fica
- ✗
holdToastsligado num painel permanente - ✗ Diálogo sem botão que feche
- ✗
focusnum 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.
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.
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.
$.ui.close
marca, ctrl+x x, Esc
não dá para segurar
o que está aberto
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.
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
Próximo módulo:
3.4 — Comandos, ferramentas e configuração