O Zé, uma capivara em pixel art que trabalha junto acima do prompt. Cada subagente vira uma capivarinha com o traje do tipo dela, trabalha com o adereço da…

<img src="docs/turma.gif" alt="Zé and his crew of little capybaras working above the Claude Code prompt" width="100%">
<h1 align="center">Zé, the Claude Code capybara</h1>
<strong>Your subagents become a crew of pixel art capybaras working right above your prompt.</strong>
<a href="LICENSE"><img alt="License Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue"></a> <img alt="Claude Code mod" src="https://img.shields.io/badge/Claude%20Code-mod-d97757"> <img alt="100% local" src="https://img.shields.io/badge/100%25-local-3fae4a"> <a href="https://github.com/headdira/ze-capibara/actions/workflows/test.yml"><img alt="tests" src="https://github.com/headdira/ze-capibara/actions/workflows/test.yml/badge.svg"></a> <a href="https://github.com/headdira/ze-capibara/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/headdira/ze-capibara?style=social"></a>
<strong>English</strong> · <a href="README.pt-BR.md">Português</a>
Zé is a Claude Code mod. He lives in the band above your prompt and shows, in pixel art, what Claude is doing right now. Every subagent Claude spawns becomes a little capybara wearing the outfit of its type. It works with a prop that matches the tool it is using and, when it finishes, it goes to relax in the hot tub.
Everything runs on your machine. Zé makes no network calls.
If Zé made you smile, a ⭐ helps other people find it.
You don't need to read the rest of this README to get started. Open a terminal:
git clone https://github.com/headdira/ze-capibara ~/ze-capibara
cd ~/ze-capibara
claude
With Claude open in that folder, paste this prompt:
Install Zé from this repository into my Claude Code following CLAUDE.md.
Claude reads the repository's CLAUDE.md, installs the plugin from your clone and checks that it worked. Then open a new session and Zé shows up above the prompt.
Claude Code does a lot at once. It reads files, runs commands, calls MCP servers and hands work to subagents that run in parallel. Most of that scrolls by as text.
Zé turns it into something you read at a glance. You see when Claude is reading, typing or building, how many subagents are working, which ones finished and which one fell over. You see when a risky command is about to run. And it makes long sessions a bit more fun.
If you'd rather run the commands yourself:
git clone https://github.com/headdira/ze-capibara ~/ze-capibara
claude plugin marketplace add ~/ze-capibara
claude plugin install ze@ze-capibara
claude plugin list
In the claude plugin list output, look for Zé's entry:
❯ ze@ze-capibara
Read from: /Users/you/ze-capibara/ze
Status: ✔ enabled
Read from must point to the ze folder inside your clone. Open a new Claude Code session and you're done.
Why clone? Claude reads the mod straight from your clone. The outfits you create work in the next session and don't vanish when the plugin updates.
If you only want to see Zé in action and don't plan to create outfits:
claude plugin marketplace add headdira/ze-capibara
claude plugin install ze@ze-capibara
In this mode Claude keeps the plugin in an internal cache, and claude plugin list shows no Read from line. Any edit you make there is lost on the next update. To customize, use the clone.
rm -rf or git push --force.<img src="docs/momentos.png" alt="Seven moments of a turn with Zé and the crew" width="100%">
| Command | What it does |
|---|---|
/ze | Shows what Zé is doing and who is in the crew |
/ze pula | Makes Zé jump |
/ze esconder | Hides Zé from above the prompt |
/ze mostrar | Brings Zé back |
Zé grew up in Brazil, so his commands are in Portuguese: pula is "jump", esconder is "hide" and mostrar is "show".
The plugin ships with three skills. With Zé installed, paste one of these into any Claude session and tweak it to your taste:
| Paste into Claude | Skill that handles it |
|---|---|
Create a Zé outfit for my api-tester agent with a cowboy hat and a magnifying glass | ze:criar-boneco |
Create a sql-reviewer subagent that runs on sonnet and give it an outfit | ze:criar-agente and ze:criar-boneco |
Create a skill that generates a changelog from git log | ze:criar-skill |
The outfit skill draws the hat and the prop in pixel art, registers the outfit, writes a test and runs the tests until they pass. The skills are written in Portuguese, and Claude answers you in your own language.
Zé picks each little capybara's outfit from the subagent type, in this order:
TRAJES). Types with their own outfit, like explore (safari hat), plan (beret and clipboard), general-purpose (cap) and fork (Zé's orange).PALAVRAS). If the type isn't in the table, Zé looks for a known word in its name. The first rule that matches wins.| If the name has | Outfit |
|---|---|
review | pen and glasses |
secur, secret, audit, scan | detective hat and padlock |
test | clipboard and glasses |
analy, extract | book and glasses |
verif, valid, judge, check | graduation cap and clipboard |
generat, scaffold, creat, migrat, build | hard hat and wrench |
simplif, refactor, clean | cap and scissors |
perf, optim | headband and lightning bolt |
deploy, devops, infra, k8s, cloud, ops | hard hat |
explor, research, search | safari hat |
architect, design, plan | beret and clipboard |
math, proof | graduation cap |
doc, writ, copy, market | pink beret and pencil |
So an agent named sql-reviewer already shows up with a pen and glasses, and you don't touch a thing.
<img src="docs/trajes-plugins.png" alt="Little capybaras dressed by the word in the agent's name" width="80%">
Outfits live in ze/hooks/turma.ts. Each drawing is a list of strings. Each letter is a color from the PAL palette and . is transparent.
This example creates a top hat and a magnifying glass, gives both to the meu-agente agent and dresses any agent with sql, banco or dados in its name.
In CHAPEUS (hats):
cartola: { arte: ['.PPPP.', '.PPPP.', '.RRRR.', 'PPPPPP'], x: 6, y: -3 },
In ITENS (props):
lupa: ['.MM', 'MlM', 'MM.', 'h..'],
In TRAJES (fixed table):
'meu-agente': { rotulo: 'meu', cor: PAL.R!, chapeu: CHAPEUS.cartola, item: ITENS.lupa, oculos: true },
At the top of PALAVRAS (word rules):
[/sql|banco|dados/, { cor: PAL.u!, chapeu: CHAPEUS.cartola, item: ITENS.lupa }],
And a test in ze/tests/meu-agente.test.ts:
import { expect, test } from 'claude-code/testing'
import { traje } from '../hooks/turma'
test('boneco do meu-agente', () => {
expect(traje('meu-agente').rotulo).toBe('meu')
expect(traje('plugin:banco-agent').chapeu?.arte[0]).toBe('.PPPP.')
})
Run claude plugin test ze from the root of your clone and open a new session to see it.
ze-capibara/
├── .claude-plugin/
│ └── marketplace.json the "ze-capibara" marketplace
├── .github/ test CI, issue and PR templates
├── CLAUDE.md instructions for the Claude running in the clone
├── CONTRIBUTING.md how to contribute
├── README.md this README
├── README.pt-BR.md README in Portuguese
├── docs/ README images
└── ze/ the plugin
├── .claude-plugin/
│ └── plugin.json
├── hooks/
│ ├── hooks.json
│ ├── register.tsx Claude Code events and the /ze command
│ ├── palco.tsx the band above the prompt
│ ├── sprites.ts Zé's drawing
│ ├── turma.ts the little capybaras and their outfits
│ └── fala.ts pose and speech bubble for each tool
├── skills/
│ ├── criar-boneco/ create an outfit
│ ├── criar-agente/ create a subagent
│ └── criar-skill/ create a skill
├── tests/
└── types/
From the root of your clone:
claude plugin test ze
The command runs every *.test.ts and *.test.tsx in ze/tests in the same environment the hooks run in. CI runs the same command on every push and pull request.
ze/hooks/turma.ts and a test in ze/tests/.claude plugin test ze until it's green.New outfits, more word rules and poses for more tools are all welcome. The step by step is in CONTRIBUTING.md. Want an outfit but don't feel like drawing? Open an issue with the "New outfit / Novo boneco" template.
Apache-2.0. Created by Gerson Moreira.
hooks/register.tsx 253 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Acao, Ajudante, Cena } from '../types'
5import { curto, papel } from './fala'
6import { LINHAS, linhas } from './sprites'
7import { nomeDe } from './turma'
8
9const CENA: Cena = { acao: 'ocioso', depois: 'ocioso', balao: '', seq: 0, ajudantes: [] }
10
11/** Quantas capivarinhas a cena guarda: as que já acabaram saem primeiro. */
12const MAX_TURMA = 24
13
14const cena = atom({ plugin: 'ze', key: 'cena' } as const, CENA)
15const ativos = atom({ plugin: 'ze', key: 'ativos' } as const, 0)
16const oculto = atom({ plugin: 'ze', key: 'oculto' } as const, false)
17
18const AJUDA = [
19 '/ze mostra o que o Zé está fazendo',
20 '/ze pula faz o Zé pular',
21 '/ze esconder tira o Zé de cima do prompt',
22 '/ze mostrar traz o Zé de volta',
23].join('\n')
24
25const duracao = (ms: number) => {
26 const s = Math.round(ms / 1000)
27
28 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}`
29}
30
31/** Troca a cena: a ação, o que vem depois dela e a fala. */
32const muda = async ($: EngineInterface, acao: Acao, depois: Acao, balao = '') =>
33 update($, cena, c => ({ ...c, acao, depois, balao, seq: c.seq + 1 }))
34
35/** Uma capivarinha nova chega (ou volta, num resume) pra turma. */
36const entra = async ($: EngineInterface, nova: Ajudante) =>
37 update($, cena, (c): Cena => {
38 const ja = c.ajudantes.find(a => a.id === nova.id)
39 let turma = ja
40 ? c.ajudantes.map(a => (a.id === nova.id ? { ...a, estado: 'trabalhando' as const, acao: nova.acao, balao: nova.balao, seq: a.seq + 1 } : a))
41 : [...c.ajudantes, nova]
42
43 while (turma.length > MAX_TURMA) {
44 const sai = turma.findIndex(a => a.estado !== 'trabalhando')
45 turma = turma.filter((_, i) => i !== (sai < 0 ? 0 : sai))
46 }
47
48 return { ...c, ajudantes: turma }
49 })
50
51/** Muda uma capivarinha da turma; devolve como ela ficou. */
52const ajusta = async ($: EngineInterface, id: string, muda: (a: Ajudante) => Ajudante) => {
53 let depois: Ajudante | undefined
54
55 await update($, cena, (c): Cena => {
56 if (!c.ajudantes.some(a => a.id === id)) {
57 return c
58 }
59
60 return {
61 ...c,
62 ajudantes: c.ajudantes.map(a => {
63 if (a.id !== id) {
64 return a
65 }
66
67 const novo = muda(a)
68 depois = novo
69
70 return novo === a ? a : { ...novo, seq: a.seq + 1 }
71 }),
72 }
73 })
74
75 return depois
76}
77
78const descreveTurma = (turma: Ajudante[]) =>
79 turma.length
80 ? turma
81 .map(a => `${nomeDe(a)} ${a.estado === 'pronto' ? '(no ofurô)' : a.estado === 'erro' ? '(caiu)' : a.balao ? `(${a.balao})` : ''}`.trim())
82 .join(', ')
83 : 'ninguém'
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 await $.command.register({ name: 'ze', description: 'O Zé, a capivara que trabalha junto acima do prompt' })
88 await update($, ativos, () => 0)
89 await update($, cena, (c): Cena => ({ ...CENA, seq: c.seq + 1, balao: 'e aí! bora trabalhar?', acao: 'pula', depois: 'ocioso' }))
90
91 return next(e)
92 })
93
94 on('turn.start', async ($, e, next) => {
95 /** O loop de um subagente também começa turno: esse não é do Zé. */
96 if (e.agentId !== undefined) {
97 return next(e)
98 }
99
100 await update($, ativos, () => 0)
101 /** Prompt novo: o ofurô esvazia, quem ainda trabalha (em segundo plano) fica. */
102 await update($, cena, (c): Cena => ({ ...c, ajudantes: c.ajudantes.filter(a => a.estado === 'trabalhando') }))
103 await muda($, 'pula', 'pensando', 'bora!')
104
105 return next(e)
106 }).catch(($, e, next) => next(e))
107
108 on('tool.call', async ($, e, next) => {
109 const principal = e.agentId === undefined
110
111 if (e.agentId !== undefined) {
112 const { acao, balao } = papel(e.tool, e)
113 const id = e.agentId
114 const agora = await ajusta($, id, a => ({ ...a, acao, balao: curto(balao, 15), estado: 'trabalhando' }))
115 const r = await next(e)
116
117 if (agora) {
118 await ajusta($, id, a => (a.seq === agora.seq + 1 && a.estado === 'trabalhando' ? { ...a, acao: 'pensando' } : a))
119 }
120
121 return r
122 }
123
124 if (principal) {
125 await update($, ativos, n => n + 1)
126 const { acao, balao } = papel(e.tool, e)
127 await muda($, acao, acao === 'panico' || acao === 'chamando' ? 'pensando' : acao, balao)
128 }
129
130 const r = await next(e)
131
132 if (principal) {
133 await update($, ativos, n => Math.max(0, n - 1))
134 const restam = await read($, ativos)
135
136 if (r.deny !== undefined) {
137 await muda($, 'caido', 'pensando', 'barrado!')
138 } else if (r.isError) {
139 await muda($, 'caido', 'pensando', 'ai, deu erro')
140 } else if (restam <= 0) {
141 await muda($, 'pensando', 'pensando')
142 }
143 }
144
145 return r
146 }).catch(($, e, next) => next(e))
147
148 on('agent.spawn', async ($, e, next) => {
149 const r = await next(e)
150
151 if (r.deny === undefined && r.agentId) {
152 await entra($, {
153 id: r.agentId,
154 tipo: e.fork ? 'fork' : e.subagentType,
155 nome: e.name ?? '',
156 acao: 'pensando',
157 balao: curto(e.description, 15),
158 estado: 'trabalhando',
159 fundo: e.background,
160 seq: 0,
161 })
162 }
163
164 return r
165 }).catch(($, e, next) => next(e))
166
167 on('turn.complete', async ($, e, next) => {
168 const r = await next(e)
169
170 if (e.agentId) {
171 const deuCerto = e.reason === 'answer'
172 const quem = await ajusta($, e.agentId, a => ({
173 ...a,
174 estado: deuCerto ? 'pronto' : 'erro',
175 acao: deuCerto ? 'comemora' : 'caido',
176 balao: '',
177 }))
178
179 if (quem) {
180 await update($, cena, (c): Cena => ({ ...c, balao: deuCerto ? `valeu, ${nomeDe(quem)}!` : `${nomeDe(quem)} caiu…` }))
181 }
182
183 return r
184 }
185
186 await update($, ativos, () => 0)
187 /** O turno principal acabou: quem ficou "trabalhando" sem ser de segundo plano foi interrompido. */
188 await update($, cena, (c): Cena => ({ ...c, ajudantes: c.ajudantes.filter(a => a.fundo || a.estado !== 'trabalhando') }))
189
190 if (e.isAborted) {
191 await muda($, 'caido', 'ocioso', 'parei!')
192 } else if (e.reason === 'error') {
193 await muda($, 'caido', 'ocioso', 'deu ruim…')
194 } else {
195 await muda($, 'comemora', 'ocioso', `feito em ${duracao(e.durationMs)}!`)
196 }
197
198 return r
199 }).catch(($, e, next) => next(e))
200
201 on('command.run', { command: 'ze' }, async ($, e) => {
202 const pedido = e.args.trim().toLowerCase()
203
204 if (pedido === 'esconder' || pedido === 'mostrar') {
205 await update($, oculto, () => pedido === 'esconder')
206
207 return { text: pedido === 'esconder' ? 'O Zé foi tomar um café. /ze mostrar traz ele de volta.' : 'O Zé voltou!' }
208 }
209
210 if (pedido === 'pula') {
211 const c = await read($, cena)
212 await muda($, 'pula', c.acao === 'pula' ? c.depois : c.acao, 'opa!')
213
214 return { text: 'O Zé pulou.' }
215 }
216
217 if (pedido !== '') {
218 return { text: AJUDA }
219 }
220
221 const c = await read($, cena)
222
223 return { text: `Zé: ${c.acao}${c.balao ? ` (“${c.balao}”)` : ''} · turma: ${descreveTurma(c.ajudantes)}\n${AJUDA}` }
224 })
225
226 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227 const abaixo = await next(e)
228
229 const c = await read($, cena)
230 /** Uma linha fica pro que vem embaixo; com pouco espaço a faixa encolhe em vez de sumir. */
231 const espaco = e.props.maxRows - 1
232
233 if (e.props.hasSurvey || (await read($, oculto)) || espaco < LINHAS) {
234 return abaixo
235 }
236
237 const elementos = $.ui.resolve(e)
238
239 if (!('Client' in elementos)) {
240 return abaixo
241 }
242
243 const { Box, Client } = elementos
244
245 return (
246 <Box flexDirection="column">
247 <Client key="ze" module="./palco.tsx" props={{ ...c, espaco }} height={linhas(c, espaco)} />
248 {abaixo}
249 </Box>
250 )
251 })
252}
253hooks/fala.ts 88 lines1/** O que o Zé diz no balão: curto, sem conteúdo, credencial mascarada. */
2
3import type { Acao } from '../types'
4
5const SEGREDOS: [RegExp, string][] = [
6 [/(bearer\s+)\S+/gi, '$1•••'],
7 [/((?:password|passwd|senha|secret|token|api[_-]?key|access[_-]?key)\s*[=:]\s*)[^\s'"&]+/gi, '$1•••'],
8 [/(--(?:password|token|secret|api-key|auth-token)[= ])\S+/gi, '$1•••'],
9 [/\b(sk-(?:[a-z]+-)?)[A-Za-z0-9_-]{6,}/g, '$1•••'],
10 [/\b(gh[pousr]_)[A-Za-z0-9]{10,}/g, '$1•••'],
11 [/(:\/\/[^:/\s]+:)[^@\s]+@/g, '$1•••@'],
12 [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g, 'jwt•••'],
13]
14
15export const mascara = (texto: string) => SEGREDOS.reduce((acc, [re, troca]) => acc.replace(re, troca), texto)
16
17export const curto = (texto: string, max = 26) => {
18 const linha = mascara(texto).replace(/\s+/g, ' ').trim()
19
20 return linha.length > max ? `${linha.slice(0, max - 1)}…` : linha
21}
22
23const ZONA_VERMELHA: [RegExp, string][] = [
24 [/\bkubectl\b[^|;&]*\b(apply|delete|edit|patch|replace|scale|drain)\b/, 'kubectl no cluster!'],
25 [/\bargocd\b[^|;&]*\bapp\s+(sync|delete|set|rollback)\b/, 'argocd sync!'],
26 [/\bgit\s+push\b[^|;&]*(\s--force\b|\s-f\b)/, 'push --force!'],
27 [/\bgit\s+push\b[^|;&]*\s(origin\s+)?(main|master)\b/, 'push na main!'],
28 [/\bgit\s+reset\s+--hard\b/, 'reset --hard!'],
29 [/\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/, 'rm -rf!'],
30 [/\b(helm\s+(upgrade|uninstall)|terraform\s+(apply|destroy))\b/, 'mexendo na infra!'],
31 [/\b(drop\s+(table|database)|truncate\s+table)\b/i, 'SQL destrutivo!'],
32]
33
34export const zonaVermelha = (comando: string) => ZONA_VERMELHA.find(([re]) => re.test(comando))?.[1] ?? null
35
36const base = (caminho: string) => caminho.split('/').filter(Boolean).pop() ?? caminho
37
38const campo = (obj: unknown, chave: string) => {
39 const valor = obj && typeof obj === 'object' ? (obj as Record<string, unknown>)[chave] : undefined
40
41 return typeof valor === 'string' ? valor : null
42}
43
44/** A ação e a fala para uma ferramenta que começou. */
45export const papel = (tool: string, input: unknown): { acao: Acao; balao: string } => {
46 if (tool.startsWith('mcp__')) {
47 const servidor = (tool.split('__')[1] ?? 'mcp').replace(/^plugin_[^_]+(?:-[^_]+)*_/, '').replace(/^claude_ai_/, '')
48
49 return { acao: 'telefone', balao: `alô, ${servidor}?` }
50 }
51
52 switch (tool) {
53 case 'Bash': {
54 const cmd = campo(input, 'command') ?? ''
55 const perigo = zonaVermelha(cmd)
56
57 return perigo ? { acao: 'panico', balao: perigo } : { acao: 'digitando', balao: `$ ${curto(cmd, 24)}` }
58 }
59 case 'Read':
60 return { acao: 'procurando', balao: `lendo ${curto(base(campo(input, 'file_path') ?? ''), 22)}` }
61 case 'Grep':
62 return { acao: 'procurando', balao: `caçando ${curto(campo(input, 'pattern') ?? '', 20)}` }
63 case 'Glob':
64 return { acao: 'procurando', balao: `achando ${curto(campo(input, 'pattern') ?? '', 20)}` }
65 case 'Edit':
66 case 'MultiEdit':
67 case 'Write':
68 case 'NotebookEdit':
69 return {
70 acao: 'construindo',
71 balao: `${tool === 'Write' ? 'criando' : 'mexendo em'} ${curto(base(campo(input, 'file_path') ?? campo(input, 'notebook_path') ?? ''), 20)}`,
72 }
73 case 'Agent':
74 case 'Task':
75 return { acao: 'chamando', balao: `ô ${curto(campo(input, 'subagent_type') ?? 'parceiro', 18)}, ajuda aqui!` }
76 case 'WebFetch': {
77 const url = campo(input, 'url') ?? ''
78 const host = /^https?:\/\/([^/]+)/.exec(url)?.[1] ?? 'web'
79
80 return { acao: 'procurando', balao: `na web: ${curto(host, 22)}` }
81 }
82 case 'WebSearch':
83 return { acao: 'procurando', balao: 'pesquisando na web' }
84 default:
85 return { acao: 'pensando', balao: curto(tool, 22) }
86 }
87}
88hooks/sprites.ts 390 lines1/**
2 * O Zé: capivara em pixel art, com laranja na cabeça. Puro: nada aqui toca
3 * o `$` nem o surface. Cada caractere do terminal são 2 pixels (▀ com cor de
4 * frente em cima e cor de fundo embaixo), então a faixa de 5 linhas tem 10px.
5 */
6
7import type { Acao, Cena } from '../types'
8
9import { arranjo, avancaTurma, pintaTurma, recebeTurma, rotulos } from './turma'
10import type { Marcas } from './turma'
11
12export type { Acao, Cena }
13
14export type Palco = {
15 tick: number
16 x: number
17 dir: 1 | -1
18 seq: number
19 inicio: number
20 /** Relógio de cada capivarinha da turma, por agentId. */
21 turma?: Marcas
22}
23
24export type Pedaco = { t: string; cor?: string; fundo?: string }
25
26export const LINHAS = 5
27/** Com a turma a faixa ganha uma linha: os nomes ficam em cima delas. */
28export const LINHAS_TURMA = 6
29export const ALTURA = LINHAS * 2
30export const TIQUE_MS = 120
31
32/** Quantos tiques dura cada ação passageira antes de virar `cena.depois`. */
33export const DURACAO: Partial<Record<Acao, number>> = { pula: 9, comemora: 18, caido: 20, panico: 24, chamando: 14 }
34
35const COR: Record<string, string> = {
36 B: '#b5835a', // corpo
37 D: '#8a5a36', // sombra, orelha, pés
38 H: '#d6a274', // brilho do lombo
39 N: '#3b2a20', // focinho
40 E: '#111111', // olho
41 O: '#ff9500', // laranja
42 o: '#d96f00', // laranja escura
43 L: '#3fae4a', // folhinha
44 G: '#3f8f3f', // grama
45 g: '#2f6f2f', // grama escura
46 F: '#f2d14b', // flor
47 S: '#9aa3ad', // notebook
48 C: '#4fd1ff', // tela acesa
49 c: '#2a8fb8', // tela
50 Y: '#f2c94c', // amarelo: lupa, faísca, estrela
51 l: '#c9f1ff', // lente
52 h: '#8b5a2b', // cabo
53 M: '#6b7280', // metal
54 P: '#2d2d2d', // celular
55 R: '#ef4444', // vermelho
56 W: '#7dd3fc', // suor
57 T: '#e5e7eb', // pensamento
58 p: '#f472b6', // confete rosa
59 v: '#a78bfa', // confete roxo
60}
61
62const CONFETE = ['R', 'Y', 'C', 'p', 'v', 'L', 'O']
63
64/** Pixel art: '.' é transparente, as letras saem de COR. 17x8, olhando pra direita. */
65const ZE = (olho: string, pernas: string) => [
66 '...........LOO...',
67 '..........OOOOo..',
68 '....HHHHHHHD.D...',
69 '..HBBBBBBBBBBBB..',
70 `.BBBBBBBBBBBB${olho}BB.`,
71 '.BBBBBBBBBBBBBBBN',
72 '..BBBBBBBBBBDBBN.',
73 pernas,
74]
75
76const PERNAS_A = '..DB.DB...DB.DB..'
77const PERNAS_B = '...DBDB....DBDB..'
78const SENTADA = '..DDDD....DDDD...'
79
80const CAIDA = [
81 '.................',
82 '.................',
83 '...DB.DB..DB.DB..',
84 '..BBBBBBBBBBBB...',
85 '.BBBBBBBBBBBBBBN.',
86 '.BBBBBBBBBBBBEBB.',
87 '..HHHHHHHHHHB....',
88 'OO...............',
89]
90
91type Tela = (string | null)[][]
92
93const tela = (largura: number, altura: number): Tela =>
94 Array.from({ length: altura }, () => Array.from({ length: largura }, () => null))
95
96const pinta = (t: Tela, arte: string[], x: number, y: number) =>
97 arte.forEach((linha, i) =>
98 Array.from(linha).forEach((ch, j) => {
99 const fila = t[y + i]
100
101 if (ch !== '.' && fila && x + j >= 0 && x + j < fila.length) {
102 fila[x + j] = ch
103 }
104 }),
105 )
106
107const ponto = (t: Tela, x: number, y: number, ch: string) => pinta(t, [ch], x, y)
108
109/** Pseudoaleatório estável por tique: o confete não pisca igual sempre. */
110const sorte = (n: number) => {
111 const s = Math.sin(n * 12.9898) * 43758.5453
112
113 return s - Math.floor(s)
114}
115
116const pisca = (tick: number) => tick % 37 === 0 || tick % 37 === 1
117
118/** A ação que vale agora: a passageira até vencer, depois `cena.depois`. */
119export const vigente = (cena: Cena, palco: Palco): Acao => {
120 const dura = DURACAO[cena.acao]
121
122 return dura !== undefined && palco.tick - palco.inicio >= dura ? cena.depois : cena.acao
123}
124
125const ATOR = 27
126
127/** Quantas colunas o Zé pode andar sem pisar na turma. */
128export const pista = (largura: number, cena: Cena, palco?: Palco) =>
129 Math.max(4, arranjo(cena, largura, palco?.tick ?? 0, palco?.turma ?? {}, ATOR).inicio - ATOR)
130
131/** Um tique de física: anda, quica nas bordas, para de vez em quando. */
132export const avanca = (palco: Palco, cena: Cena, largura: number): Palco => {
133 const tick = palco.tick + 1
134 const acao = vigente(cena, { ...palco, tick })
135 const fim = pista(largura, cena, palco)
136 const pausa = acao === 'ocioso' && Math.floor(tick / 40) % 3 === 2
137 const ritmo = acao === 'panico' ? 1 : acao === 'ocioso' ? 2 : acao === 'procurando' ? 3 : 0
138 let { x, dir } = palco
139
140 if (ritmo && !pausa && tick % ritmo === 0) {
141 x += dir
142
143 if (x <= 0 || x >= fim) {
144 dir = dir === 1 ? -1 : 1
145 x = Math.max(0, Math.min(fim, x))
146 }
147 }
148
149 const turma = avancaTurma(palco.turma ?? {}, cena, largura, tick, ATOR)
150
151 return { ...palco, tick, x: Math.min(x, fim), dir, turma }
152}
153
154/** Nova cena chegou: reinicia o relógio da ação. */
155export const recebe = (palco: Palco, cena: Cena): Palco => {
156 const p = cena.seq === palco.seq ? palco : { ...palco, seq: cena.seq, inicio: palco.tick }
157
158 const turma = recebeTurma(p.turma ?? {}, cena, p.tick)
159
160 return turma === p.turma ? p : { ...p, turma }
161}
162
163/**
164 * Quantas linhas a faixa usa: com a turma cresce até LINHAS_TURMA, mas nunca
165 * passa do espaço que o terminal deu (`espaco`), nem fica menor que LINHAS.
166 */
167export const linhas = (cena: Cena, espaco = LINHAS_TURMA) =>
168 Math.max(LINHAS, Math.min(cena.ajudantes.length ? LINHAS_TURMA : LINHAS, espaco))
169
170/** Desenha o Zé e o adereço da ação numa tela própria de ATOR x ALTURA. */
171const ator = (acao: Acao, palco: Palco): Tela => {
172 const t = tela(ATOR, ALTURA)
173 const k = palco.tick - palco.inicio
174 const olho = pisca(palco.tick) ? 'B' : 'E'
175 const passo = Math.floor(palco.tick / 2) % 2 === 0
176 const topo = 1
177
178 switch (acao) {
179 case 'pula':
180 case 'comemora': {
181 const ciclo = acao === 'comemora' ? k % 9 : k
182 const dy = [0, 1, 2, 2, 2, 1, 0, 0, 0][ciclo] ?? 0
183 pinta(t, ZE(olho, dy ? PERNAS_B : SENTADA), 0, topo - dy)
184
185 if (acao === 'comemora') {
186 for (let i = 0; i < 9; i++) {
187 const r = sorte(palco.tick * 13 + i)
188 ponto(t, Math.floor(r * ATOR), Math.floor(sorte(r * 7 + i) * 5), CONFETE[i % CONFETE.length] ?? 'Y')
189 }
190 }
191
192 return t
193 }
194 case 'digitando':
195 pinta(t, ZE(olho, k % 2 ? '..DDDD....DD.D...' : '..DDDD....D.DD...'), 0, topo)
196 pinta(t, ['.....S', '.....S', '.....S', 'SSSSSS'], 17, 5)
197 pinta(t, [k % 4 < 2 ? 'C' : 'c', 'C', k % 3 ? 'c' : 'C'], 21, 5)
198
199 return t
200 case 'procurando': {
201 const sobe = Math.floor(k / 3) % 2
202 pinta(t, ZE(olho, passo ? PERNAS_A : PERNAS_B), 0, topo)
203 pinta(t, ['.Y.', 'YlY', '.Y.', 'h..'], 17, 3 - sobe)
204
205 return t
206 }
207 case 'construindo':
208 pinta(t, ZE(olho, SENTADA), 0, topo)
209
210 if (Math.floor(k / 2) % 2) {
211 pinta(t, ['MMM', '.h.', '.h.', '.h.'], 17, 1)
212 } else {
213 pinta(t, ['....MM', 'hhhhMM.Y', '......Y.'], 17, 6)
214 }
215
216 pinta(t, ['DD', 'DDD'].slice(0, 1 + (Math.floor(palco.tick / 30) % 2)), 24, 7)
217
218 return t
219 case 'telefone':
220 pinta(t, ZE(olho, SENTADA), 0, topo)
221 pinta(t, ['P', 'P'], 17, 3)
222 ;[19, 21, 23].forEach((x, i) => {
223 if (k % 6 >= i * 2) {
224 pinta(t, ['C', '.', 'C'], x, 2)
225 }
226 })
227
228 return t
229 case 'chamando':
230 pinta(t, ZE(olho, SENTADA), 0, topo)
231 pinta(t, ['..oo', 'oOOO', '..oo'], 17, 3)
232 ;[22, 24].forEach((x, i) => {
233 if (k % 4 >= i * 2) {
234 pinta(t, ['T', '.', 'T'], x, 3)
235 }
236 })
237
238 return t
239 case 'pensando': {
240 pinta(t, ZE(olho, SENTADA), 0, topo)
241 const bolinhas = 1 + (Math.floor(palco.tick / 4) % 3)
242 ;[18, 20, 22].slice(0, bolinhas).forEach((x, i) => ponto(t, x, 2 - i, 'T'))
243
244 return t
245 }
246 case 'caido':
247 pinta(t, CAIDA, 0, topo)
248 ;[3, 8, 13].forEach((x, i) => {
249 if ((palco.tick + i) % 4 < 2) {
250 ponto(t, x, (palco.tick + i) % 2, 'Y')
251 }
252 })
253
254 return t
255 case 'panico':
256 pinta(t, ZE('E', passo ? PERNAS_A : PERNAS_B), 0, topo)
257 pinta(t, ['R', 'R', 'R', '.', 'R'], 18, 0)
258 ponto(t, 5, 2 + (k % 3), 'W')
259
260 return t
261 case 'ocioso':
262 pinta(t, ZE(olho, passo ? PERNAS_A : PERNAS_B), 0, topo)
263
264 return t
265 }
266}
267
268const espelha = (t: Tela): Tela => t.map(fila => [...fila].reverse())
269
270/** O chão: grama com tufos e flores, sempre igual pra largura. */
271const chao = (t: Tela) => {
272 const fila = t[t.length - 1]
273
274 if (fila) {
275 fila.forEach((_, c) => {
276 fila[c] = c % 17 === 3 ? 'F' : (c * 7) % 5 === 0 ? 'g' : 'G'
277 })
278 }
279}
280
281const BALAO_MAX = 34
282
283/** Mapa de pixels da faixa inteira: útil pro desenho e pros testes. */
284export const pixels = (palco: Palco, cena: Cena, largura: number, alto = ALTURA): Tela => {
285 const w = Math.max(ATOR + 2, largura)
286 const t = tela(w, Math.max(ALTURA, alto))
287 const ceu = t.length - ALTURA
288 const acao = vigente(cena, palco)
289 const andando = acao === 'ocioso' || acao === 'panico' || acao === 'procurando'
290 const a = ator(acao, palco)
291 const virado = andando && palco.dir === -1 ? espelha(a) : a
292
293 chao(t)
294 pintaTurma(t, cena, palco.turma ?? {}, palco.tick, ATOR)
295
296 virado.forEach((fila, y) =>
297 fila.forEach((px, x) => {
298 const destino = t[ceu + y]
299
300 if (px && destino && palco.x + x < w) {
301 destino[palco.x + x] = px
302 }
303 }),
304 )
305
306 return t
307}
308
309type Celula = { t: string; cor?: string; fundo?: string }
310
311/** Dois pixels por caractere: ▀ com a cor de cima na frente e a de baixo no fundo. */
312const celula = (cima: string | null, baixo: string | null): Celula => {
313 const c = cima ? COR[cima] ?? (cima.startsWith('#') ? cima : undefined) : undefined
314 const b = baixo ? COR[baixo] ?? (baixo.startsWith('#') ? baixo : undefined) : undefined
315
316 if (!c && !b) {
317 return { t: ' ' }
318 }
319
320 if (c && !b) {
321 return { t: '▀', cor: c }
322 }
323
324 if (!c && b) {
325 return { t: '▄', cor: b }
326 }
327
328 return c === b ? { t: '█', cor: c } : { t: '▀', cor: c, fundo: b }
329}
330
331/** Junta células iguais em pedaços: menos nós pro desenho. */
332const pedacos = (fila: Celula[]): Pedaco[] =>
333 fila.reduce<Pedaco[]>((acc, cel) => {
334 const ultimo = acc[acc.length - 1]
335
336 if (ultimo && ultimo.cor === cel.cor && ultimo.fundo === cel.fundo) {
337 ultimo.t += cel.t
338 } else {
339 acc.push({ ...cel })
340 }
341
342 return acc
343 }, [])
344
345export const desenha = (palco: Palco, cena: Cena, largura: number, espaco = LINHAS_TURMA): Pedaco[][] => {
346 const t = pixels(palco, cena, largura, linhas(cena, espaco) * 2)
347 const w = t[0]?.length ?? 0
348 const celulas: Celula[][] = Array.from({ length: t.length / 2 }, (_, r) =>
349 Array.from({ length: w }, (_, c) => celula(t[r * 2]?.[c] ?? null, t[r * 2 + 1]?.[c] ?? null)),
350 )
351 const acao = vigente(cena, palco)
352 const fala = cena.balao || (acao === 'pensando' ? '·'.repeat(1 + (Math.floor(palco.tick / 4) % 3)) : '')
353
354 const arr = arranjo(cena, w, palco.tick, palco.turma ?? {}, ATOR)
355 /** Os nomes da turma ficam logo acima delas; com a faixa de 6 linhas, na mesma linha do balão. */
356 const linhaNomes = Math.max(0, Math.floor((t.length - 12) / 2))
357 const limite = cena.ajudantes.length && linhaNomes === 0 ? arr.inicio - 1 : w
358
359 if (cena.ajudantes.length && t.length >= 12) {
360 for (const r of rotulos(arr, palco.turma ?? {}, palco.tick)) {
361 const fila = celulas[linhaNomes]
362
363 Array.from(r.texto).forEach((ch, i) => {
364 if (fila && r.x + i >= 0 && r.x + i < w) {
365 fila[r.x + i] = { t: ch, cor: r.cor }
366 }
367 })
368 }
369 }
370
371 if (fala) {
372 const depois = palco.x + ATOR
373 const cabe = Math.max(limite - depois, palco.x - 1, 6) - 4
374 const max = Math.min(BALAO_MAX, cabe)
375 const texto = `‹ ${fala.length > max ? `${fala.slice(0, max - 1)}…` : fala} ›`
376 const tam = Array.from(texto).length
377 const coluna = depois + tam <= limite ? depois : Math.max(0, palco.x - tam - 1)
378 const cor = acao === 'panico' || acao === 'caido' ? COR.R : 'subtle'
379 const fila = celulas[0]
380
381 Array.from(texto).forEach((ch, i) => {
382 if (fila && coluna + i < w) {
383 fila[coluna + i] = { t: ch, cor }
384 }
385 })
386 }
387
388 return celulas.map(pedacos)
389}
390hooks/turma.ts 544 lines1/**
2 * A turma: cada subagente do Claude Code vira uma capivarinha com o traje do
3 * tipo dela (Explore de safári, devops de capacete, fork com a laranja do Zé…).
4 * Chega andando pela direita, trabalha com o adereço da ferramenta que está
5 * usando e, quando termina, comemora e vai pro ofurô. Puro: nada aqui toca o `$`.
6 */
7
8import type { Acao, Ajudante, Cena } from '../types'
9
10export type Tela = (string | null)[][]
11
12/** O relógio de cada capivarinha no palco: onde está e desde quando faz o que faz. */
13export type Marca = { x: number; seq: number; desde: number; chegou: number }
14export type Marcas = Record<string, Marca>
15
16/** Largura de uma capivarinha com o adereço na mão. */
17export const SLOT = 18
18/** Altura do desenho de uma capivarinha: chapéu, corpo e o pulo. */
19const ALTO = 11
20/** O corpo começa nesta linha do desenho; acima é chapéu e pulo. */
21const CORPO_Y = 4
22/** Quanto a comemoração dura antes de ela ir pro ofurô. */
23export const COMEMORA_TIQUES = 14
24/** Quantas capivarinhas trabalhando cabem lado a lado, no máximo. */
25const MAX_VISIVEIS = 5
26const CABECA = 8
27const MAX_CABECAS = 3
28
29const PAL: Record<string, string> = {
30 N: '#3b2a20', // focinho
31 E: '#111111', // olho
32 Y: '#f2c94c', // amarelo
33 y: '#ffd43b', // amarelo python
34 R: '#ef4444', // vermelho
35 r: '#9f1d1d', // vermelho escuro
36 K: '#d8c38f', // cáqui
37 k: '#7a6440', // faixa do safári
38 P: '#2d2d2d', // preto
39 v: '#8b5cf6', // roxo
40 n: '#512bd4', // roxo .NET
41 h: '#8b5a2b', // madeira
42 j: '#5f3b1a', // madeira escura
43 M: '#9aa3ad', // metal
44 l: '#c9f1ff', // lente
45 L: '#3fae4a', // folha
46 O: '#ff9500', // laranja
47 o: '#d96f00', // laranja escura
48 C: '#4fd1ff', // tela acesa
49 c: '#2a8fb8', // tela
50 S: '#9aa3ad', // notebook
51 T: '#e5e7eb', // vapor, pensamento
52 W: '#7dd3fc', // água
53 w: '#f8fafc', // branco
54 b: '#3572a5', // azul python
55 u: '#326ce5', // azul k8s
56 F: '#f2d14b', // flor
57 p: '#f472b6', // rosa
58 s: '#e6c86e', // palha
59 Q: '#d97757', // laranja Claude
60}
61
62/** Pelagens: corpo, brilho do lombo e sombra. */
63const PELOS: [string, string, string][] = [
64 ['#b5835a', '#d6a274', '#8a5a36'], // caramelo, igual ao Zé
65 ['#8f6242', '#b07f58', '#5e3b24'], // chocolate
66 ['#c99a5f', '#e6bd86', '#946638'], // dourada
67 ['#9c8270', '#bfa695', '#6b5646'], // terra
68 ['#b8744f', '#d9976e', '#82492b'], // ruiva
69]
70
71type Chapeu = { arte: string[]; x: number; y: number; pisca?: boolean }
72
73export type Traje = {
74 /** Como aparece no rótulo quando o subagente não tem nome próprio. */
75 rotulo: string
76 /** Cor do rótulo. */
77 cor: string
78 chapeu?: Chapeu
79 /** Adereço que ela carrega sempre que não está usando outro. */
80 item?: string[]
81 oculos?: boolean
82 /** Pelagem fixa (o fork é a cara do Zé). */
83 pelo?: number
84}
85
86const CHAPEUS = {
87 safari: { arte: ['..KKK..', '.kkkkk.', 'KKKKKKK'], x: 6, y: -2 },
88 capacete: { arte: ['..YYY..', '.YYYYY.', 'YYYYYYY'], x: 6, y: -2 },
89 bone: { arte: ['.QQQQ...', 'QQQQQQQQ'], x: 6, y: -1 },
90 bonéAzul: { arte: ['.uuuu...', 'uuuuuuuu'], x: 6, y: -1 },
91 capelo: { arte: ['PPPPPPPP', '..PPPP.Y', '.......Y'], x: 5, y: -2 },
92 coroa: { arte: ['Y.Y.Y', 'YRYRY', 'YYYYY'], x: 7, y: -2 },
93 boina: { arte: ['..vvv.', '.vvvvv', 'vvvvvvv'], x: 6, y: -2 },
94 boinaRosa: { arte: ['..ppp.', '.ppppp', 'ppppppp'], x: 6, y: -2 },
95 gorro: { arte: ['...w...', '..nnn..', '.nnnnn.', 'nnnnnnn'], x: 6, y: -3 },
96 detetive: { arte: ['..jjj..', '.jrrrj.', 'jjjjjjj'], x: 6, y: -2 },
97 faixa: { arte: ['RR.....', '.RRRRRR'], x: 4, y: 0 },
98 cobra: { arte: ['.....yyR', 'bbbbbyE.', '.b......'], x: 1, y: -1 },
99 laranja: { arte: ['...LOO', '..OOOOo'], x: 5, y: -2 },
100 giroflex: { arte: ['.RC.', '.MM.', 'OOOOOO'], x: 6, y: -2, pisca: true },
101 palha: { arte: ['..sss..', 'sssssss'], x: 6, y: -1 },
102 flor: { arte: ['F.F', '.Y.', 'F.F'], x: 9, y: -3 },
103 folha: { arte: ['.LL', 'LL.', 'L..'], x: 9, y: -3 },
104} satisfies Record<string, Chapeu>
105
106const ITENS = {
107 prancheta: ['.M.', 'www', 'wPw', 'www'],
108 chave: ['M.M', 'MMM', '.M.', '.M.'],
109 timao: ['u.u.u', '.uuu.', 'uuMuu', '.uuu.', 'u.u.u'],
110 pincel: ['..p', '.h.', 'h..'],
111 caneta: ['..R', '.R.', 'w..'],
112 cadeado: ['.MM.', 'M..M', 'YYYY', 'YYYY'],
113 livro: ['uuu', 'uwu', 'uwu', 'uuu'],
114 lapis: ['..Y', '.Y.', 'h..'],
115 raio: ['.Y', 'Y.', 'YY', '.Y', 'Y.'],
116 tesoura: ['M.M', '.M.', 'R.R'],
117}
118
119/** O traje de cada tipo de subagente. Quem não está aqui ganha um chapéu do sorteio. */
120const TRAJES: Record<string, Traje> = {
121 explore: { rotulo: 'explore', cor: PAL.K!, chapeu: CHAPEUS.safari },
122 plan: { rotulo: 'plan', cor: PAL.v!, chapeu: CHAPEUS.boina, item: ITENS.prancheta },
123 'general-purpose': { rotulo: 'geral', cor: PAL.Q!, chapeu: CHAPEUS.bone },
124 claude: { rotulo: 'claude', cor: PAL.Q!, chapeu: CHAPEUS.bone },
125 fork: { rotulo: 'zé 2', cor: PAL.O!, chapeu: CHAPEUS.laranja, pelo: 0 },
126 devops: { rotulo: 'devops', cor: PAL.Y!, chapeu: CHAPEUS.capacete, item: ITENS.chave },
127 'claude-code-guide': { rotulo: 'guia', cor: PAL.u!, item: ITENS.livro, oculos: true },
128 'statusline-setup': { rotulo: 'status', cor: PAL.C!, chapeu: CHAPEUS.bonéAzul },
129}
130
131/**
132 * Agentes de plugin (`plugin:nome`) que não têm traje próprio: o traje sai do
133 * que o nome diz que ele faz. A primeira regra que casa vence.
134 */
135const PALAVRAS: [RegExp, Omit<Traje, 'rotulo'>][] = [
136 [/review|critic/, { cor: PAL.R!, item: ITENS.caneta, oculos: true }],
137 [/secur|secret|audit|redact|scan|vuln/, { cor: PAL.Y!, chapeu: CHAPEUS.detetive, item: ITENS.cadeado }],
138 [/test/, { cor: PAL.C!, item: ITENS.prancheta, oculos: true }],
139 [/analy|extract|comment|insight/, { cor: PAL.u!, item: ITENS.livro, oculos: true }],
140 [/verif|valid|judge|grader|check/, { cor: PAL.T!, chapeu: CHAPEUS.capelo, item: ITENS.prancheta }],
141 [/generat|scaffold|creat|migrat|patch|build/, { cor: PAL.Y!, chapeu: CHAPEUS.capacete, item: ITENS.chave }],
142 [/simplif|refactor|modern|clean/, { cor: PAL.Q!, chapeu: CHAPEUS.bone, item: ITENS.tesoura }],
143 [/perf|optim|speed|fast/, { cor: PAL.R!, chapeu: CHAPEUS.faixa, item: ITENS.raio }],
144 [/deploy|devops|infra|k8s|cloud|ops/, { cor: PAL.Y!, chapeu: CHAPEUS.capacete }],
145 [/explor|research|hunt|scout|search|inventory|load/, { cor: PAL.K!, chapeu: CHAPEUS.safari }],
146 [/architect|design|plan/, { cor: PAL.v!, chapeu: CHAPEUS.boina, item: ITENS.prancheta }],
147 [/math|proof|theor/, { cor: PAL.T!, chapeu: CHAPEUS.capelo }],
148 [/doc|writ|copy|market|content/, { cor: PAL.p!, chapeu: CHAPEUS.boinaRosa, item: ITENS.lapis }],
149]
150
151const SORTEIO: [Chapeu, string][] = [
152 [CHAPEUS.palha, PAL.s!],
153 [CHAPEUS.flor, PAL.F!],
154 [CHAPEUS.folha, PAL.L!],
155 [CHAPEUS.bonéAzul, PAL.u!],
156 [CHAPEUS.boina, PAL.v!],
157]
158
159const hash = (s: string) => {
160 let h = 7
161
162 for (let i = 0; i < s.length; i++) {
163 h = (h * 31 + s.charCodeAt(i)) | 0
164 }
165
166 return Math.abs(h)
167}
168
169const chave = (tipo: string) => tipo.trim().toLowerCase().replace(/^[^:]+:/, '')
170
171export const traje = (tipo: string): Traje => {
172 const k = chave(tipo)
173 const fixo = TRAJES[k]
174
175 if (fixo) {
176 return fixo
177 }
178
179 const rotulo = k.replace(/^(code|claude|agent)-/, '').replace(/-(agent|dev|expert|worker)$/, '').slice(0, 14) || 'ajudante'
180 const porNome = PALAVRAS.find(([re]) => re.test(k))
181
182 if (porNome) {
183 return { rotulo, ...porNome[1] }
184 }
185
186 const [chapeu, cor] = SORTEIO[hash(k) % SORTEIO.length]!
187
188 return { rotulo, cor, chapeu }
189}
190
191/** O nome que aparece em cima dela. */
192export const nomeDe = (a: Pick<Ajudante, 'tipo' | 'nome'>) => a.nome || traje(a.tipo).rotulo
193
194const pelagem = (a: Ajudante, t: Traje): [string, string, string] =>
195 PELOS[t.pelo ?? hash(a.id) % PELOS.length] ?? PELOS[0]!
196
197const sorte = (n: number) => {
198 const s = Math.sin(n * 12.9898) * 43758.5453
199
200 return s - Math.floor(s)
201}
202
203const tela = (largura: number, altura: number): Tela =>
204 Array.from({ length: altura }, () => Array.from({ length: largura }, () => null))
205
206/** Pinta arte de letras com uma paleta: '.' é transparente. */
207const pinta = (t: Tela, arte: string[], x: number, y: number, pal: Record<string, string>) =>
208 arte.forEach((linha, i) =>
209 Array.from(linha).forEach((ch, j) => {
210 const fila = t[y + i]
211 const cor = pal[ch]
212
213 if (ch !== '.' && cor && fila && x + j >= 0 && x + j < fila.length) {
214 fila[x + j] = cor
215 }
216 }),
217 )
218
219/** A capivarinha, olhando pra direita: cabeça quadrada, focinho rombudo, orelhinhas, corpo de barril. */
220const CORPO = (olho: string, pernas: string) => [
221 '........D.D..',
222 '..HHHHHHBBBB.',
223 `.BBBBBBBBB${olho}BB`,
224 'BBBBBBBBBBBBN',
225 'BBBBBBBBDBBBN',
226 '.BBBBBBBBBBB.',
227 pernas,
228]
229
230const PERNAS_A = '.DB.DB..DB.DB'
231const PERNAS_B = '..DBDB...DBDB'
232const SENTADA = '.DDDD...DDDD.'
233
234const espelha = (t: Tela): Tela => t.map(fila => [...fila].reverse())
235
236/** O adereço da ação, na frente do focinho (coluna 13 em diante). */
237const adereco = (acao: Acao, k: number, tick: number, item: string[] | undefined): [string[], number, number] | null => {
238 switch (acao) {
239 case 'procurando':
240 return [['.Y.', 'YlY', '.Y.', 'h..'], 13, CORPO_Y - 1 + (Math.floor(k / 3) % 2)]
241 case 'digitando':
242 return [[k % 4 < 2 ? '.CC' : '.cC', k % 3 ? '.Cc' : '.cc', 'SSSS'], 13, CORPO_Y + 3]
243 case 'construindo':
244 return Math.floor(k / 2) % 2 ? [['MMM', '.h.', '.h.'], 13, CORPO_Y] : [['...MM', 'hhhMM', '....Y'], 13, CORPO_Y + 3]
245 case 'chamando':
246 return [['..oo', 'oOOO', '..oo'], 13, CORPO_Y + 2]
247 case 'pensando': {
248 const n = 1 + (Math.floor(tick / 4) % 3)
249
250 return [['....T', '..T..', 'T....'].slice(3 - n), 13, CORPO_Y - 1 - (n - 1)]
251 }
252 default:
253 return item ? [item, 13, CORPO_Y + 6 - item.length] : null
254 }
255}
256
257/** Desenha uma capivarinha numa tela própria de SLOT x ALTO, olhando pra direita. */
258export const capivarinha = (a: Ajudante, k: number, tick: number, andando: boolean): Tela => {
259 const t = tela(SLOT, ALTO)
260 const tr = traje(a.tipo)
261 const [B, H, D] = pelagem(a, tr)
262 const pal: Record<string, string> = { ...PAL, B, H, D }
263 const pisca = (tick + hash(a.id)) % 41 < 2
264 const passo = Math.floor(tick / 2) % 2 === 0
265
266 if (a.estado === 'erro' || a.acao === 'caido') {
267 const deitada = CORPO('E', PERNAS_A).slice().reverse()
268 pinta(t, deitada, 0, CORPO_Y, pal)
269
270 if (tr.chapeu) {
271 pinta(t, tr.chapeu.arte, 14, ALTO - tr.chapeu.arte.length, pal)
272 }
273
274 ;[2, 6, 10].forEach((x, i) => {
275 if ((tick + i) % 4 < 2) {
276 pinta(t, ['Y'], x, CORPO_Y - 1 - ((tick + i) % 2), pal)
277 }
278 })
279
280 return t
281 }
282
283 const comemorando = a.estado === 'pronto' || a.acao === 'comemora'
284 const dy = comemorando ? [0, 1, 2, 2, 1, 0, 0][k % 7] ?? 0 : 0
285 const pernas = andando ? (passo ? PERNAS_A : PERNAS_B) : dy ? PERNAS_B : SENTADA
286 const y = CORPO_Y - dy
287
288 pinta(t, CORPO(pisca ? 'B' : 'E', pernas), 0, y, pal)
289
290 if (tr.oculos) {
291 pinta(t, ['MMMM', 'l.lM'], 8, y + 1, pal)
292 pinta(t, [pisca ? 'B' : 'E'], 10, y + 2, pal)
293 pinta(t, ['MlM'], 9, y + 2, pal)
294 }
295
296 if (tr.chapeu) {
297 const arte = tr.chapeu.pisca && Math.floor(tick / 3) % 2 ? tr.chapeu.arte.map(l => l.replace('RC', 'CR')) : tr.chapeu.arte
298 pinta(t, arte, tr.chapeu.x, y + tr.chapeu.y, pal)
299 }
300
301 if (comemorando) {
302 for (let i = 0; i < 4; i++) {
303 const r = sorte(tick * 7 + i + hash(a.id))
304 pinta(t, [['R', 'Y', 'C', 'p'][i]!], Math.floor(r * SLOT), Math.floor(sorte(r * 3 + i) * 3), pal)
305 }
306
307 return t
308 }
309
310 if (a.acao === 'panico') {
311 pinta(t, ['W'], 4, y + 1 + (k % 3), pal)
312 }
313
314 if (a.acao === 'telefone') {
315 pinta(t, ['P', 'P'], 12, y + 2, pal)
316 ;[14, 16].forEach((x, i) => {
317 if (k % 6 >= i * 2) {
318 pinta(t, ['C', '.', 'C'], x, y + 1, pal)
319 }
320 })
321
322 return t
323 }
324
325 const ad = adereco(andando ? 'ocioso' : a.acao, k, tick, tr.item)
326
327 if (ad) {
328 pinta(t, ad[0], ad[1], ad[2] - dy, pal)
329 }
330
331 return t
332}
333
334/** As cabeças no ofurô: olhos fechados de tão relaxadas, laranja na cabeça e vapor. */
335const ofuro = (cabecas: Ajudante[], tick: number): Tela => {
336 const largura = 2 + CABECA * cabecas.length
337 const t = tela(largura, 11)
338
339 cabecas.forEach((a, i) => {
340 const [B, H, D] = pelagem(a, traje(a.tipo))
341 const pal: Record<string, string> = { ...PAL, B, H, D }
342 const sobe = Math.floor((tick + i * 5) / 9) % 2
343 const olho = (tick + i * 13) % 53 < 3 ? 'E' : 'D'
344 pinta(t, ['....LO.', '...OOOo', '..D.D..', '.BBBBHH', `NB${olho}BBBB`, 'NBBBBBB', 'BBBBBBB'], 1 + CABECA * i, 1 - sobe, pal)
345 })
346
347 const agua = Array.from({ length: largura - 2 }, (_, c) => ((c + Math.floor(tick / 3)) % 7 === 0 ? 'l' : 'W')).join('')
348 pinta(t, [`h${agua}h`, `j${'M'.repeat(largura - 2)}j`, `.${'h'.repeat(largura - 2)}.`], 0, 7, PAL)
349
350 for (let i = 0; i < cabecas.length * 2; i++) {
351 const r = sorte(Math.floor(tick / 2) * 5 + i)
352
353 if (r < 0.6) {
354 pinta(t, ['T'], 1 + Math.floor(sorte(i * 9.1) * (largura - 2)), Math.floor(r * 2) + (tick % 2), PAL)
355 }
356 }
357
358 return t
359}
360
361/** Quem está no ofurô: terminou e já comemorou. */
362const noOfuro = (a: Ajudante, marcas: Marcas, tick: number) =>
363 a.estado === 'pronto' && tick - (marcas[a.id]?.desde ?? -COMEMORA_TIQUES) >= COMEMORA_TIQUES
364
365export type Lugar = { a: Ajudante; alvo: number }
366
367export type Arranjo = {
368 /** Primeira coluna da turma: o Zé não passa daqui. */
369 inicio: number
370 lugares: Lugar[]
371 ofuro: { x: number; cabecas: Ajudante[]; total: number } | null
372 /** Capivarinhas que não couberam: viram "+N". */
373 sobra: number
374 /** Se o "+N" tem coluna própria ou vai colado no nome da primeira. */
375 sobraSolta: boolean
376}
377
378/** Onde cada uma fica: ofurô no canto direito, quem trabalha do lado, mais nova perto do Zé. */
379export const arranjo = (cena: Cena, largura: number, tick: number, marcas: Marcas, ator: number): Arranjo => {
380 const turma = cena.ajudantes
381 const prontos = turma.filter(a => noOfuro(a, marcas, tick))
382 const ativos = turma
383 .filter(a => !noOfuro(a, marcas, tick))
384 .sort((x, y) => Number(x.estado === 'erro') - Number(y.estado === 'erro'))
385 const livre = largura - ator - 4
386 const larguraOfuro = (n: number) => (n ? 2 + CABECA * n + 1 : 0)
387 let cabecas = Math.min(prontos.length, MAX_CABECAS)
388
389 while (cabecas > 0 && livre - larguraOfuro(cabecas) < (ativos.length ? SLOT : 0)) {
390 cabecas--
391 }
392
393 const resto = livre - larguraOfuro(cabecas)
394 const n = Math.min(ativos.length, MAX_VISIVEIS, Math.max(0, Math.floor(resto / SLOT)))
395
396 const xOfuro = largura - larguraOfuro(cabecas) + (cabecas ? 1 : 0)
397 const borda = cabecas ? xOfuro - 1 : largura
398 const lugares = ativos.slice(0, n).map((a, i) => ({ a, alvo: borda - SLOT * (i + 1) }))
399 const sobra = ativos.length - n
400 const sobraSolta = sobra > 0 && (n === 0 || resto - n * SLOT >= 4)
401 const inicio = borda - SLOT * n - (sobraSolta ? 4 : 0)
402
403 return {
404 inicio: turma.length ? inicio : largura,
405 lugares,
406 ofuro: cabecas ? { x: xOfuro, cabecas: prontos.slice(-cabecas), total: prontos.length } : null,
407 sobra,
408 sobraSolta,
409 }
410}
411
412/** Atualiza o relógio de cada uma quando a cena muda. */
413export const recebeTurma = (marcas: Marcas, cena: Cena, tick: number): Marcas => {
414 const ids = new Set(cena.ajudantes.map(a => a.id))
415 let mudou = Object.keys(marcas).some(id => !ids.has(id))
416 const novas: Marcas = {}
417
418 for (const a of cena.ajudantes) {
419 const m = marcas[a.id]
420
421 if (!m) {
422 novas[a.id] = { x: -1, seq: a.seq, desde: tick, chegou: tick }
423 mudou = true
424 } else if (m.seq !== a.seq) {
425 novas[a.id] = { ...m, seq: a.seq, desde: tick }
426 mudou = true
427 } else {
428 novas[a.id] = m
429 }
430 }
431
432 return mudou ? novas : marcas
433}
434
435/** Um tique: cada capivarinha anda até o lugar dela; o fork surge do lado. */
436export const avancaTurma = (marcas: Marcas, cena: Cena, largura: number, tick: number, ator: number): Marcas => {
437 const { lugares } = arranjo(cena, largura, tick, marcas, ator)
438 let mudou = false
439 const novas = { ...marcas }
440
441 for (const { a, alvo } of lugares) {
442 const m = novas[a.id]
443
444 if (!m) {
445 continue
446 }
447
448 const x = m.x < 0 ? (a.tipo === 'fork' ? alvo : largura) : m.x
449 const proximo = x > alvo ? Math.max(alvo, x - 2) : x < alvo ? Math.min(alvo, x + 1) : x
450
451 if (proximo !== m.x) {
452 novas[a.id] = { ...m, x: proximo }
453 mudou = true
454 }
455 }
456
457 return mudou ? novas : marcas
458}
459
460/** Pinta a turma na tela da faixa, de pé na grama (a última linha é o chão). */
461export const pintaTurma = (t: Tela, cena: Cena, marcas: Marcas, tick: number, ator: number) => {
462 const largura = t[0]?.length ?? 0
463 const alto = t.length
464 const arr = arranjo(cena, largura, tick, marcas, ator)
465
466 const blit = (arte: Tela, x: number, y: number) =>
467 arte.forEach((fila, i) =>
468 fila.forEach((px, j) => {
469 const destino = t[y + i]
470
471 if (px && destino && x + j >= 0 && x + j < largura) {
472 destino[x + j] = px
473 }
474 }),
475 )
476
477 for (const { a, alvo } of arr.lugares) {
478 const m = marcas[a.id]
479 const x = m && m.x >= 0 ? m.x : alvo
480 const k = m ? tick - m.desde : 99
481 const andando = x !== alvo
482 const arte = capivarinha(a, k, tick, andando)
483 const virada = andando && x < alvo ? arte : espelha(arte)
484
485 blit(virada, x, alto - 1 - ALTO)
486
487 if (a.tipo === 'fork' && m && tick - m.chegou < 6) {
488 const nuvem = tela(SLOT, ALTO)
489 for (let i = 0; i < 14; i++) {
490 pinta(nuvem, ['T'], Math.floor(sorte(tick * 3 + i) * SLOT), 3 + Math.floor(sorte(i * 7.7) * 7), PAL)
491 }
492 blit(nuvem, x, alto - 1 - ALTO)
493 }
494 }
495
496 if (arr.ofuro) {
497 blit(ofuro(arr.ofuro.cabecas, tick), arr.ofuro.x, alto - 1 - 10)
498 }
499
500 return arr
501}
502
503export type Rotulo = { x: number; texto: string; cor: string }
504
505const corta = (texto: string, max: number) => {
506 const cs = Array.from(texto)
507
508 return cs.length > max ? `${cs.slice(0, max - 1).join('')}…` : texto
509}
510
511/** Os nomes em cima de cada uma: o nome e, de vez em quando, o que está fazendo. */
512export const rotulos = (arr: Arranjo, marcas: Marcas, tick: number): Rotulo[] => {
513 const fora: Rotulo[] = []
514
515 const ultima = arr.lugares[arr.lugares.length - 1]?.a.id
516
517 for (const { a, alvo } of arr.lugares) {
518 const m = marcas[a.id]
519 const x = m && m.x >= 0 ? m.x : alvo
520 const nome = `${arr.sobra && !arr.sobraSolta && a.id === ultima ? `+${arr.sobra} ` : ''}${nomeDe(a)}`
521 const fazendo = a.estado === 'trabalhando' && a.balao && Math.floor(tick / 25) % 2 === 1
522 const texto = corta(a.estado === 'erro' ? `✗ ${nome}` : a.estado === 'pronto' ? `✓ ${nome}` : fazendo ? a.balao : nome, SLOT - 2)
523 const tam = Array.from(texto).length
524
525 fora.push({
526 x: tam <= 13 ? x + SLOT - 13 + Math.floor((13 - tam) / 2) : x + Math.max(0, Math.floor((SLOT - tam) / 2)),
527 texto,
528 cor: a.estado === 'erro' ? PAL.R! : fazendo ? 'subtle' : traje(a.tipo).cor,
529 })
530 }
531
532 if (arr.ofuro) {
533 const largura = 2 + CABECA * arr.ofuro.cabecas.length
534 const texto = corta(arr.ofuro.total > 1 ? `✓ ${arr.ofuro.total} no ofurô` : '✓ ofurô', largura + 1)
535 fora.push({ x: arr.ofuro.x + Math.max(0, Math.floor((largura - Array.from(texto).length) / 2)), texto, cor: PAL.W! })
536 }
537
538 if (arr.sobraSolta) {
539 fora.push({ x: Math.max(0, arr.inicio), texto: `+${arr.sobra}`, cor: 'subtle' })
540 }
541
542 return fora
543}
544hooks/palco.tsx 44 lines1import type { ClientModule } from 'claude-code'
2
3import { TIQUE_MS, avanca, desenha, linhas, recebe } from './sprites'
4import type { Cena, Palco } from './sprites'
5
6/** O último props e a última largura que o desenho viu: o tique lê daqui. */
7let cena: Cena | undefined
8let largura = 60
9
10let atual: Palco = { tick: 0, x: 2, dir: 1, seq: -1, inicio: 0, turma: {} }
11
12/** O que o register manda: a cena e quantas linhas o terminal deu pra faixa. */
13type Props = Cena & { espaco: number }
14
15const Ze: ClientModule<Props, Palco> = (props, surface) => {
16 const { Box, Text } = surface.elements
17 cena = props
18 largura = surface.columns || largura
19
20 if (surface.state === undefined) {
21 surface.every(TIQUE_MS, () => {
22 if (cena) {
23 atual = avanca(recebe(atual, cena), cena, largura)
24 surface.setState(atual)
25 }
26 })
27 surface.setState(atual)
28 }
29
30 const palco = recebe(atual, props)
31
32 return (
33 <Box flexDirection="column" height={linhas(props, props.espaco)}>
34 {desenha(palco, props, largura, props.espaco).map((fila, i) => (
35 <Text key={`l${i}`} wrap="truncate-end">
36 {fila.map(p => (p.cor ? <Text color={p.cor} backgroundColor={p.fundo}>{p.t}</Text> : p.t))}
37 </Text>
38 ))}
39 </Box>
40 )
41}
42
43export default Ze
44types/index.d.ts 36 lines1export type Acao =
2 | 'ocioso'
3 | 'pula'
4 | 'pensando'
5 | 'digitando'
6 | 'procurando'
7 | 'construindo'
8 | 'telefone'
9 | 'chamando'
10 | 'caido'
11 | 'comemora'
12 | 'panico'
13
14/**
15 * Um subagente do Claude Code, desenhado como capivarinha: o tipo escolhe o
16 * traje, `acao`/`balao` dizem o que ela está fazendo e `seq` muda a cada troca.
17 */
18export type Ajudante = {
19 id: string
20 tipo: string
21 nome: string
22 acao: Acao
23 balao: string
24 estado: 'trabalhando' | 'pronto' | 'erro'
25 fundo: boolean
26 seq: number
27}
28
29export type Cena = { acao: Acao; depois: Acao; balao: string; seq: number; ajudantes: Ajudante[] }
30
31declare module 'claude-code' {
32 interface PluginState {
33 ze: { cena: Cena; ativos: number; oculto: boolean }
34 }
35}
36