SLOPSHOPPER

brain-dash

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

newpanebandcommandtoastprocess
v0.3.1MITupdated 2026-10-12sillss1/brain-dash/brain-dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brain-dash
│ ┃ brain ✕ › fix the failing auth test and add an audit log call │ ┃ BRAIN-DASH Th… │ ┃ ⏺ Read(src/auth.ts) │ ┃ CONTEXT … ⎿ Read 6 lines │ ┃ ███████████████████████████░░░░░░░░░░░░░░░░… ⏺ Update(src/auth.ts) │ ┃ 97k of 200k ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 5-HOUR WINDOW … ⎿ 3 pass, 1 fail │ ┃ usage ██████████████░░░░░░░░░░░░░░░░░░░░░░░… │ ┃ too early in the window to project ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ session $0.42 API-equivalent (quota on a pl… ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ RADAR › /brain │ ┃ No deadlines yet: create DEADLINES.md at the │ ┃ project root, one per line (! = critical): │ ┃ 2026-10-15 Submit the report ! │ ┃ or point radar_command at your own script. │ ┃ │ ┃ r: refresh radar │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · brain
BRAIN-DASH Thu Oct 9 08:53 CONTEXT 49% ███████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 97k of 200k 5-HOUR WINDOW usage ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 31% too early in the window to project session $0.42 API-equivalent (quota on a plan) RADAR No deadlines yet: create DEADLINES.md at the project root, one per line (! = critical): 2026-10-15 Submit the report ! or point radar_command at your own script. r: refresh radar
README

brain-dash

See how much room your Claude Code session has left, and what is due next, without leaving the code.

License: MIT Claude Code mod Network: none

<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%">

Why

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.

See it work

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%">

What you get

<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%">

Install

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.

Quick start

  1. Install it (above). The usage part works straight away.
  2. Create DEADLINES.md at the root of your project:
   - [ ] 2026-10-15 Thesis draft to advisor !
   - [ ] 2026-10-20 Networks exam
  1. Finish a turn, or press r in the pane. The radar appears.

/brain opens and closes the pane at any time.

The deadline radar

<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%">

File format

One deadline per line. Everything else in the file is ignored, so it can hold notes too.

You writeIt means
2026-10-15 Submit the reportA deadline. The date can be anywhere in the line, and the rest of the line becomes the text.
15/10/2026 Submit the reportThe 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 dateIgnored.

The radar re-reads the file at the end of the turn after you save it.

Using your own command

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:

  • runs at the project root, at startup, every 30 minutes, and when you press r in the pane;
  • runs without a shell, so for pipes or && wrap it: sh -c 'your | pipeline';
  • has 20 seconds to finish;
  • prints either the line format above, or JSON.

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:

  • the command exits non-zero;
  • it times out;
  • it prints invalid JSON;
  • the file cannot be read.

An empty radar always means there is nothing due, never that the radar failed to run.

Options

Set them in /config under brain-dash. Changing one reloads the mod with the new value.

OptionDefaultWhat it does
radar_fileDEADLINES.mdThe 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_days21How 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.

How the numbers are worked out

  • Turns left = (compaction threshold − current context) ÷ average growth per turn, over up to the last 6 turns since the last drop. If auto-compact is off, the full window is the limit.
  • Projection at reset = used + (used ÷ time elapsed in the window) × time remaining. It is linear on purpose. It's an estimate, and it stays hidden for the first 5% of a window, when the average means little.
  • Runs out at is shown only when the projection passes 100%. It is when, at the same pace, usage would reach 100%.
  • session $ is the API-equivalent cost of the session as Claude Code reports it. On a subscription plan this is quota, not money.

All of this is computed locally from the usage figures Claude Code already reports. Nothing is requested from any server.

What it touches

NetworkNone.
Files writtenNone.
Files readYour deadlines file.
ProcessesOnly radar_command, and only if you set it.
Prompts and tool callsNot 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.

Troubleshooting

SymptomCause and fix
The pane does not open at startupUnasked, 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 FAILEDThe command or the file failed. The pane shows the reason and the source underneath.
No radar at allThere is no DEADLINES.md (or radar_file) at the project root, and no radar_command.
Times look wrongTimes and "today" use your machine's time zone.
Two panels or two bandsTwo copies of the mod are loaded, for example an installed one and a --plugin-dir copy. Keep one.

Development

.
├── .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.

License

MIT © 2026 Francisco Antunes

Source 4 files
hooks/register.tsx 235 lines
1// 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}
235
hooks/calc.ts 306 lines
1// 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}
306
hooks/radar.ts 194 lines
1// 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}
194
types/index.d.ts 45 lines
1/** 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