Context and plan limits with a burn-rate projection, plus a deadline radar from a plain file or your own command, beside the code and above the prompt

See how much room your Claude Code session has left, and what is due next, without leaving the code.
<img src="docs/images/hero.svg" alt="The brain-dash pane docked to the right of a Claude Code conversation, showing context and plan-limit bars with projections, a 21-day calendar and a list of deadlines" width="100%">
Long sessions fail in two quiet ways. The context fills up and compacts in the middle of a task, or a plan limit runs out an hour before the reset, and you only find out when it happens. Meanwhile the deadline that matters this week lives in another app.
brain-dash keeps all of that in a pane next to the conversation. It doesn't only show where you are. It also projects where you will be at your current pace, so you can wrap up, compact or switch tasks before you hit the wall.
A real Claude Code session (2.1.296, Haiku), recorded from the terminal. Claude adds a deadline to DEADLINES.md, and the radar picks it up as soon as the turn ends. The usage numbers are the real ones at that moment. The only edit is the working-directory path, which was replaced.
<img src="docs/images/demo.gif" alt="Recording of a real session: asked to add 'Demo day' on Oct 14 to DEADLINES.md, Claude edits the file, and when the turn ends the radar in the side pane shows 'Oct 14 2d Demo day' and marks the calendar" width="100%">
<img align="right" src="docs/images/pane.svg" alt="The brain-dash pane on its own" width="420">
Context. A bar with the auto-compact threshold marked, a sparkline of context per turn, and roughly how many turns are left at the current pace. Only growth since the last compaction or /clear counts as pace, and subagent turns are left out.
Plan limits. The 5-hour and weekly windows, each shown as elapsed time against usage. The shaded part of the usage bar is where you will be at reset if you keep the average pace so far. If that projection goes past 100%, the pane tells you when the window runs out.
Deadline radar. A 21-day calendar and a list of what is due, read from a plain DEADLINES.md in your project or from a command you choose. Overdue items stay visible for two weeks. Critical ones stay until you tick them off.
Colour means something. Amber is a warning: the context is past 75% or 5 turns from the limit, a window is heading past 80%, or a deadline is 3 to 4 days away. Red means act now: the context is past 90% or 2 turns from the limit, a window is past 90% or projected to run out, or a deadline is overdue, critical or 2 days away or less.
When the pane is closed, or the terminal is too narrow for it, the same summary sits in one line above the prompt:
<img src="docs/images/band.svg" alt="A single summary line above the prompt: context percentage and turns left, 5-hour and weekly usage with projections, overdue count and the next deadline" width="100%">
At the prompt of a Claude Code terminal session:
/plugin install brain-dash --marketplace sillss1/brain-dash
Answer y to add the marketplace and pick a scope (user scope gives you the pane in every project). If /brain is not available straight away, run /reload-plugins or start a new session.
Requirements. Built and tested against Claude Code 2.1.296. Mods are an early-access part of Claude Code, and their API may change between releases. The pane sits beside the conversation in the fullscreen renderer (/tui fullscreen). In the classic renderer it opens in a frame above the prompt.
DEADLINES.md at the root of your project: - [ ] 2026-10-15 Thesis draft to advisor !
- [ ] 2026-10-20 Networks exam
r in the pane. The radar appears./brain opens and closes the pane at any time.
<img src="docs/images/deadlines.svg" alt="A DEADLINES.md file on the left and the radar it produces on the right: dates highlighted in the file become a calendar and a sorted list" width="100%">
One deadline per line. Everything else in the file is ignored, so it can hold notes too.
| You write | It means |
|---|---|
2026-10-15 Submit the report | A deadline. The date can be anywhere in the line, and the rest of the line becomes the text. |
15/10/2026 Submit the report | The same: DD/MM/YYYY works too. |
... Submit the report ! | Critical (a ! at the end of the line): red in the radar, and kept on screen after it is overdue. |
- [ ] 2026-10-15 ... | Markdown lists and task boxes are fine. |
- [x] 2026-10-15 ... | Done. A ticked box is skipped. |
- Email the advisor ! | A critical item with no date yet. It is listed under "no date". |
# Heading, lines without a date | Ignored. |
The radar re-reads the file at the end of the turn after you save it.
If your deadlines already live somewhere else, such as a script, a calendar export or a task tool, set the radar_command option. Its output replaces the file. The command:
r in the pane;&& wrap it: sh -c 'your | pipeline';The JSON can be a plain array of deadlines or an object:
{
"deadlines": [
{ "date": "2026-10-15", "text": "Thesis draft to advisor", "critical": true },
{ "date": "2026-10-20", "text": "Networks exam" }
],
"undated": ["Reply to reviewer 2"],
"warnings": ["3 tickets waiting for review"]
}
warnings are shown as free-text lines under the list. Portuguese keys work as well (prazos, data, texto, critico, sem_data, avisos), and unknown fields are ignored.
A minimal script, for example scripts/deadlines.py:
import json
print(json.dumps([{"date": "2026-10-15", "text": "Thesis draft", "critical": True}]))
Then set radar_command to python scripts/deadlines.py.
Failures are loud on purpose. The radar turns red and shows the reason, and where it came from, if any of these happens:
An empty radar always means there is nothing due, never that the radar failed to run.
Set them in /config under brain-dash. Changing one reloads the mod with the new value.
| Option | Default | What it does |
|---|---|---|
radar_file | DEADLINES.md | The deadlines file, relative to the project root. A .json file is read as JSON. |
radar_command | (empty) | A command whose output replaces the file (see above). |
radar_days | 21 | How many days ahead the calendar and the list cover (1–90). |
With no file and no command there is simply no radar. The line above the prompt shows usage only, and the pane explains how to add deadlines.
All of this is computed locally from the usage figures Claude Code already reports. Nothing is requested from any server.
| Network | None. |
| Files written | None. |
| Files read | Your deadlines file. |
| Processes | Only radar_command, and only if you set it. |
| Prompts and tool calls | Not observed. It reads only the session's usage measurements (context, rate limits, cost). |
claude plugin validate brain-dash prints exactly which engine calls the mod makes, if you want to check this yourself.
| Symptom | Cause and fix |
|---|---|
| The pane does not open at startup | Unasked, a pane needs the fullscreen renderer and at least 144 columns (110 once you have opened it by hand). A toast says when it did not fit. /brain opens it at any width. |
RADAR FAILED | The command or the file failed. The pane shows the reason and the source underneath. |
| No radar at all | There is no DEADLINES.md (or radar_file) at the project root, and no radar_command. |
| Times look wrong | Times and "today" use your machine's time zone. |
| Two panels or two bands | Two copies of the mod are loaded, for example an installed one and a --plugin-dir copy. Keep one. |
.
├── .claude-plugin/marketplace.json makes this repository installable
├── brain-dash/
│ ├── .claude-plugin/plugin.json manifest and options
│ ├── hooks/register.tsx events, the pane, the band, /brain
│ ├── hooks/calc.ts projections and drawing (pure)
│ ├── hooks/radar.ts deadline sources and parsing (pure)
│ ├── types/index.d.ts the state contract
│ └── tests/register.test.ts 24 tests
└── docs/render.ts regenerates the images in this README
claude plugin test brain-dash # run the tests
claude plugin validate brain-dash # what the engine will load and refuse
claude --plugin-dir ./brain-dash # try a local copy in one session
npx tsx docs/render.ts # redraw docs/images/*.svg
The tests cover the projections, both deadline formats, the radar window, the drawing, and the mod running inside the engine on both the terminal and Desktop surfaces. The README images are drawn by docs/render.ts from the mod's own drawing functions and sample data, so they match what the pane shows.
Source comments and internal names are in Portuguese. Everything a user sees is in English.
MIT © 2026 Francisco Antunes
hooks/register.tsx 235 lines1// brain-dash: um painel ao lado do código com o uso de tokens (barras e projeção) e um radar de
2// prazos (calendário e lista). Abre sozinho no arranque; /brain volta a abri-lo. Com o painel
3// fechado ou sem lugar (terminal estreito), o resumo fica numa banda acima do prompt.
4//
5// Alcance pequeno de propósito: não ouve tool.call nem prompt.submit, não vai à rede e não escreve
6// ficheiros. Os prazos vêm de um ficheiro do projeto (DEADLINES.md por omissão) ou, se a pessoa o
7// configurar, de um comando seu; o único processo que arranca é esse comando (ver radar.ts).
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, SessionUsage, TextProps } from 'claude-code'
10
11import type { Uso } from '../types'
12import { banda, painel } from './calc'
13import type { Linha, Seg } from './calc'
14import { config, falhou, lerFonte, montar } from './radar'
15import type { Config } from './radar'
16
17const PANE = 'brain'
18const LARGURA = 50 // colunas pedidas para o painel ancorado ao lado da conversa
19const RADAR_MS = 30 * 60_000 // o radar muda de dia para dia; meia hora chega
20
21const uso = atom({ plugin: 'brain-dash', key: 'uso' } as const, null)
22const amostras = atom({ plugin: 'brain-dash', key: 'amostras' } as const, [])
23const radar = atom({ plugin: 'brain-dash', key: 'radar' } as const, null)
24const aberto = atom({ plugin: 'brain-dash', key: 'aberto' } as const, false)
25
26function deMedida(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, limiar: number | undefined): Uso {
27 return {
28 tokens: u.context.tokens,
29 janela: u.context.window,
30 pct: u.context.percent,
31 limiar,
32 limites: u.rateLimits.map(l => {
33 const reset = l.resetsAt ? Date.parse(l.resetsAt) : NaN
34 return Number.isFinite(reset) ? { kind: l.kind, pct: l.percentUsed, reset } : { kind: l.kind, pct: l.percentUsed }
35 }),
36 usd: u.cost?.usd,
37 }
38}
39
40const absoluto = (p: string) => /^([A-Za-z]:)?[\\/]/.test(p)
41const motivo = (err: unknown) => (err instanceof Error ? err.message : String(err))
42
43// A configuração vem das opções do manifesto. Mudar uma no /config recarrega o módulo, e o
44// register volta a correr com as novas: por isso vive no módulo e lê-se lá.
45let cfg: Config = config({})
46let aLer: Promise<void> | undefined
47
48async function caminhoFicheiro($: EngineInterface): Promise<string> {
49 return absoluto(cfg.ficheiro) ? cfg.ficheiro : `${await $.session.root()}/${cfg.ficheiro}`
50}
51
52async function lerRadar($: EngineInterface): Promise<void> {
53 const agora = await $.clock.now()
54 if (cfg.comando.length > 0) {
55 const nome = cfg.comando.join(' ')
56 try {
57 const r = await $.process.run(cfg.comando, { cwd: await $.session.root(), timeoutMs: 20_000 })
58 if (r.exitCode !== 0) throw new Error(`exited with ${r.exitCode}: ${r.stderr.trim().slice(0, 160)}`)
59 const lido = lerFonte(r.stdout, '')
60 await update($, radar, () => montar(lido, agora, cfg.dias, nome))
61 } catch (err) {
62 await update($, radar, () => falhou(nome, motivo(err), agora, cfg.dias))
63 }
64 return
65 }
66 const caminho = await caminhoFicheiro($)
67 // Sem ficheiro não há radar: o painel diz como criar um, a banda mostra só o uso.
68 if (!(await $.fs.exists(caminho))) {
69 await update($, radar, () => null)
70 return
71 }
72 try {
73 const lido = lerFonte(await $.fs.read(caminho), cfg.ficheiro)
74 await update($, radar, () => montar(lido, agora, cfg.dias, cfg.ficheiro))
75 } catch (err) {
76 await update($, radar, () => falhou(cfg.ficheiro, motivo(err), agora, cfg.dias))
77 }
78}
79
80function refrescarRadar($: EngineInterface): Promise<void> {
81 aLer ??= lerRadar($).finally(() => {
82 aLer = undefined
83 })
84 return aLer
85}
86
87/** No fim de cada turno: o ritmo do contexto, e o radar se estiver velho ou o ficheiro mudou. */
88async function fimDeTurno($: EngineInterface): Promise<void> {
89 const u = await $.session.usage({ breakdown: 'summary' }) // estimado localmente, sem pedidos
90 const b = u.context.breakdown
91 const limiar = b?.isAutoCompactEnabled ? b.autoCompactThreshold : undefined
92 await update($, uso, () => deMedida(u, limiar))
93 const tokens = u.context.tokens
94 if (tokens !== undefined) await update($, amostras, s => [...s, tokens].slice(-24))
95
96 const r = await read($, radar)
97 const agora = await $.clock.now()
98 let velho = !r || agora - r.lidoEm > RADAR_MS
99 if (!velho && r && cfg.comando.length === 0) {
100 // Um ficheiro editado (ou apagado) a meio da sessão vê-se no turno seguinte.
101 const st = await $.fs.stat(await caminhoFicheiro($)).catch(() => undefined)
102 velho = st === undefined || st.mtimeMs > r.lidoEm
103 }
104 if (velho) void refrescarRadar($)
105}
106
107async function recomecar($: EngineInterface): Promise<void> {
108 const u = await $.session.usage()
109 await update($, uso, prev => deMedida(u, prev?.limiar))
110 await refrescarRadar($)
111}
112
113/**
114 * Abre o painel. Sem ser a pessoa a pedir, só tem lugar num terminal largo (>= 144 colunas; 110
115 * depois de o abrir à mão uma vez) — e quando não cabe, o motor deixa-o à espera em silêncio.
116 * Diz-se sempre porquê.
117 */
118async function abrir($: EngineInterface): Promise<void> {
119 const r = await $.ui.open({ id: PANE, title: 'brain-dash', columns: LARGURA })
120 await update($, aberto, () => r.isPlaced)
121 if (!r.isPlaced) {
122 $.ui.toast(`brain-dash: the pane did not fit (${r.reason}). /brain opens it now; beside the code it needs /tui fullscreen.`)
123 }
124}
125
126export const register: Register = (on, options) => {
127 cfg = config(options)
128
129 on('session.start', async ($, e, next) => {
130 await $.command.register({
131 name: 'brain',
132 description: 'brain-dash: pane beside the code with context, plan limits with projection, and a deadline radar',
133 })
134 $.clock.every(RADAR_MS, () => void refrescarRadar($))
135 // As barras de tempo e as projeções andam com o relógio: redesenhar a cada minuto.
136 $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
137 void recomecar($)
138 void abrir($)
139
140 return next(e)
141 })
142
143 // /clear, /resume e /branch repõem o $.state: voltar a ler tudo.
144 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
145 void recomecar($)
146 void abrir($)
147
148 return next(e)
149 }).catch(($, e, next) => next(e)) // não guarda nada: se falhar, o arranque segue como seguiria
150
151 on('session.measure', async ($, e, next) => {
152 await update($, uso, prev => deMedida(e, prev?.limiar))
153
154 return next(e)
155 })
156
157 on('turn.complete', async ($, e, next) => {
158 const fim = await next(e)
159 // Os turnos dos subagentes também acabam aqui: não são ritmo da conversa principal, e o
160 // contexto que o $.session.usage() mede é o dela.
161 if (!e.agentId) await fimDeTurno($)
162
163 return fim
164 })
165
166 // /brain é um interruptor: abre o painel, ou fecha-o se já estiver ao lado do código.
167 on('command.run', { command: 'brain' }, async $ => {
168 if (await read($, aberto)) {
169 await $.ui.close({ id: PANE })
170 return {}
171 }
172 await abrir($)
173 void refrescarRadar($)
174
175 return {}
176 })
177
178 // Fechado pela pessoa (ou pelo motor): a banda volta a mostrar o resumo.
179 on('ui.close', async ($, e, next) => {
180 if (e.id === PANE) await update($, aberto, () => false)
181
182 return next(e)
183 })
184
185 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186 // A banda é partilhada com outros mods: o que os de baixo desenham fica.
187 const deles = await next(e)
188 if (e.props.hasSurvey || (await read($, aberto))) return deles
189 const segs = banda(await read($, uso), await read($, amostras), await read($, radar), await $.clock.now())
190 if (segs.length === 0) return deles
191
192 const { Box, Text } = $.ui.resolve(e)
193 const pedaco = (s: Linha) => (s.cor ? <Text color={s.cor}>{s.texto}</Text> : <Text dimColor>{s.texto}</Text>)
194 const filhos = segs.flatMap((s, i) => (i === 0 ? [pedaco(s)] : [<Text dimColor> │ </Text>, pedaco(s)]))
195 const linha = <Text wrap="truncate-end">{filhos}</Text>
196
197 return deles ? (
198 <Box flexDirection="column">
199 {deles}
200 {linha}
201 </Box>
202 ) : (
203 linha
204 )
205 })
206
207 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
208 const { Box, Text, Button } = $.ui.resolve(e)
209 const filas = painel(
210 await read($, uso),
211 await read($, amostras),
212 await read($, radar),
213 await $.clock.now(),
214 Math.max(20, e.props.bodyColumns),
215 cfg.ficheiro,
216 )
217 // Props só as que existem: um `color: undefined` não é o mesmo que não ter cor.
218 const seg = (s: Seg) => {
219 const p: TextProps = {}
220 if (s.cor) p.color = s.cor
221 if (s.dim) p.dimColor = true
222 if (s.bold) p.bold = true
223 return <Text {...p}>{s.t}</Text>
224 }
225
226 return (
227 <Box flexDirection="column">
228 {filas.map(f => (f.length === 0 ? <Text> </Text> : <Text wrap="truncate-end">{f.map(seg)}</Text>))}
229 <Text> </Text>
230 <Button key="atualizar" label="refresh radar" hotkey="r" plain onPress={() => void refrescarRadar($)} />
231 </Box>
232 )
233 })
234}
235hooks/calc.ts 306 lines1// Lógica pura do brain-dash: projeções, a banda acima do prompt e o painel ao lado do código.
2// Sem `$`, para os testes a poderem chamar diretamente. De onde vêm os prazos é o radar.ts.
3import type { Radar, Uso } from '../types'
4
5export const JANELAS: Record<string, { nome: string; ms: number }> = {
6 five_hour: { nome: '5h', ms: 5 * 3_600_000 },
7 seven_day: { nome: 'week', ms: 7 * 86_400_000 },
8}
9const DIAS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
10const MESES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
11
12export type Cor = 'error' | 'warning'
13export type Linha = { texto: string; cor?: Cor }
14export type Projecao = { pct: number; projetado?: number; esgotaEm?: number }
15
16/**
17 * Onde a janela acaba ao ritmo médio que levou desde que abriu. Linear de propósito: é uma
18 * estimativa, e nos primeiros 5 % da janela a média ainda não quer dizer nada.
19 */
20export function projetar(pct: number, reset: number | undefined, agora: number, janelaMs: number): Projecao {
21 if (reset === undefined) return { pct }
22 const decorrido = agora - (reset - janelaMs)
23 if (pct <= 0 || decorrido < janelaMs * 0.05) return { pct }
24 const ritmo = pct / decorrido
25 const projetado = pct + ritmo * Math.max(0, reset - agora)
26 if (projetado <= 100) return { pct, projetado }
27 return { pct, projetado, esgotaEm: agora + Math.max(0, 100 - pct) / ritmo }
28}
29
30/**
31 * Turnos até o contexto chegar ao limite, ao ritmo dos últimos turnos. Só conta a subida desde
32 * a última descida: uma compactação ou um /clear não são ritmo.
33 */
34export function turnosAte(
35 serie: readonly number[],
36 tokens: number | undefined,
37 limite: number | undefined,
38): { porTurno?: number; turnos?: number } {
39 if (tokens === undefined || limite === undefined) return {}
40 let inicio = 0
41 for (let i = 1; i < serie.length; i++) if ((serie[i] ?? 0) < (serie[i - 1] ?? 0)) inicio = i
42 const subida = serie.slice(inicio).slice(-6)
43 const primeiro = subida[0]
44 const ultimo = subida[subida.length - 1]
45 if (subida.length < 2 || primeiro === undefined || ultimo === undefined) return {}
46 const porTurno = (ultimo - primeiro) / (subida.length - 1)
47 if (porTurno <= 0) return { porTurno }
48 return { porTurno, turnos: Math.max(0, Math.floor((limite - tokens) / porTurno)) }
49}
50
51export function k(n: number): string {
52 return n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
53}
54
55export function hora(ms: number, agora: number): string {
56 const d = new Date(ms)
57 const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
58 return new Date(agora).toDateString() === d.toDateString() ? hhmm : `${DIAS[d.getDay()]} ${hhmm}`
59}
60
61function corContexto(pct: number, turnos: number | undefined): Cor | undefined {
62 if (pct >= 90 || (turnos !== undefined && turnos <= 2)) return 'error'
63 if (pct >= 75 || (turnos !== undefined && turnos <= 5)) return 'warning'
64 return undefined
65}
66
67function corLimite(p: Projecao): Cor | undefined {
68 if (p.pct >= 90 || (p.projetado ?? 0) >= 100) return 'error'
69 if ((p.projetado ?? p.pct) >= 80) return 'warning'
70 return undefined
71}
72
73function corPrazo(dias: number, critico: boolean): Cor | undefined {
74 if (dias < 0 || dias <= 2 || critico) return 'error'
75 return dias <= 4 ? 'warning' : undefined
76}
77
78/** A linha acima do prompt (quando o painel não está aberto): contexto, janelas e o radar. */
79export function banda(u: Uso | null, serie: readonly number[], r: Radar | null, agora: number): Linha[] {
80 const seg: Linha[] = []
81 if (u?.pct !== undefined) {
82 const { turnos } = turnosAte(serie, u.tokens, u.limiar ?? u.janela)
83 seg.push({
84 texto: `ctx ${u.pct}%` + (turnos !== undefined ? ` ~${turnos} turns` : ''),
85 cor: corContexto(u.pct, turnos),
86 })
87 }
88 for (const l of u?.limites ?? []) {
89 const j = JANELAS[l.kind]
90 if (!j) continue
91 const p = projetar(l.pct, l.reset, agora, j.ms)
92 const proj = p.projetado !== undefined ? `→${Math.min(999, Math.round(p.projetado))}%` : ''
93 const esgota = p.esgotaEm !== undefined ? ` runs out ${hora(p.esgotaEm, agora)}` : ''
94 seg.push({ texto: `${j.nome} ${Math.round(l.pct)}%${proj}${esgota}`, cor: corLimite(p) })
95 }
96 if (r?.falha) {
97 seg.push({ texto: 'RADAR FAILED — /brain', cor: 'error' })
98 } else if (r) {
99 const vencidos = r.prazos.filter(p => p.dias < 0).length
100 if (vencidos) seg.push({ texto: `${vencidos} overdue`, cor: 'error' })
101 const futuros = r.prazos.filter(p => p.dias >= 0)
102 const prox = futuros[0]
103 if (prox) {
104 const resto = futuros.length > 1 ? ` +${futuros.length - 1}` : ''
105 seg.push({ texto: `${prox.quando} ${prox.texto}${resto}`, cor: corPrazo(prox.dias, prox.critico) })
106 }
107 }
108 return seg
109}
110
111// ---------------------------------------------------------------- o painel ao lado do código
112//
113// Tudo em texto com cor (blocos e pontos), não em Raster: o Raster é só do terminal, e assim o
114// mesmo painel desenha-se no Desktop e os testes encontram-no por texto.
115
116/** Um bocado de uma fila do painel. `cor` é uma chave do tema (segue o tema da pessoa). */
117export type Seg = { t: string; cor?: string; dim?: boolean; bold?: boolean }
118export type Fila = Seg[]
119
120const NIVEIS = '▁▂▃▄▅▆▇█'
121const LETRAS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] // Sun..Sat
122
123/** Junta células do mesmo estilo num só segmento: menos elementos, o mesmo desenho. */
124function juntar(celulas: Seg[]): Fila {
125 const out: Fila = []
126 for (const c of celulas) {
127 const ult = out[out.length - 1]
128 if (ult && ult.cor === c.cor && ult.dim === c.dim && ult.bold === c.bold) ult.t += c.t
129 else out.push({ ...c })
130 }
131 return out
132}
133
134/** Título à esquerda e um valor encostado à direita, em `w` colunas. */
135function titulo(esq: Seg, dir: Seg, w: number): Fila {
136 return [esq, { t: ' '.repeat(Math.max(1, w - esq.t.length - dir.t.length)) }, dir]
137}
138
139/**
140 * Uma barra de `w` células: cheia até `cheio` (0–1) na cor dada, sombreada até `sombra` (a
141 * projeção), vazia no resto, e um marco opcional (o limiar de compactação).
142 */
143export function barra(w: number, cheio: number, cor: string, sombra = 0, marco?: number): Fila {
144 const n = (f: number) => Math.max(0, Math.min(w, Math.round(f * w)))
145 const a = n(cheio)
146 const b = Math.max(a, n(sombra))
147 const m = marco === undefined ? -1 : Math.min(w - 1, n(marco))
148 const celulas: Seg[] = []
149 for (let i = 0; i < w; i++) {
150 if (i === m) celulas.push({ t: '│', cor: 'warning' })
151 else if (i < a) celulas.push({ t: '█', cor })
152 else if (i < b) celulas.push({ t: '▒', cor })
153 else celulas.push({ t: '░', dim: true })
154 }
155 return juntar(celulas)
156}
157
158/** O contexto por turno, em 8 níveis, escalado ao limite (não ao máximo da série). */
159export function sparkline(serie: readonly number[], limite: number, w: number): string {
160 return serie
161 .slice(-w)
162 .map(v => NIVEIS[Math.max(0, Math.min(7, Math.floor((v / limite) * 8)))] ?? '▁')
163 .join('')
164}
165
166function corPrazoVisual(dias: number, critico: boolean): string {
167 return corPrazo(dias, critico) ?? 'suggestion'
168}
169
170/** Os próximos `dias` dias a partir de `r.hoje`, cada um com a pior cor dos prazos que lá caem. */
171export function calendario(r: Radar, dias: number): { iso: string; letra: string; cor?: string }[] {
172 const [y, m, d] = r.hoje.split('-').map(Number)
173 const out: { iso: string; letra: string; cor?: string }[] = []
174 for (let i = 0; i < dias; i++) {
175 const dia = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + i))
176 const iso = dia.toISOString().slice(0, 10)
177 const letra = LETRAS[dia.getUTCDay()] ?? '?'
178 const cores = r.prazos.filter(p => p.data === iso).map(p => corPrazoVisual(p.dias, p.critico))
179 const cor = cores.includes('error') ? 'error' : cores.includes('warning') ? 'warning' : cores[0]
180 out.push(cor ? { iso, letra, cor } : { iso, letra })
181 }
182 return out
183}
184
185function blocoContexto(u: Uso, serie: readonly number[], w: number): Fila[] {
186 const pct = u.pct ?? 0
187 const limite = u.limiar ?? u.janela
188 const { porTurno, turnos } = turnosAte(serie, u.tokens, limite)
189 const cor = corContexto(pct, turnos) ?? 'claude'
190 const filas: Fila[] = [
191 titulo({ t: 'CONTEXT', bold: true }, { t: `${pct}%`, cor, bold: true }, w),
192 barra(w, pct / 100, cor, 0, u.limiar !== undefined ? u.limiar / u.janela : undefined),
193 [
194 {
195 t: `${k(u.tokens ?? 0)} of ${k(u.janela)}` + (u.limiar !== undefined ? ` · │ compacts at ${k(u.limiar)}` : ''),
196 dim: true,
197 },
198 ],
199 ]
200 if (serie.length >= 2) {
201 const ritmo = turnos !== undefined && porTurno !== undefined ? ` +${k(porTurno)}/turn · ~${turnos} turns` : ''
202 const largura = Math.max(4, Math.min(serie.length, w - ritmo.length - 1))
203 filas.push([{ t: sparkline(serie, limite, largura), cor }, { t: ritmo, dim: true }])
204 }
205 return filas
206}
207
208function blocoJanela(l: { pct: number; reset?: number }, nome: string, ms: number, agora: number, w: number): Fila[] {
209 const p = projetar(l.pct, l.reset, agora, ms)
210 const cor = corLimite(p) ?? 'success'
211 const reset = l.reset !== undefined ? `reset ${hora(l.reset, agora)}` : ''
212 const tempo = l.reset !== undefined ? Math.max(0, Math.min(1, (agora - (l.reset - ms)) / ms)) : undefined
213 const bw = Math.max(8, w - 11)
214 const num = (x: number) => `${Math.round(x)}%`.padStart(5)
215 const filas: Fila[] = [titulo({ t: nome, bold: true }, { t: reset, dim: true }, w)]
216 if (tempo !== undefined) {
217 filas.push([{ t: 'time ', dim: true }, ...barra(bw, tempo, 'inactive'), { t: num(tempo * 100), dim: true }])
218 }
219 filas.push([
220 { t: 'usage ', dim: true },
221 ...barra(bw, l.pct / 100, cor, (p.projetado ?? 0) / 100),
222 { t: num(l.pct), cor, bold: true },
223 ])
224 if (p.projetado !== undefined) {
225 filas.push([{ t: ` ▒ at reset, at this pace: ${Math.round(p.projetado)}%`, dim: true }])
226 // Numa linha própria: junto à projeção não cabia nas 50 colunas e ficava cortado (12 out 2026).
227 if (p.esgotaEm !== undefined) filas.push([{ t: ` runs out ~${hora(p.esgotaEm, agora)}`, cor: 'error', bold: true }])
228 } else {
229 filas.push([{ t: ' too early in the window to project', dim: true }])
230 }
231 return filas
232}
233
234export function blocoRadar(r: Radar, agora: number, w: number): Fila[] {
235 if (r.falha) {
236 return [
237 [{ t: 'RADAR FAILED', cor: 'error', bold: true }],
238 [{ t: r.falha, cor: 'error' }],
239 [{ t: "Don't assume there are no deadlines.", bold: true }],
240 [{ t: `source: ${r.fonte}`, dim: true }],
241 ]
242 }
243 const dias = calendario(r, r.janela_dias || 21)
244 const cw = w >= dias.length * 2 ? 2 : 1
245 const pad = (s: string) => s.padEnd(cw)
246 const filas: Fila[] = [
247 titulo({ t: `RADAR · ${dias.length} days`, bold: true }, { t: `read ${hora(r.lidoEm, agora)}`, dim: true }, w),
248 juntar(dias.map((d, i) => (i === 0 ? { t: pad(d.letra), bold: true } : { t: pad(d.letra), dim: true }))),
249 juntar(dias.map(d => (d.cor ? { t: pad('■'), cor: d.cor } : { t: pad('·'), dim: true }))),
250 [{ t: '▲ today', dim: true }],
251 [],
252 ]
253 for (const p of r.prazos) {
254 filas.push([
255 { t: '● ', cor: corPrazoVisual(p.dias, p.critico) },
256 { t: `${p.quando.padEnd(18)} `, dim: p.dias > 4 && !p.critico },
257 { t: p.texto },
258 ])
259 }
260 if (r.prazos.length === 0) filas.push([{ t: `Nothing in the next ${r.janela_dias} days.`, dim: true }])
261 if (r.sem_data.length) filas.push([{ t: '⚑ no date: ', cor: 'warning', bold: true }, { t: r.sem_data.join(' · ') }])
262 for (const a of r.avisos) filas.push([{ t: '⚑ ', cor: 'warning' }, { t: a }])
263 filas.push([], [{ t: `source: ${r.fonte}`, dim: true }])
264 return filas
265}
266
267/** Sem fonte de prazos: diz como ter uma, em vez de deixar o lugar vazio. */
268function semRadar(ficheiro: string): Fila[] {
269 return [
270 [{ t: 'RADAR', bold: true }],
271 [{ t: `No deadlines yet: create ${ficheiro} at the`, dim: true }],
272 [{ t: 'project root, one per line (! = critical):', dim: true }],
273 [{ t: ' 2026-10-15 Submit the report !', cor: 'suggestion' }],
274 [{ t: 'or point radar_command at your own script.', dim: true }],
275 ]
276}
277
278/**
279 * O painel inteiro, fila a fila, para `w` colunas. Uma fila vazia é uma linha em branco.
280 * `ficheiro` é o nome que a dica sugere quando não há radar.
281 */
282export function painel(
283 u: Uso | null,
284 serie: readonly number[],
285 r: Radar | null,
286 agora: number,
287 w: number,
288 ficheiro = 'DEADLINES.md',
289): Fila[] {
290 const d = new Date(agora)
291 const relogio = `${DIAS[d.getDay()]} ${MESES[d.getMonth()]} ${d.getDate()} ${hora(agora, agora)}`
292 const filas: Fila[] = [titulo({ t: 'BRAIN-DASH', cor: 'claude', bold: true }, { t: relogio, dim: true }, w), []]
293 if (u?.tokens !== undefined) filas.push(...blocoContexto(u, serie, w), [])
294 for (const l of u?.limites ?? []) {
295 const j = JANELAS[l.kind]
296 if (j) filas.push(...blocoJanela(l, j.nome === '5h' ? '5-HOUR WINDOW' : 'WEEK', j.ms, agora, w), [])
297 }
298 if (!u) filas.push([{ t: 'Usage: arrives with the first reply.', dim: true }], [])
299 else if (!u.limites.some(l => JANELAS[l.kind])) filas.push([{ t: 'Limits: no reading yet.', dim: true }], [])
300 if (u?.usd !== undefined) {
301 filas.push([{ t: `session $${u.usd.toFixed(2)} API-equivalent (quota on a plan)`, dim: true }], [])
302 }
303 filas.push(...(r ? blocoRadar(r, agora, w) : semRadar(ficheiro)))
304 return filas
305}
306hooks/radar.ts 194 lines1// O radar, separado do desenho: de onde vêm os prazos e como se normalizam. Há duas fontes e um só
2// caminho depois delas:
3//
4// ficheiro (DEADLINES.md por omissão) ─┐
5// ├─ lerFonte → Lido ─ montar → Radar ─ calc.ts desenha
6// comando (radar_command) ─────────────┘
7//
8// As duas aceitam os mesmos dois formatos: linhas simples (uma por prazo) ou JSON. Quem tem um
9// script seu (o AI-Brain tem o agenda.py) aponta o comando para ele; quem não tem escreve um
10// ficheiro. Lógica pura, sem `$`, para os testes a poderem chamar diretamente.
11import type { PluginOptions } from 'claude-code'
12
13import type { Prazo, Radar } from '../types'
14
15/** Um prazo como sai da fonte, antes de se saber quantos dias faltam. */
16export type Bruto = { data: string; texto: string; critico: boolean }
17export type Lido = { prazos: Bruto[]; sem_data: string[]; avisos: string[] }
18export type Config = { ficheiro: string; comando: string[]; dias: number }
19
20export const FICHEIRO = 'DEADLINES.md'
21const DIAS = 21
22// Um vencido fica à vista duas semanas; um crítico, até ser marcado feito ou apagado.
23const VENCIDO_MAX_DIAS = 14
24const DIA_MS = 86_400_000
25
26/** As opções do manifesto (`userConfig`), com os valores por omissão onde faltam ou não servem. */
27export function config(o: PluginOptions): Config {
28 const ficheiro = typeof o.radar_file === 'string' && o.radar_file.trim() ? o.radar_file.trim() : FICHEIRO
29 const comando = typeof o.radar_command === 'string' ? partirComando(o.radar_command.trim()) : []
30 const d = typeof o.radar_days === 'number' && o.radar_days > 0 ? Math.round(o.radar_days) : DIAS
31 return { ficheiro, comando, dias: Math.max(1, Math.min(d, 90)) }
32}
33
34/**
35 * Parte um comando em argumentos, com aspas simples ou duplas. Sem shell: um pipe ou um `&&` vão
36 * como texto, e quem precisar deles escreve `sh -c '...'`.
37 */
38export function partirComando(cmd: string): string[] {
39 const out: string[] = []
40 let cur = ''
41 let aspa: string | undefined
42 let houve = false
43 for (const c of cmd) {
44 if (aspa) {
45 if (c === aspa) aspa = undefined
46 else cur += c
47 } else if (c === '"' || c === "'") {
48 aspa = c
49 houve = true
50 } else if (/\s/.test(c)) {
51 if (cur || houve) out.push(cur)
52 cur = ''
53 houve = false
54 } else {
55 cur += c
56 }
57 }
58 if (cur || houve) out.push(cur)
59 return out
60}
61
62function iso(a: number, m: number, d: number): string | undefined {
63 const t = new Date(Date.UTC(a, m - 1, d))
64 if (t.getUTCFullYear() !== a || t.getUTCMonth() !== m - 1 || t.getUTCDate() !== d) return undefined
65 return t.toISOString().slice(0, 10)
66}
67
68const RE_ISO = /(\d{4})-(\d{2})-(\d{2})/
69const RE_DMA = /(\d{1,2})\/(\d{1,2})\/(\d{4})/
70
71/** A primeira data da linha (`2026-10-15` ou `15/10/2026`) e a linha sem ela. */
72export function acharData(s: string): { data: string; resto: string } | undefined {
73 for (const [re, ordem] of [
74 [RE_ISO, [1, 2, 3]],
75 [RE_DMA, [3, 2, 1]],
76 ] as const) {
77 const m = re.exec(s)
78 if (!m) continue
79 const [a, me, d] = ordem.map(i => Number(m[i]))
80 const data = iso(a ?? 0, me ?? 0, d ?? 0)
81 if (data) return { data, resto: s.slice(0, m.index) + s.slice(m.index + m[0].length) }
82 }
83 return undefined
84}
85
86/** Tira o que sobra à volta do texto depois de lhe tirar a data: espaços, traços, dois pontos. */
87function limpar(s: string): string {
88 return s.replace(/^[\s—–:|·-]+/, '').replace(/[\s—–:|·-]+$/, '')
89}
90
91/**
92 * O formato simples, uma linha por prazo:
93 *
94 * 2026-10-15 Entregar o relatório ! ← `!` no fim: crítico
95 * - [ ] 20/10/2026 Exame de Redes ← listas e caixas do Markdown servem
96 * - [x] 2026-10-01 Já feito ← caixa marcada: ignorado
97 * - Email ao orientador ! ← item crítico sem data: vai para "sem data"
98 *
99 * Títulos (`#`) e linhas sem data e sem `!` são ignorados, para o ficheiro poder ter notas.
100 */
101export function lerLinhas(texto: string): Lido {
102 const out: Lido = { prazos: [], sem_data: [], avisos: [] }
103 for (const crua of texto.split(/\r?\n/)) {
104 let l = crua.trim()
105 if (!l || l.startsWith('#') || l.startsWith('<!--')) continue
106 const marcador = /^([-*+]|\d+[.)])\s+/.exec(l)
107 if (marcador) l = l.slice(marcador[0].length)
108 const caixa = /^\[([ xX])\]\s*/.exec(l)
109 if (caixa) {
110 if (caixa[1] !== ' ') continue
111 l = l.slice(caixa[0].length)
112 }
113 const critico = /!\s*$/.test(l)
114 if (critico) l = l.replace(/\s*!+\s*$/, '')
115 const d = acharData(l)
116 if (d) out.prazos.push({ data: d.data, texto: limpar(d.resto) || '(no text)', critico })
117 else if (critico && marcador) out.sem_data.push(limpar(l))
118 }
119 return out
120}
121
122const textos = (v: unknown): string[] => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : [])
123
124/**
125 * O formato JSON: uma lista de prazos, ou `{ prazos, sem_data, avisos }`. Aceita as chaves em
126 * português e em inglês (`deadlines`, `date`, `text`, `critical`, `undated`, `warnings`), e ignora
127 * o resto — por isso a saída do `agenda.py --json` do AI-Brain serve tal como está.
128 */
129export function lerJson(v: unknown): Lido {
130 const obj: Record<string, unknown> = Array.isArray(v)
131 ? { prazos: v }
132 : v !== null && typeof v === 'object'
133 ? (v as Record<string, unknown>)
134 : {}
135 const lista = obj.prazos ?? obj.deadlines
136 if (!Array.isArray(lista)) throw new Error('the JSON has no "deadlines" (or "prazos") list')
137 const prazos: Bruto[] = []
138 for (const p of lista) {
139 if (p === null || typeof p !== 'object') continue
140 const r = p as Record<string, unknown>
141 const d = acharData(String(r.data ?? r.date ?? ''))
142 if (!d) continue
143 const texto = String(r.texto ?? r.text ?? r.title ?? '').trim() || '(no text)'
144 prazos.push({ data: d.data, texto, critico: Boolean(r.critico ?? r.critical) })
145 }
146 return { prazos, sem_data: textos(obj.sem_data ?? obj.undated), avisos: textos(obj.avisos ?? obj.warnings) }
147}
148
149/** Escolhe o formato: `.json`, ou um texto que comece por `{` ou `[`, é JSON; o resto são linhas. */
150export function lerFonte(texto: string, nome: string): Lido {
151 const t = texto.trim()
152 if (nome.toLowerCase().endsWith('.json') || t.startsWith('{') || t.startsWith('[')) return lerJson(JSON.parse(t))
153 return lerLinhas(texto)
154}
155
156/** A data de hoje no fuso da máquina, em `AAAA-MM-DD`. */
157export function hojeDe(agora: number): string {
158 const d = new Date(agora)
159 return iso(d.getFullYear(), d.getMonth() + 1, d.getDate()) ?? '1970-01-01'
160}
161
162function diasEntre(de: string, ate: string): number {
163 return Math.round((Date.parse(`${ate}T00:00:00Z`) - Date.parse(`${de}T00:00:00Z`)) / DIA_MS)
164}
165
166const MESES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
167
168/** `Oct 15 3d`, `Oct 13 tomorrow`, `Oct 12 today`, `Oct 9 OVERDUE 3d`. */
169export function quando(data: string, dias: number): string {
170 const dm = `${MESES[Number(data.slice(5, 7)) - 1] ?? '?'} ${Number(data.slice(8, 10))}`
171 if (dias < 0) return `${dm} OVERDUE ${-dias}d`
172 if (dias === 0) return `${dm} today`
173 if (dias === 1) return `${dm} tomorrow`
174 return `${dm} ${dias}d`
175}
176
177/** Situa os prazos no tempo e corta a janela: os próximos `janela` dias e os vencidos recentes. */
178export function montar(l: Lido, agora: number, janela: number, fonte: string): Radar {
179 const hoje = hojeDe(agora)
180 const prazos: Prazo[] = l.prazos
181 .map(p => {
182 const dias = diasEntre(hoje, p.data)
183 return { ...p, dias, quando: quando(p.data, dias) }
184 })
185 .filter(p => p.dias <= janela && (p.dias >= -VENCIDO_MAX_DIAS || p.critico))
186 .sort((a, b) => a.data.localeCompare(b.data) || Number(b.critico) - Number(a.critico))
187 return { hoje, janela_dias: janela, fonte, prazos, sem_data: l.sem_data, avisos: l.avisos, falha: null, lidoEm: agora }
188}
189
190/** Um radar que não correu diz que não correu: o silêncio não pode querer dizer duas coisas. */
191export function falhou(fonte: string, falha: string, agora: number, janela: number): Radar {
192 return { hoje: hojeDe(agora), janela_dias: janela, fonte, prazos: [], sem_data: [], avisos: [], falha, lidoEm: agora }
193}
194types/index.d.ts 45 lines1/** Uma janela de limite do plano (`five_hour`, `seven_day`): percentagem e reset em ms. */
2export type Limite = { kind: string; pct: number; reset?: number }
3
4/** O que o `session.measure` traz, mais o limiar de auto-compactação lido no fim de cada turno. */
5export type Uso = {
6 tokens?: number
7 janela: number
8 pct?: number
9 limiar?: number
10 limites: Limite[]
11 usd?: number
12}
13
14/** Um prazo já situado no tempo: quantos dias faltam (negativo se venceu) e como se diz. */
15export type Prazo = { data: string; dias: number; quando: string; texto: string; critico: boolean }
16
17/** O radar pronto a desenhar, venha de um ficheiro de prazos ou de um comando. */
18export type Radar = {
19 hoje: string
20 janela_dias: number
21 /** O ficheiro ou o comando de onde veio, para o rodapé e para a mensagem de falha. */
22 fonte: string
23 prazos: Prazo[]
24 /** Itens críticos ainda sem data. */
25 sem_data: string[]
26 /** Avisos livres que a fonte queira mostrar (só por JSON). */
27 avisos: string[]
28 falha: string | null
29 lidoEm: number
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'brain-dash': {
35 uso: Uso | null
36 /** Tokens de contexto no fim de cada turno da conversa principal, os últimos 24. */
37 amostras: number[]
38 /** null quando não há fonte de prazos (sem ficheiro e sem comando): só se mostra o uso. */
39 radar: Radar | null
40 /** O painel está aberto e com lugar no ecrã: a banda acima do prompt cala-se. */
41 aberto: boolean
42 }
43 }
44}
45