17. Camada de apresentação do CLI — UiEvent + TerminalRenderer
- Status: Accepted
- Deciders: @michelbr84 + Claude (sessão 2026-09-05)
- Date: 2026-09-05
- Tags: cli, terminal-ux, arquitetura, tracing, testabilidade
- Supersedes: none
- Superseded by: none
- Links:
- Épico: #944 (Garra Terminal UX v2) — este ADR abre a Fase 2
- Issue: #942 (introduce
UiEvent+TerminalRenderer) - Fase 1 entregue: #933 (sinks de tracing separados), #936 (spinner de estado puro), #934 + #935 (layout de conversa e cabeçalho compacto)
- Consome: #937, #938, #941 dependem desta camada existir
Context and Problem Statement
Hoje a saída do garra chat é montada por três donos diferentes que não se
conhecem:
println!/print!diretos espalhados porchat.rs— prompts, avisos de timeout, cancelamento,/help,/context.stream_turn, que escreve os deltas do modelo e o rótulo da resposta numimpl io::Write, e ainda hospeda o spinner como um braço doselect!.tracing, que até o #933 escrevia no mesmo stderr da conversa. O #933 separou os sinks, mas nada impede que uma linha de INFO volte a ser usada como interface — a separação hoje é convenção, não estrutura.
A Fase 1 do épico arrumou o que dava para arrumar sem mexer na arquitetura: o
console ficou limpo, o spinner virou estado puro, o layout ficou moderno. Mas as
issues restantes (#937 eventos de tool, #938 sumarização de output, #941 erros
acionáveis) não são cosméticas — todas precisam saber o que está
acontecendo no runtime para decidir o que desenhar, e hoje a única forma de
saber isso seria ler o log ou espalhar mais println! dentro do runtime.
A pergunta arquitetural: como o runtime conta o que está fazendo, sem que contar vire desenhar?
Decision Drivers
- ★★★★★ Não quebrar a drenagem concorrente do canal limitado. O runtime
empurra deltas por um
mpsccomsend().await; o receptor precisa ser drenado durante a chamada, senão o produtor trava quando o buffer enche — foi o travamento original dogarra chat, documentado emchat.rs. Qualquer desenho novo entra no mesmoselect!, nunca numa task separada. - ★★★★★ Estado puro, relógio de fora. O
spinner.rsjá é assim (SpinnerState::tick, sem relógio interno) e é por isso que cada quadro é afirmável contra umVec<u8>. O renderer herda a regra, não a revoga. - ★★★★
tracingcontinua existindo e independente. O log persistente é diagnóstico, não interface. O renderer não substitui otracing; os dois passam a ter destinos e públicos declarados. - ★★★★ Não-TTY é comportamento definido, não acidente. Hoje
NO_COLOR,TERM=dumbe stdout redirecionado são tratados em dois lugares (spinner::detecteconversation::Style::detect) que já foram escritos para concordar. O renderer passa a ser o dono único dessa decisão. - ★★★ Migração incremental. "Existing chat behavior remains functional" é critério de aceite do próprio épico. Nenhum PR desta fase pode exigir um big-bang.
Considered Options
A. Manter println! direto e adicionar helpers (status quo estendido)
Barato e sem arquitetura nova. Mas cada issue da Fase 2 precisaria alcançar o
runtime de algum jeito, e o jeito disponível seria passar mais um impl Write
(ou pior, um &mut dyn FnMut) por dentro das assinaturas do AgentRuntime —
acoplando o crate de agentes à apresentação do CLI. É exatamente o que o #942
existe para evitar.
B. Adotar um framework de TUI (ratatui / crossterm full-screen)
Rejeitado, e vale registrar por quê para ninguém propor de novo: um TUI de tela
cheia toma a tela alternativa do terminal, o que quebra o scrollback (a
conversa some ao sair), quebra o pipe (garraia 2>/dev/null | cat deixa de
ter sentido) e obriga a redesenhar em vez de acrescentar linhas. O Garra é uma
CLI de conversa em fluxo, não um painel: a saída é uma sequência de linhas que o
usuário rola, copia e redireciona. O custo do framework é alto e o benefício —
layout bidimensional — é justamente o que não queremos.
C. UiEvent + TerminalRenderer (escolhida)
Um enum de eventos de interface que o produtor emite e um renderer que decide como (e se) desenhar cada um.
Decision
Adotamos C.
AgentRuntime
│
├── tracing ──────────────────> arquivo (~/.garraia/garraia.log), redigido
│ e stderr filtrado (#933)
│
└── UiEvent ──> TerminalRenderer ──> impl io::Write (stdout, ou Vec<u8> em teste)
Onde mora
crates/garraia-cli/src/ui/ — no CLI, não em garraia-agents. O runtime não
depende do renderer; ele emite eventos por um canal que o CLI fornece. Um
consumidor que não queira interface (o garra ask, o mcp_server) simplesmente
não conecta renderer nenhum, e nada muda para ele.
O que o renderer possui
- a linha de atividade (o
SpinnerStatemigra para dentro dele, intacto); - o texto streamado da resposta e o rótulo
Garrado #934; - o ciclo de vida das tools (#937) e a sumarização de output grande (#938);
- avisos e erros de usuário (#941);
- a decisão de cor/Unicode/TTY — hoje espalhada entre
spinner::detecteconversation::Style::detect, que passam a ser uma coisa só; - a largura do terminal.
O que o renderer NÃO possui
- o relógio: continua vindo do
tokio::time::intervaldoselect!, passado como tick. O renderer não dorme, não mede tempo de parede, não spawna nada; - o
tracing: umUiEvent::Warningnão vira log, e umwarn!não vira linha de interface. Quando os dois precisam acontecer, o produtor faz os dois explicitamente — a duplicação é intencional e visível.
Invariantes que este ADR fixa
- O renderer é chamado de dentro do
select!destream_turn, nunca de uma task própria. Esta é a linha que protege a drenagem do canal limitado. Um renderer com task própria e canal próprio seria a forma mais natural de escrever isto e é exatamente o bug que já derrubou ogarra chatuma vez. - Todo caminho de desenho aceita um
impl io::Write, então todo teste afirma contra umVec<u8>— inclusive os caminhos ASCII, sem cor e não-TTY, que são os que ninguém exercita à mão e por isso quebram sem ninguém ver. - O cursor nunca é escondido.
\x1b[?25lnão é emitido em lugar nenhum, como já vale para o spinner: nenhum caminho de saída — sucesso, erro, timeout, Ctrl+C, pânico — pode deixar o terminal sem cursor. - Não-TTY não emite sequência de escape alguma. Verificável por teste que
varre a saída procurando
\x1b.
Consequences
Positivas
- #937, #938 e #941 passam a ser trabalho de renderer, não de espalhar
println!pelo runtime. - O
AgentRuntimedeixa de ter qualquer motivo para escrever no terminal, o que torna o crate reusável fora do CLI sem carregar apresentação junto. - Snapshot test vira o modo default de testar UX: hoje os invariantes do spinner já são afirmados assim, e a Fase 2 herda a prática em vez de inventá-la.
Negativas, assumidas
- Uma indireção a mais entre "aconteceu" e "apareceu". Para o
println!de duas linhas do/helpisso é overhead puro — por isso comandos síncronos que não participam de um turno podem continuar escrevendo direto; o renderer é para o que compete pela mesma linha durante o streaming. - Risco de reintroduzir o travamento se alguém, no futuro, "melhorar" o
renderer dando a ele uma task própria. Mitigado pelo invariante 1 estar aqui e
no doc de
stream_turn, e por um teste que falha se o spinner deixar de ser braço doselect!. - Duas verdades sobre o mesmo evento quando algo precisa ser logado e mostrado. É deliberado: unificar os dois foi o que fez o console virar despejo de INFO, que é o problema que o #933 acabou de consertar.
Plano de migração (incremental, sem big-bang)
- #942 cria
ui/com o enum e o renderer, e migra para dentro dele o que já é puro: oSpinnerStatee oconversation::Style. Comportamento visível idêntico — o PR se prova por os testes existentes continuarem passando sem alteração. - #937 acrescenta os eventos de tool, que hoje não existem no stream. É a primeira capacidade nova, e é o que destrava o "retomar o spinner após a tool" que ficou de fora do #936 por não haver evento para ouvir.
- #938 e #941 entram como novos braços do mesmo enum.
- Os
println!de comandos síncronos (/help,/context,/history) migram por último, ou não migram — decisão do PR que encostar neles.
Verificação
cargo +1.95 test -p garraia --bin garra ui:: # snapshot do renderer
cargo +1.95 test -p garraia --bin garra spinner:: # invariantes preservados
cargo +1.95 test -p garraia --bin garra conversation::
Fim a fim: garraia interativo comparado ao mockup do épico; NO_COLOR=1,
TERM=dumb e garraia 2>/dev/null | cat para os três caminhos degradados.