O Claude Code mora no terminal. Ele renderiza com React e Ink, um framework que desenha componentes de interface no stdout usando códigos de escape ANSI e um motor de layout Yoga compilado para WASM. É bonito do jeito que um prédio brutalista é bonito: funcional, cheio de opinião e sem o menor interesse nas suas preferências.

Queríamos colocá-lo em um navegador. Uma interface gráfica de verdade. Balões de chat, cartões de ferramentas, pedidos de permissão, um indicador de raciocínio que não some no meio do pensamento. O que veio depois foram seis dias depurando flags não documentadas, brigando com o comportamento de spawn de cada plataforma, vendo o mesmo bug voltar dos mortos três vezes e aprendendo que a distância entre “protótipo funcionando” e “funciona de verdade” se mede em palavrões.

Dia 1: pesquisa, ou como descobrimos que era uma má ideia

A primeira pergunta era óbvia: dá para trocar a skin do Claude Code? A resposta era não. A interface de terminal não é uma camada de tema que você troca. Ela é a aplicação. Componentes React renderizando em uma instância do Ink que escreve direto no buffer do seu terminal. Não existe “é só mudar o CSS”. Não existe CSS.

Disparamos seis agentes de pesquisa em paralelo. Eles dissecaram as entranhas do Claude Code, analisaram o protocolo de streaming do Agent SDK e revisaram nove wrappers de interface gráfica da comunidade que já tinham tentado alguma versão disso: Claudia (Tauri, apoiada pela YC), CodePilot (Electron/Next.js), Claudex (FastAPI/React) e outros seis. Cada um tinha seguido um caminho diferente. Nenhum tinha resolvido de forma limpa o problema da ida e volta das permissões.

Três opções apareceram. Criar do zero um app novo em Tauri ou Electron. Fazer um fork do Claude Code e arrancar o renderizador Ink. Ou estender a nossa Neural Interface, que já tinha um servidor Express, infraestrutura de WebSocket, sessões de terminal e um explorador de arquivos rodando na porta 3344.

Ficamos com a opção três. Não porque era a melhor arquitetura. Porque era a única em que não precisávamos começar do zero.

Dia 2: “É só fazer spawn de um processo”

O plano era simples. O usuário manda uma mensagem por um WebSocket. O servidor inicia o claude como subprocesso com --output-format stream-json. Faz o parse dos eventos NDJSON. Encaminha para o navegador. Renderiza os balões de chat.

O processo não iniciou.

No Windows, claude não é um binário. É um wrapper .cmd que o spawn() do Node não consegue executar sem shell: true. O erro era ENOENT. Nada de “arquivo não encontrado”. Nada de “plataforma errada”. Só... nada. O subprocesso falhou em silêncio e o WebSocket ficou lá esperando eventos que nunca iam chegar.

Adicionamos shell: true. Aí descobrimos o problema das variáveis de ambiente. O Claude Code procura as variáveis VSCODE_* e TERM_PROGRAM para detectar se está rodando dentro de uma IDE. O processo do nosso servidor herdava todas elas. A instância aninhada do Claude achava que estava dentro do VS Code, o que mudava seu comportamento de maneiras que não dava para prever. Tivemos que remover todas as variáveis de ambiente que interferiam antes do spawn, seguindo exatamente o mesmo padrão de filtragem que o nosso spawner de terminal já usava.

Depois o subprocesso travou. Ele iniciou, conectou, carregou a configuração dos servidores MCP. E ficou lá parado sem fazer nada, porque --strict-mcp-config não estava definida e a inicialização dos servidores MCP estava bloqueando o processo. Mais uma falha silenciosa. Mais uma hora perdida.

Também descobrimos, por acidente, que claude -p --output-format stream-json exige em silêncio a flag --verbose se você quiser os eventos de chamada de ferramenta. Sem ela, você recebe o resultado final e nada do que acontece no meio. Isso não está documentado em lugar nenhum. Descobrimos lendo o código-fonte.

Dia 3: dando cara de alguma coisa

Quando os eventos finalmente começaram a fluir, tivemos que renderizá-los. O formato stream-json emite eventos como system.init, assistant (com blocos de conteúdo de texto e tool_use), tool_result e result. Cada um precisava do seu próprio renderizador.

A primeira versão parecia um terminal fingindo ser um app de chat. Tudo monoespaçado, nenhuma hierarquia visual, chamadas de ferramenta despejadas como JSON cru. Então reescrevemos o CSS do zero. Layout no estilo do GPT: coluna centralizada, largura máxima de 780px, avatar mais conteúdo nas mensagens do assistente, balões do usuário em formato de pílula. Destaque em dourado, porque estávamos construindo o SynaBun e dourado é a cor da marca.

O usuário odiou.

Não só o dourado. A paleta inteira. Os gradientes dourados no botão de enviar, a barra de rolagem com tom dourado, as bordas douradas nos cartões de ferramentas, o box-shadow dourado no foco do campo de texto. Tudo. Aí tentamos destaques em azul. Odiou também.

Tire a cor. Deixe neutro. O painel tem que se misturar à Neural Interface, não gritar uma identidade própria.

Então arrancamos todas as cores de destaque e religamos o painel inteiro ao sistema de variáveis CSS da própria Neural Interface. --t-faint, --s-subtle, --b-hover. Cinzas neutros por toda parte. O único elemento colorido que sobrou foi o ícone “S” da marca: um único gradiente dourado em um mar monocromático. Foi a decisão certa. Precisamos reescrever a paleta inteira três vezes para chegar lá.

Dia 4: o binário que engoliu o stream de JSON

Alguém anexou um JPEG pelo botão do clipe.

O sistema de anexos usava file.text() para ler o conteúdo do arquivo e o injetava no prompt por meio de tags <file>. Isso funciona bem com arquivos de código, markdown, texto puro. Com um JPEG, file.text() devolve um lixo binário embaralhado que é concatenado na string do prompt, enviado para o stdin e corrompe o stream NDJSON na hora. O subprocesso engasga. O WebSocket morre. A interface congela.

A correção foi uma lista de permissões. Cerca de 70 extensões de arquivos de texto e código que são seguras para ler como texto. Todo o resto é rejeitado com uma mensagem de status. Também adicionamos um limite de tamanho de 100KB e passamos o conteúdo do arquivo para antes do texto do usuário no prompt, para que o Claude veja o contexto primeiro. Nove testes novos. O tipo de bug que faz você se perguntar como aquilo funcionava antes, até perceber que ninguém tinha tentado anexar uma imagem ainda.

Dia 5: a assombração da flag --print

Essa merece uma seção própria porque aconteceu três vezes.

O Claude Code tem uma flag --print (forma curta: -p). Ela roda o Claude em modo não interativo: manda um prompt, recebe uma resposta, sai. Ela também aprova automaticamente todas as chamadas de ferramenta. Sem pedidos de permissão. Sem eventos control_request. A CLI simplesmente faz o que quiser.

Para uma skin que precisa mostrar cartões de permissão “Permitir / Negar / Sempre”, isso é fatal. Se --print estiver nos argumentos do spawn, a interface de permissões é código morto. Toda implementada, toda estilizada, nunca acionada.

11 de março: Percebemos que os pedidos de permissão não estavam aparecendo. Achamos -p no array de argumentos. Removemos. As permissões funcionaram. Comemoramos.

12 de março: As permissões pararam de funcionar de novo. Alguém tinha colocado --print de volta com um comentário que dizia // --print is REQUIRED for stream-json. O comentário estava errado. --input-format stream-json cuida sozinha do envio pelo stdin. O comentário foi escrito com confiança absoluta e estava absolutamente incorreto.

O “alguém” era a IA. Em outra sessão, trabalhando com outro plano, ela tinha concluído que --print era necessária porque a documentação da CLI dizia que o stream-json “só funciona com --print”. Aquela documentação descrevia outro caso de uso. A IA não verificou. Só adicionou a flag e escreveu um comentário convincente.

13 de março: Mesmo bug. Terceira vez. A correção definitiva foi remover --print e adicionar um comentário tão agressivo que não dava para entender errado:

NÃO adicione --print / -p aqui. Ela força o modo não interativo, que aprova automaticamente TODOS os usos de ferramenta e suprime os eventos control_request. Este bug já foi corrigido 3 vezes. Veja o CHANGELOG.

Três sessões. Três planos. Dois deles se contradiziam. Um dizia “adicione --print, é obrigatória”. O outro dizia “remova --print, ela quebra as permissões”. Os dois foram escritos pela mesma IA, com dias de diferença e a mesma confiança. É assim que construir com IA é de verdade. Não a demo polida. A parte em que a sua própria ferramenta faz gaslighting com você de uma sessão para outra.

A guerra do formato da control response

Mesmo depois de corrigir a flag --print, as respostas de permissão não funcionavam. O usuário clicava em “Permitir”. Nada acontecia. O Claude ficava lá esperando.

O servidor estava normalizando a mensagem control_response em um formato plano: {"type": "control_response", "request_id": "...", "response": {"behavior": "allow"}}. Limpo. Lógico. Errado.

A CLI lê q.response.request_id e q.response.response. Ela espera um formato aninhado em que tudo fica dentro de um wrapper response com um campo subtype: "success". Com o formato plano, q.response.request_id resultava em undefined, a busca pela requisição pendente falhava e a resposta era descartada em silêncio.

Descobrimos isso lendo o código-fonte do SDK de verdade. Linha 13436 do cli.js. Não a documentação. Não os exemplos. O código minificado. Esse é o nível de arqueologia que este projeto exigiu.

Dia 6: pensando sobre pensar

O Claude Code tem raciocínio estendido. Você pode definir níveis de esforço: low, medium, high, max. Criamos um botão de alternância na barra de ferramentas. Um pequeno raio que percorre os níveis com pontinhos que vão se acendendo aos poucos. A primeira versão usava um emoji de raio com gambiarras de CSS com filter: saturate(). Feio. Trocamos por um SVG que herda currentColor, como um ícone civilizado.

As cores estavam saturadas demais. Brilho roxo no nível máximo. Animação pulsante. Transformações de escala no ícone. Bastou uma olhada para ficar claro: era preciso baixar o tom. Então suavizamos tudo. Tiramos o brilho, a pulsação, a escala. De um azul suave a um violeta apagado ao longo dos níveis. Discreto.

Depois descobrimos que os blocos de pensamento estavam invisíveis. A função renderAssistant() só extraía blocos de conteúdo text e tool_use. Os blocos de pensamento eram descartados em silêncio. Com o esforço em alto ou máximo, o Claude pensava longamente e o usuário não via nada.

Adicionamos a extração dos blocos de pensamento e passamos a renderizá-los como elementos <details> recolhíveis. O primeiro design usava um destaque roxo na borda esquerda. Ficou feio. Redesenhamos para seguir à risca o padrão dos cards de ferramenta que já existia: os mesmos tokens de CSS, o mesmo border-radius, as mesmas transições de hover, a mesma rotação do chevron. Se parece que faz parte, faz parte.

O indicador de pensamento tinha um problema só dele. hideThinking() era chamada no primeiro evento de streaming do assistente, o que removia os pontinhos de atividade enquanto o Claude ainda estava trabalhando. Na CLI de verdade, o spinner continua lá durante o turno inteiro. Tivemos que inventar um padrão repositionThinking(), que move o indicador para baixo do conteúdo novo conforme as mensagens chegam, com um timer persistente contando os segundos. Só finish() acaba com ele.

O placar de verdade

Seis dias. Quatorze arquivos modificados em duas interfaces completas. Duas reescritas totais do CSS. Três paletas de cores rejeitadas. Um sistema de anexos que precisou aprender o que é um arquivo binário. Um fluxo de permissões que quebrou e foi consertado três vezes, porque a IA insistia em contradizer as próprias correções anteriores. Um botão de pensamento que passou por quatro iterações visuais. Um formato de control_response que só deu para acertar lendo o código-fonte minificado do SDK. Um spawn de subprocesso que falhava em silêncio no Windows por três motivos diferentes.

A skin final tem: uma página de chat independente com seletores de projeto e de branch, um painel lateral deslizante para a visualização 3D, troca de modelo, acompanhamento de custos, anexos de arquivos com validação, cartões de pedido de permissão com Permitir/Negar/Sempre, pensamento estendido com controle de esforço, suporte a comandos de barra, streaming de mensagens parciais, atalhos de teclado e um layout redimensionável. Ela usa zero frameworks. JS puro, sem etapa de build, sem bundler. Cada linha de CSS foi escrita à mão e discutida.

O que realmente aprendemos

Comportamento não documentado é a regra, não a exceção. A exigência da flag --verbose, a mudança no ciclo de vida do --print, o formato de resposta aninhado, o wrapper .cmd do Windows, o bloqueio da configuração de MCP. Nada disso estava em guia nenhum. Descobrimos tudo iniciando o processo e vendo ele falhar.

Planos gerados por IA se contradizem de uma sessão para outra. O mesmo modelo escreve dois planos para o mesmo problema recomendando soluções opostas, os dois com total confiança. Se você está usando IA para construir, precisa de um sistema de memória que registre as decisões e o raciocínio por trás delas. Nós tínhamos um. Mesmo assim, não foi o bastante para impedir que a flag --print fosse readicionada duas vezes.

Cor é um campo minado. O que parece certo isolado parece errado no contexto. O que parece certo com o painel em 420px de largura parece errado em 22%. O que parece certo com uma mensagem parece errado com vinte. Cada decisão visual foi tomada, desfeita e refeita no navegador, com conteúdo real.

E a distância entre "a funcionalidade existe no código" e "a funcionalidade funciona de verdade, de ponta a ponta" é onde vai a maior parte do tempo. A UI de permissões estava totalmente implementada no Dia 3. Só foi funcionar de verdade no Dia 6. Cada componente estava correto isoladamente. Foi na integração que tudo desmoronou.


A skin do Claude Code vem com a Neural Interface do SynaBun. Não é um fork, não é um wrapper, não é um app separado. É o mesmo servidor Express, o mesmo WebSocket, a mesma porta. Só um jeito diferente de olhar para a mesma conversa. Se isso valeu seis dias e uma piora mensurável na saúde mental, é uma pergunta que ainda não estamos prontos para responder.