Pular para o conteúdo
MÓDULO 3.1

🎨 ui.render e as superfícies

Tudo o que um mod desenha passa por um único evento: ui.render. O mesmo hook é chamado para o terminal, o desktop, o celular e o VS Code, e cada um aceita um conjunto diferente de elementos. Este módulo é o contrato do desenho antes da faixa, do painel e do comando.

6
Tópicos
~35
Minutos
Médio
Nível
Fundamento
Tipo
0 de 60%
Versão conferida: nomes de elementos, props e mensagens de erro deste módulo foram conferidos no claude-code.d.ts do Claude Code 2.1.289 e na referência oficial (seção "Drawing: ui.render"), em 05/10/2026. A tabela de elementos por superfície é a parte que mais muda entre versões: confira o tipo Elements da sua instalação.
1

Entenda o que o ui.render recebe

Um hook em ui.render recebe uma instância de um componente por vez: a faixa acima do prompt, um painel, a linha de saída de um comando, uma mensagem do modelo. Ele devolve uma árvore de elementos, ou passa adiante com next.

O e de um ui.render tem cinco campos que você vai usar sempre. O matcher filtra por eles: { component: 'Pane', requestId: 'meu-painel' } faz o hook acordar só para o seu painel.

🆕 Novo aqui? Três palavras do desenho

  • Componente — o lugar da tela que está sendo desenhado. Os nomes estão no tipo RenderComponent: AbovePrompt, Pane, CommandOutput, UserMessage, ToolUse, Spinner e outros.
  • Superfície — onde o desenho aparece: terminal, desktop (o app Claude Code Desktop), mobile (o app do celular) ou vscode.
  • requestId — qual instância: o id do painel, o id da mensagem, o tool_use_id de uma linha de ferramenta. É o endereço que $.ui.blit e $.ui.focus usam depois.
Campo de eO que dizPara que serve
e.componentqual componentematcher; um hook pode atender vários
e.surfaceterminal, desktop, mobile ou vscodeescolher o que desenhar em cada uma
e.requestIdqual instânciaseparar o seu painel dos outros
e.propsos dados do componente, como dado simplesbodyColumns, hasSurvey, text...
e.viewportcolumns, rows e, quando a superfície diz, isFullscreensaber o tamanho total; pode faltar

O que olhar na tabela: o e.viewport é opcional (use e.viewport?.rows ?? 24, como o exemplo oficial do painel). E para a faixa e o painel, a largura certa não é viewport.columns: é e.props.bodyColumns, a caixa que sobra sem as marcas do engine.

e (ui.render) component: "Pane" surface: "terminal" requestId: "recibo" props: { bodyColumns: 58 } viewport: { columns: 120, rows: 40 } seu hook ($, e, next) return <Box>…</Box> a sua árvore é desenhada return next(e) o engine desenha a dele

Como ler o desenho: o cartão da esquerda é o e que chega. O hook olha esses campos e escolhe um dos dois caminhos: devolve uma árvore própria (seta azul) ou passa a vez (seta cinza). Existe um terceiro caminho, next({ ...e, props }), que muda os dados antes de o engine desenhar.

🧱
component

qual lugar

🖥️
surface

qual tela

🔖
requestId

qual instância

📐
viewport

tamanho, se houver

2

Pegue os elementos com ui.resolve

O módulo do mod não tem Box nem Text globais. Quem entrega os elementos é $.ui.resolve(e): uma tabela de construtores da superfície em que aquele e vai ser desenhado.

Você desestrutura o que precisa e usa como tag JSX. Não é uma chamada ao engine: o engine já resolveu a tabela quando os plugins carregaram. Por isso pode ser chamada no começo de todo render, sem custo.

📄 plugin-authoring/examples/pane.tsx (referência oficial, trecho do render)
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
  const { Box, Text } = $.ui.resolve(e)
  const list = await read($, calls)
  const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)

  return (
    <Box flexDirection="column">
      {list.length === 0 && <Text dimColor>No tool calls yet.</Text>}
      {list.slice(-room).map(call => (
        <Text dimColor={call.isDone}>
          {call.isDone ? 'done' : 'runs'} {call.tool}
        </Text>
      ))}
    </Box>
  )
})
O que olhar: a primeira linha do corpo é sempre $.ui.resolve(e). Repare também que este hook não recebe next: o painel é dele, não há o que passar adiante.
1

Resolva a tabela

const { Box, Text, Button } = $.ui.resolve(e). Peça só o que vai usar.

2

Leia o estado

await read($, calls): a leitura inscreve este desenho para redesenhar quando o valor mudar (tópico 4).

3

Meça o espaço

Linhas pelo viewport, colunas pelo e.props.bodyColumns no painel e na faixa.

4

Devolva a árvore

JSX com h como fábrica. No .mjs do kit, sem JSX, a mesma árvore é Box({ flexDirection: "column", children: [...] }).

🆕 Novo aqui? Elements

Elements é o tipo que lista, por superfície, os construtores que $.ui.resolve devolve. Quando você estreita o e.surface (if (e.surface === 'terminal')), o TypeScript estreita a tabela junto: Raster só aparece dentro desse if.

3

Respeite as diferenças entre superfícies

Cada superfície pede o desenho por conta própria. O terminal desenha a árvore inteira com Ink; as outras recebem a árvore pela rede e desenham do jeito delas. Por isso cada uma tem sua tabela de elementos.

E nem todo componente existe em toda superfície: a faixa AbovePrompt só é pedida no terminal e no desktop; o Pane é pedido em todas.

Elementoterminaldesktopmobilevscode
Box, Text, Button, Link, Code, Markdown✓✓✓✓
Input, Select✓✓✗✓
Client✓✓✗✗
Svg✗✓✓✓
Raster, Image✓✗✗✗

O que olhar na tabela: o básico (Box, Text, Button) funciona em todo lugar. O resto exige um if (e.surface === ...). Um Svg devolvido ao terminal é uma árvore que não valida (tópico 5).

📄 claude-mods-starter-kit/plugins/terminal-pet/hooks/terminal-pet.mjs (Prompt Advisers, MIT; trecho resumido)
if (e.surface === "desktop") {
  const { Svg } = $.ui.resolve(e);
  // ... desenha o bicho como SVG animado
}
const fits = e.surface === "terminal" && columns >= COLS + 2 && (e.props.maxRows || 0) >= ROWS + 1;
if (!fits) {
  stopTicker();
  return stack(fallbackLine(Text, prefs, mood, stats, fill));
}
const { Raster } = $.ui.resolve(e);
O que olhar: três caminhos para o mesmo bicho. Desktop: Svg. Terminal com espaço: Raster. Qualquer outro caso: uma linha de texto. Nunca uma superfície sem nada.

✓ Faça

  • ✓ Estreite e.surface antes de pedir Svg, Raster ou Image
  • ✓ Tenha uma versão só com Box e Text para o resto
  • ✓ Teste em terminal e desktop (módulo 3.2)

✗ Evite

  • ✗ Supor que a pessoa usa o terminal
  • ✗ Input num painel que precisa abrir no celular
  • ✗ Contar com a faixa no VS Code ou no celular
4

Desenhe do estado, nunca escreva ao desenhar

O render é uma leitura. Ele lê o $.state (direto ou com read) e devolve uma árvore. A leitura feita durante o desenho inscreve aquela instância: quando alguém grava um valor novo, o engine redesenha exatamente quem leu. Ninguém precisa pedir redesenho.

Quem escreve é outro: um evento (tool.call, turn.complete) ou um handler de botão. Uma escrita feita dentro do render é recusada. Você viu o porquê do estado em $.state no módulo 2.3: variável de módulo some no hot-reload.

📄 plugin-authoring/examples/band.tsx (referência oficial, trecho)
const isHidden = atom({ plugin: 'turn-band', key: 'isHidden' } as const, false)
// ...
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
  const turn = await read($, last)
  // ...
  <Button
    key="hide"
    label="Hide"
    onPress={() => update($, isHidden, () => true)}
  />
O que olhar: o render só lê (read). A escrita (update) mora dentro do onPress, que roda depois, quando a pessoa aperta. Aí o render volta sozinho, porque leu isHidden.

✓ No render

  • ✓ read($, atom) ou $.state.get(ref)
  • ✓ $.clock.now() para um relógio
  • ✓ Montar closures de botão que escrevem depois

✗ No render

  • ✗ $.state.set ou update direto: negado
  • ✗ Ler de uma variável de módulo que o reload zera
  • ✗ Escrever a partir de um valor capturado no desenho

⚠️ O valor capturado engana

onPress: () => $.state.set(ref, n + 1), com n lido no desenho, perde cliques: dois toques antes do redesenho gravam o mesmo número. Use update($, ref, n => (n ?? 0) + 1), que lê, aplica e grava com ifVersion e tenta de novo se perder a corrida.

💡 E quando o estado não é do $.state?

Um relógio que conta minutos não muda nenhum valor guardado. Nesse caso o mod pede $.ui.invalidate('ui.render') de um timer ($.clock.every), como o terminal-pet faz quando o humor do bicho expira.

5

Saiba o que acontece quando a árvore não valida

Uma árvore não valida quando usa um elemento que a superfície não tem, uma prop que o elemento não aceita, ou um filho onde não cabe filho. O engine não quebra a sessão: desenha a dele no lugar e anota o motivo.

Por isso um mod com erro de desenho parece "não fazer nada". O motivo está escrito; você só precisa saber onde olhar.

1

O hook devolve a árvore

Por exemplo, um Svg no terminal.

2

O engine valida e recusa

No debug log entra uma linha que começa com ui.render (<Component>): a hook returned a tree that does not validate, seguida do motivo.

3

Com hot-reload, a conversa também avisa

Uma vez por carga: <plugin>: ui.render (<Component>) refused: <reason>; the engine drew its own. Fora disso, só o debug log sabe.

4

O lugar fica com o desenho do engine

Onde o engine não desenha nada, nada sobra: a faixa fica vazia (a linha termina em nothing was drawn) e o painel é fechado (the pane was closed).

árvore válida Svg no terminal validaelemento, prop, filho tela: a sua árvoretudo certo tela: a do engine+ linha no debug log

Como ler o desenho: o portão do meio confere três coisas. Passou, a sua árvore aparece. Não passou, a tela mostra o desenho padrão e o motivo vai para o log. O módulo 4.3 mostra como corrigir uma árvore recusada.

💡 Dois valores que não derrubam a árvore

Um Box com borderStyle que o terminal não conhece é desenhado sem borda. Um Code com format: 'diff' cujo texto não é um diff vira código simples. O resto da árvore aparece; o log avisa. Os estilos de borda válidos: single, double, round, bold, singleDouble, doubleSingle, classic, arrow, dashed, quote.

6

Use Raster e Image no terminal

O Rafa quer um mapa de calor do repositório: uma grade de quadradinhos coloridos. O instinto é um Box por célula. Não faça: uma grade colorida é um único Raster, com todas as células empacotadas numa string.

Para uma figura de verdade (PNG, quadro de vídeo) existe o Image. Os dois só existem no terminal.

🆕 Novo aqui? Raster e Image

  • Raster — key, columns (1 a 512), rows (1 a 256) e cells: base64 de trincas [codePoint, frente, fundo] em u32 little-endian, linha por linha. Cor 0x00RRGGBB; 0x01000000 é a cor padrão do terminal. Cada caractere precisa ter largura 1 (blocos, bordas, braille).
  • Image — source ({ png }, { rgba, width, height }, ou o nome de um arquivo ou memória compartilhada), columns, rows e alt (obrigatório). Usa o protocolo gráfico do kitty onde o terminal tem (kitty, Ghostty); nos outros, aparece o alt.
  • $.ui.blit — repinta um Raster (ou troca a fonte de um Image) já montado, pelo requestId e pela key, sem passar pelo render.
✗ 24 elementos Box cada célula vira um nó para validar e desenhar ✓ 1 elemento Raster <Raster key="mapa" columns={6} rows={3} cells="iCUAAP+I…" /> repintado com $.ui.blit uma string com as 18 trincas, um nó só

Como ler o desenho: as duas grades mostram a mesma coisa. A da esquerda custa um elemento por célula; a da direita, um só. O repo-heatmap do kit vai além: dois "pixels" por célula com o meio bloco ▀ (frente = pixel de cima, fundo = pixel de baixo) e animação por $.ui.blit num $.clock.every.

⚠️ E no desktop?

O desktop não tem Raster. Para uma barra que funcione nas duas superfícies, use caracteres █░ num Text com cor, como o mapa-calor do inema-mods faz (pegadinha 20 do COMO-FAZER-UM-MOD). Ou estreite e.surface e mande um Svg ao desktop, como o terminal-pet.

Teste rápido (opcional): o Rafa quer, no painel, uma grade 40×10 com a cor de cada arquivo pela quantidade de edições. Ele usa terminal e desktop. O que faz sentido?

🎓 Resumo do módulo

✓
Um render, uma instância — component, surface, requestId, props e viewport.
✓
Elementos vêm de $.ui.resolve(e) — não há globais.
✓
Cada superfície tem sua tabela — terminal sem Svg, só ele com Raster e Image.
✓
O render só lê — escrita mora em handlers e eventos.
✓
Árvore recusada não quebra nada — o engine desenha a dele e o log diz por quê.

Próximo módulo:

3.2 — Faixa, status e avisos