Como o lemon-code desenha a TUI do agente com componentes React, e os dois patches que precisei manter para o Ink rodar sob Bun no Mac
English summary: How lemon-code builds its coding-agent terminal UI with React and Ink on Bun: components, approval hooks, streaming, and three patches Ink needed on macOS ARM64.
Publicado: 23 de outubro de 2026
Tempo de leitura: 9 min
Série: lemon-code em 30 dias, parte 3 de 13
O terceiro commit do lemon-code, em 7 de março de 2026, tinha um nome direto: bootstrap Bun, TypeScript e Ink. Antes de qualquer modelo responder, eu já tinha decidido que a interface do terminal seria escrita em React.
A decisão virou a ADR 0005. O argumento era simples: um agente precisa de barra de status, spinners, menu de aprovação, lista de tarefas e chat com streaming, tudo ao mesmo tempo. Fazer isso com sequências ANSI na mão é difícil de manter. Com React, cada pedaço vira um componente.
Este post mostra como a TUI está organizada, o que o Bun trouxe e três pontos corrigidos no build do Ink sob Bun no macOS ARM64.
Pense na diferença entre desenhar uma página web posicionando cada pixel à mão e montar a mesma página com componentes que reagem ao estado. Sequências ANSI na mão são o primeiro jeito. Ink é o segundo, aplicado ao terminal.
Uma barra de status de agente em Ink cabe em poucas linhas:
// status.tsx (exemplo mínimo; bun add ink@4 react@18)
import React, { useEffect, useState } from "react";
import { render, Box, Text } from "ink";
function StatusBar({ model }: { model: string }) {
const [turn, setTurn] = useState(0);
useEffect(() => {
const id = setInterval(() => setTurn((t) => t + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<Box borderStyle="round" paddingX={1} justifyContent="space-between">
<Text color="green">{model}</Text>
<Text dimColor>turno {turn}</Text>
</Box>
);
}
render(<StatusBar model="codex" />);
Rode com bun status.tsx. Sem tsc, sem bundler. A cada segundo o estado muda, o Ink recalcula o layout e redesenha só o que for preciso. Para quem escreve React, isso dispensa aprender uma biblioteca nova de terminal.
useApprovalFlow, useExecutionProgress, usePermissionControls); os componentes só apresentam.console.log direto quebra a renderização. Todo log passa por um canal próprio.O componente central é um App.tsx que compõe peças pequenas:
| Componente | Responsabilidade |
|---|---|
ChatPane | Mensagens, blocos de código e resultados de ferramenta |
InputBar | Entrada multilinha, histórico e atalhos |
StatusBar | Modelo atual, tokens da sessão e estado do companion |
ApprovalMenu | Aprovar uma vez, aprovar na sessão ou negar |
ExecutionProgress | Pensando, executando ferramenta, aguardando aprovação |
A regra que mais me ajudou: componente não decide nada. A decisão mora em hooks.
// fluxo de aprovação como hook (reescrito e simplificado)
import { useState } from "react";
type Pending = { id: string; action: string } | null;
export function useApprovalFlow(send: (id: string, ok: boolean) => void) {
const [pending, setPending] = useState<Pending>(null);
const request = (id: string, action: string) => setPending({ id, action });
const answer = (ok: boolean) => {
if (!pending) return;
send(pending.id, ok);
setPending(null);
};
return { pending, request, answer };
}
O ApprovalMenu só recebe pending e chama answer. Isso deixa o fluxo testável sem terminal, com bun test, e é o mesmo contrato que o app nativo macOS consome depois por RPC.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares

Nos últimos meses, os agentes de Inteligência Artificial deixaram de ser apenas copilotos de código e passaram a agir como verdadeiros engenheiros virtuais. Ferramentas como Claude Code, Codex CLI e Gemini CLI estão mudando completamente a forma como desenvolvedores escrevem, testam e otimizam código.
Eu pagava API para tarefas que o Claude Code ou o Codex, já logados na minha máquina, resolveriam. A regra CLI-first do lemon-code cabe em quatro linhas de precedência.
Meu agente escolhia o modelo e eu não sabia por quê. Hoje cada escolha vem com uma linha como 'anthropic score 71.5 (risk +20, order +50)'. Este post mostra como chegar nisso.
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.
O texto do modelo chega em pedaços. Antes de virar componente, ele passa por um pipeline:
Truncar a saída de ferramenta foi questão de desempenho. Um ls -R de 20 mil linhas renderizado inteiro trava o terminal, porque o Ink refaz o layout a cada atualização.
Pela ADR 0006, o Bun é o runtime de tudo:
.ts e .tsx direto, então o ciclo de editar e rodar é imediato.bun test roda a suíte inteira sem configuração extra.bun build --compile gera um binário único do lc para distribuir.bun:sqlite) guarda sessões e decisões.Sobre desempenho, não tenho benchmark comparativo próprio contra Node para publicar. A vantagem que senti foi no fluxo de trabalho do dia a dia.
Aqui está a parte que ninguém me contou. O lemon-code mantém um script que roda no postinstall e aplica três patches no build do Ink.
O Ink usa o Yoga (o motor de flexbox) compilado para WebAssembly. O import padrão funciona no Node, mas sob Bun no macOS ARM64 o objeto do Yoga chegava sem Node definido, porque essa variante precisa de inicialização explícita. A correção troca o import por uma inicialização assíncrona:
// antes (no build do ink)
import * as Yoga from "yoga-wasm-web/auto";
// depois do patch
import yogaInit from "yoga-wasm-web/asm";
const Yoga = await yogaInit();
Com o terminal muito baixo, o cálculo da altura da borda vertical ficava negativo, e String.prototype.repeat lança RangeError com número negativo. A TUI inteira caía ao redimensionar a janela. A correção é limitar o valor:
const height = Math.max(0, verticalBorderHeight);
O script é idempotente: ele detecta se o arquivo já foi corrigido e não aplica de novo. Ele roda no postinstall e de novo antes de cada build, porque um bun install limpo reverte tudo.
O script também encerra esse caminho quando a checagem de suporte a raw mode retorna falso:
if (!this.isRawModeSupported()) {
return;
}
A ADR 0005 listava três consequências negativas, e as três apareceram:
console.log interfere no render. Qualquer log direto bagunça a tela. Todo log do lemon-code vai para arquivo ou para um canal capturado pela própria UI.console.log solto no código que roda com a UI montada.bun install limpo seguido de build reaplica os patches.React no terminal parecia exagero até eu precisar de um menu de aprovação que aparece no meio de um streaming, com barra de status atualizando ao lado. Com Ink, isso é composição de componentes. O trade-off é conhecer onde o Ink e o Bun ainda precisam de ajustes e manter os três patches.
No próximo post, segunda, volto para o núcleo: como o roteamento entre modelos dá uma pontuação explicável para cada escolha.
Se você está construindo ferramenta interna de terminal para o seu time e quer acertar arquitetura e DX desde o começo, esse é um trabalho que faço na LEMON.DEV.