SLOPSHOPPER

painel-sessao

Session panel for Claude Code: the steps of the session with the current one marked, the decisions waiting for you, a queue of next prompts with auto-send…

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · painel-sessao
│ ┃ Painel da sessão ✕ › fix the failing auth test and add an audit log call │ ┃ [ Esta sessão ] [ Sessões ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ Aguardando você agora resposta 08:53 │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ ▍ ▾ DECISÕES SUAS │ │ ┃ │ Nenhuma decisão pendente. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ ⏱ 09/10/2025 · pergunta 08:52 · resposta 08:53 · 42 s (GMT) │ ┃ │ ▍ ▸ PASSOS nenhum registrado │ │ ┃ ╰──────────────────────────────────────────╯ › /painel │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▍ ▾ PRÓXIMOS PROMPTS │ ┃ │ ● [ Envio automático: ligado ] Depois de c │ ┃ │ prompt entr │ ┃ │ │ ┃ │ ▣ client module ./textarea.tsx │ ┃ │ │ ┃ │ Nenhum prompt anotado. │ ┃ ╰─────────────────────────────────────────── │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Painel da sessão
[ Esta sessão ] [ Sessões ] ╭──────────────────────────────────────────────────────────╮ │ Aguardando você agora resposta 08:53 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▍ ▾ DECISÕES SUAS │ │ Nenhuma decisão pendente. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▍ ▸ PASSOS nenhum registrado │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ ▍ ▾ PRÓXIMOS PROMPTS │ ● [ Envio automático: ligado ] Depois de cada resposta, o │ segundos. │ │ ▣ client module ./textarea.tsx │ │ Nenhum prompt anotado. ╰─────────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────── │ ▍ ▸ MINHAS ÚLTIMAS MENSAGENS 08:53 · fix the failing auth ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ ▍ ▸ NOTAS 0 anotações · 0 pontos · 0 termos │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▍ ▸ CONTEXTO E LIMITES 49% · 97,4k de 200k │ ╰──────────────────────────────────────────────────────────╯ [ ↻ Preencher o painel a partir da conversa ] [ ⧉ Copiar pas
README

claude-code-mods-gbvdm

Mods for Claude Code. A mod is a plugin that draws in Claude Code and reacts to its events.

Leia em português: README.pt-BR.md

painel-sessao: a panel for each session

A pane that stays beside the conversation and answers, at a glance, where the session is and what it waits for.

SectionWhat it shows
TopWhether Claude is answering or waiting for you, for how long, the current step and a progress bar
Your decisionsThe questions Claude left for you to decide, until you answer them
StepsThe last two steps done, the current one marked, and the next ones
Next promptsPrompts you queue while Claude works. Send one with a click, reorder them, or turn on auto-send
My last messagesYour last messages with their time. Click a time to scroll the conversation to that message
NotesImportant points and terms of the session, and your own free notes
Context and limitsHow full the context window is, the limits of your plan, the last turn and compactions
Sessions (second view)The other sessions of the same machine that run the panel: answering, waiting for you, ended

Claude keeps the steps, decisions, terms and points up to date through a tool the mod registers. Everything else is yours: the queue, the notes, the buttons.

What changes in your sessions

Three things happen without you asking, and two of them have an option to turn off:

  • The pane opens by itself once in each new session (option openOnStart).
  • Each answer ends with one line: the date, when you asked, when the answer ended and how long it took (option answerLine).
  • A band above the prompt shows the current step and how many prompts are queued, while there is a current step or a queued prompt.

Requirements

  • Claude Code 2.1.287 or later in the terminal, or the Code tab of the Claude Desktop app.
  • The pane is drawn in the terminal and in the Desktop app. In the VS Code extension and on the phone the commands work and nothing is drawn.

Install

The panel comes in Portuguese unless you ask for English:

claude plugin marketplace add guilhermebvdm/claude-code-mods-gbvdm
claude plugin install painel-sessao@gbvdm-mods --config language=en

Start a new session, or run /reload-plugins in an open one. In a session that was already under way, run /panel-reload to fill the panel from the conversation.

Options

OptionValuesDefault
languagept-BR or enpt-BR
timeZoneAn IANA time zone name, such as America/Sao_Paulo or Europe/Lisbonempty: the time zone of the system
openOnStarttrue or falsetrue
answerLinetrue or falsetrue

Set them when you install, with one --config for each:

claude plugin install painel-sessao@gbvdm-mods --config language=en --config timeZone=Europe/Lisbon --config answerLine=false

Or later, in a session, from the plugin's row in /plugin.

Commands

PortugueseEnglishWhat it does
/painel/panelOpens the pane
/anotar <text>/queue <text>Queues a prompt in Next prompts, without starting a turn
/nota <text>/note <text>Writes to your notes. Takes several lines
`/envio-automatico [ligar\desligar]``/auto-send [on\off]`Turns auto-send on or off
/painel-recarregar/panel-reloadFills the panel from the conversation

The mod registers and answers the names of the language in force. The names of the other language are not its commands.

Auto-send

With auto-send on, the first queued prompt enters 5 seconds after each answer, and the pane shows the time and a Cancel button during those 5 seconds.

  • With auto-send on and no answer running, a queued prompt is sent within 20 seconds, with no answer in between.
  • It waits while a background agent runs and, for up to 3 minutes, while the app shows its survey about the session.
  • A prompt that does not enter the conversation is sent again within 20 seconds. After 3 failures in a row auto-send turns itself off.
  • An answer you interrupt, or one that ends with an error, turns it off, and the pane says why.
  • It starts off in every new session.

What this mod can reach

A mod is code that runs with your permissions. Read this before you install it, and check it yourself: clone the repository and run claude plugin validate plugins/painel-sessao, whose hooks: and calls: lines list the events the mod handles and what it asks Claude Code to do.

  • It reads your prompts. It keeps the first 300 characters of each one to list them in the pane.
  • It sends prompts as you. The Send button and auto-send submit a queued prompt as if you had typed it. Only prompts you queued are sent.
  • It approves its own tool. Claude calls mcp__painel-sessao__atualizar without a permission prompt to write steps, decisions, terms and points to the panel. A call that turns auto-send on is not approved by the mod: you are asked, as for any tool. A deny rule of yours stays in force. The tool and its fields keep their Portuguese names in English too.
  • It adds a short text to your prompts. When the panel changed since the last prompt, the next one carries its steps, decisions, terms and points, so Claude knows what the panel shows. That text also goes with the first prompt of a session and after each compaction. With a prompt sent from the pane, it goes as a separate row of the conversation that only the model reads.
  • It can spend your usage. /panel-reload and the button "Fill the panel from the conversation" read the whole conversation and ask the model for a summary. Nothing else calls a model. The tool is in every request's tool list, and Claude calls it as the plan changes, which adds some tokens to ordinary turns.
  • It writes to the clipboard and to the prompt box when you click. The copy buttons copy text, and "Move to the prompt box" puts a queued prompt in the prompt box.
  • It stores the panel on your machine. One JSON file under ~/.claude/plugins/store/ holds, for each session: steps, decisions, terms and points; your queued prompts and the lines of one being written; your notes; the start of your messages and the ids of their rows in the conversation; the session's title or the name of its folder; what auto-send last did, with the first 60 characters of each prompt it sent; and which buttons of the pane were last pressed. A session's entry is deleted after 30 days with no activity, and the file keeps the entries of the 40 other sessions that saved last.

It makes no network request, starts no process, and reads or writes no file other than that store.

Known limits

  • A queued prompt has at most 4000 characters, the queue 30 prompts, and your notes 200 lines. What goes beyond is refused with a notice, never cut. One exception: a line of your notes longer than 1000 characters is cut at 1000.
  • The one-line field of the Desktop app has a width the app sets. The button "Try the wide multi-line box" swaps it for a box as wide as the pane. That box was not checked in the Desktop app.
  • The Desktop app replaces the pane's content each time it is drawn again, so a selection made with the mouse does not last. The copy buttons are there for that.
  • The mod was written and used on Windows, in the Desktop app, in Portuguese. It was not run on macOS or Linux, its pane was never seen in a terminal, and its English version was checked by automated tests only. The tests cover the terminal and Desktop surfaces without drawing them.
  • This plugin and a copy of it loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS have the same name: with both, the installed one does not load.

Development

claude plugin validate plugins/painel-sessao
claude plugin test plugins/painel-sessao

The tests run with the times in the zone of São Paulo, whatever the zone of the machine. plugins/painel-sessao/tsconfig.json extends the type declarations Claude Code writes beside a plugin when it loads it (.claude-plugin/types/, not in the repository): load the plugin once before running tsc. plugins/painel-sessao/.diagramas.md has sequence diagrams of the parts with the most logic (in Portuguese).

License

MIT

Source 4 files
hooks/register.tsx 2508 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ask, Board, Queued, SessionRow, Step, Term, Usage } from '../types'
5import { commandName, dateText, decimal, setLang, t, weekdayText } from './texts'
6
7const PANE = 'painel-sessao'
8// The title of the pane, in the language in force
9const title = (): string => t('Painel da sessão')
10const DAY_MS = 24 * 60 * 60 * 1000
11
12// The time zone the panel shows times in: the one of the plugin's `timeZone` option (an IANA name, as
13// "America/Sao_Paulo"), or the system's when the option is empty
14let zoneParts = new Intl.DateTimeFormat('en-GB', zoneFormat(undefined))
15
16function zoneFormat(timeZone: string | undefined): Intl.DateTimeFormatOptions {
17  return {
18    timeZone,
19    hourCycle: 'h23',
20    year: 'numeric',
21    month: '2-digit',
22    day: '2-digit',
23    hour: '2-digit',
24    minute: '2-digit',
25    second: '2-digit',
26  }
27}
28
29// Sets the time zone from the plugin's option. Returns false for a name the system does not know, and the
30// system's zone stays in force.
31function setZone(value: unknown): boolean {
32  const name = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
33  try {
34    zoneParts = new Intl.DateTimeFormat('en-GB', zoneFormat(name))
35
36    return true
37  } catch {
38    zoneParts = new Intl.DateTimeFormat('en-GB', zoneFormat(undefined))
39
40    return false
41  }
42}
43
44type Wall = { year: number; month: number; day: number; hour: number; minute: number; second: number }
45
46// The date and the time a moment has on a clock of the zone in force
47function wall(ms: number): Wall {
48  const parts: Record<string, number> = {}
49  for (const part of zoneParts.formatToParts(new Date(ms))) parts[part.type] = Number(part.value)
50
51  return {
52    year: parts.year ?? 1970,
53    month: parts.month ?? 1,
54    day: parts.day ?? 1,
55    hour: parts.hour ?? 0,
56    minute: parts.minute ?? 0,
57    second: parts.second ?? 0,
58  }
59}
60
61// The same date and time read as if they were UTC: its difference to the moment is the zone's offset then
62function wallMs(ms: number): number {
63  const w = wall(ms)
64
65  return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second)
66}
67
68// The zone's offset at a moment, as the line under an answer names it: "GMT-3", "GMT+5:30", "GMT"
69function zoneLabel(ms: number): string {
70  const minutes = Math.round((wallMs(ms) - Math.floor(ms / 1000) * 1000) / 60000)
71  if (minutes === 0) return 'GMT'
72  const size = Math.abs(minutes)
73
74  return 'GMT' + (minutes < 0 ? '-' : '+') + Math.floor(size / 60) + (size % 60 === 0 ? '' : ':' + two(size % 60))
75}
76
77// The one accent color of the panel (current step, progress, term names) and the color of a done step
78const ACCENT = '#D97757'
79const DONE = '#3FA66B'
80// The color of a session that is answering
81const BUSY = '#5B9BD5'
82
83const EMPTY: Board = { asks: [], steps: [], terms: [], points: [], queue: [], savedAt: 0 }
84const board = atom({ plugin: 'painel-sessao', key: 'board' } as const, EMPTY)
85
86// What the user chose to see in the panel: how many of their messages, and which noted prompts whole
87const view = atom({ plugin: 'painel-sessao', key: 'view' } as const, { shownAsks: 5, expanded: [] })
88
89// The key a message of the user is found by: the start of its text, on one line
90function rowKey(text: string): string {
91  return oneLine(text, 80)
92}
93
94const NO_USAGE: Usage = { window: 0, limits: [], compactions: 0 }
95const usage = atom({ plugin: 'painel-sessao', key: 'usage' } as const, NO_USAGE)
96
97// What the user has typed in the two fields of the panel and not sent yet. The drawing of the desktop app does
98// not read it, so a key typed writes it without drawing the panel again; it survives a reload of the mod,
99// which empties the module's own memory.
100const typed = atom({ plugin: 'painel-sessao', key: 'typed' } as const, { prompt: '', note: '' })
101
102const RED = '#E5484D'
103
104const two = (n: number): string => String(n).padStart(2, '0')
105
106// The day of a message of the user, as the list names it: "Hoje", "Ontem · 05/10/2026", "02/10/2026 · há 4 dias".
107// Days are calendar days in the zone in force, so a message of 23:50 is of yesterday ten minutes later.
108function dayLabel(at: number, now: number): string {
109  if (at === 0) return t('Antes de o painel ser ligado')
110  const days = Math.floor(wallMs(now) / DAY_MS) - Math.floor(wallMs(at) / DAY_MS)
111  if (days <= 0) return t('Hoje')
112  if (days === 1) return t('Ontem · {date}', { date: day(at) })
113
114  return t('{date} · há {n} dias', { date: day(at), n: days })
115}
116
117// "HH:MM:SS" in the zone in force
118function clockSeconds(ms: number): string {
119  return clock(ms) + ':' + two(wall(ms).second)
120}
121
122// A count of tokens in the short form the app uses: 441,2k, 1M
123function amount(n: number): string {
124  const cut = (value: number): string => decimal(Math.round(value * 10) / 10)
125  if (n >= 1_000_000) return cut(n / 1_000_000) + 'M'
126  if (n >= 1000) return cut(n / 1000) + 'k'
127
128  return String(n)
129}
130
131// When a limit's window resets: the time if it is today, the weekday and the time otherwise
132function resetLabel(iso: string | undefined, now: number): string {
133  if (iso === undefined) return ''
134  const ms = Date.parse(iso)
135  if (Number.isNaN(ms)) return ''
136  if (day(ms) === day(now)) return clock(ms)
137
138  return weekdayText(new Date(wallMs(ms)).getUTCDay()) + ' ' + clock(ms)
139}
140
141function limitName(kind: string): string {
142  if (kind === 'five_hour') return t('5 horas')
143  if (kind === 'seven_day') return t('Semana')
144
145  if (kind === 'spend_limit') return t('Limite de gasto')
146
147  return kind.replace(/_/g, ' ')
148}
149
150// Green while there is room, the accent from half, red near the end
151function tone(percent: number): string {
152  if (percent >= 80) return RED
153
154  return percent >= 50 ? ACCENT : DONE
155}
156
157type Measured = {
158  context: { tokens?: number; window: number; percent?: number }
159  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
160}
161
162// The usage with the figures of one measurement; a figure the measurement lacks keeps its last value
163function measured(now: Usage, e: Measured): Usage {
164  return {
165    ...now,
166    tokens: e.context.tokens ?? now.tokens,
167    window: e.context.window,
168    percent: e.context.percent ?? now.percent,
169    limits:
170      e.rateLimits.length === 0
171        ? now.limits
172        : e.rateLimits.map(one => ({ kind: one.kind, percent: one.percentUsed, resetsAt: one.resetsAt })),
173  }
174}
175
176// "HH:MM" in the zone in force
177function clock(ms: number): string {
178  const w = wall(ms)
179
180  return two(w.hour) + ':' + two(w.minute)
181}
182
183// The date in the zone in force, as the language in force writes it
184function day(ms: number): string {
185  const w = wall(ms)
186
187  return dateText(w.year, w.month, w.day)
188}
189
190function span(ms: number): string {
191  const seconds = Math.round(ms / 1000)
192  if (seconds < 60) return seconds + ' s'
193  const minutes = Math.floor(seconds / 60)
194  if (minutes < 60) return minutes + ' min ' + two(seconds % 60) + ' s'
195  return Math.floor(minutes / 60) + ' h ' + two(minutes % 60) + ' min'
196}
197
198function oneLine(text: string, max: number): string {
199  const flat = text.replace(/\s+/g, ' ').trim()
200  return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
201}
202
203function isBoard(value: unknown): value is Board {
204  if (typeof value !== 'object' || value === null) return false
205  const b = value as Record<string, unknown>
206  return (
207    Array.isArray(b.asks) &&
208    Array.isArray(b.steps) &&
209    Array.isArray(b.terms) &&
210    Array.isArray(b.points) &&
211    Array.isArray(b.queue)
212  )
213}
214
215function isEmpty(b: Board): boolean {
216  return (
217    b.asks.length + b.steps.length + b.terms.length + b.points.length + b.queue.length + (b.notes ?? []).length === 0
218  )
219}
220
221// The steps the model sent, with exactly one of them current while any is left to do
222function asSteps(value: unknown): Step[] | null {
223  if (!Array.isArray(value)) return null
224  const steps: Step[] = []
225  let hasCurrent = false
226  for (const item of value.slice(0, 40)) {
227    if (typeof item !== 'object' || item === null) continue
228    const { title, status } = item as { title?: unknown; status?: unknown }
229    if (typeof title !== 'string' || title.trim() === '') continue
230    if (status === 'current' && !hasCurrent) {
231      hasCurrent = true
232      steps.push({ title: oneLine(title, 160), status: 'current' })
233    } else {
234      steps.push({ title: oneLine(title, 160), status: status === 'done' ? 'done' : 'next' })
235    }
236  }
237  // A list in which no item is a valid step is a malformed call, not a request to empty the panel
238  if (value.length > 0 && steps.length === 0) return null
239  if (hasCurrent) return steps
240  const first = steps.findIndex(step => step.status === 'next')
241
242  return steps.map((step, i) => (i === first ? { ...step, status: 'current' as const } : step))
243}
244
245function asTerms(value: unknown): Term[] {
246  if (!Array.isArray(value)) return []
247  const terms: Term[] = []
248  for (const item of value) {
249    if (typeof item !== 'object' || item === null) continue
250    const { term, meaning } = item as { term?: unknown; meaning?: unknown }
251    if (typeof term !== 'string' || term.trim() === '') continue
252    terms.push({ term: oneLine(term, 40), meaning: typeof meaning === 'string' ? oneLine(meaning, 200) : '' })
253  }
254  return terms
255}
256
257function asStrings(value: unknown, max: number): string[] | null {
258  if (!Array.isArray(value)) return null
259  return value.filter((one): one is string => typeof one === 'string' && one.trim() !== '').map(one => oneLine(one, max))
260}
261
262// The current step becomes done and the first step still to do becomes current
263function advance(steps: Step[]): Step[] {
264  let isMoved = false
265  return steps
266    .map(step => (step.status === 'current' ? { ...step, status: 'done' as const } : step))
267    .map(step => {
268      if (isMoved || step.status !== 'next') return step
269      isMoved = true
270      return { ...step, status: 'current' as const }
271    })
272}
273
274// How many rows of a noted prompt the panel shows before "Ver tudo"
275const ROWS_SHOWN = 4
276
277// The start of a text that fits in `rows` rows of `rowChars` characters: whole lines while they fit, then the
278// line that does not, cut with an ellipsis. `hidden` counts the lines left out after it.
279function firstRows(
280  lines: string[],
281  rowChars: number,
282  rows: number,
283): { lines: string[]; hidden: number; isCut: boolean } {
284  const shown: string[] = []
285  let left = rows
286  for (const [i, line] of lines.entries()) {
287    const needs = Math.max(1, Math.ceil(line.length / rowChars))
288    if (needs <= left) {
289      shown.push(line)
290      left -= needs
291      continue
292    }
293    if (left === 0) return { lines: shown, hidden: lines.length - i, isCut: false }
294    shown.push(line.slice(0, left * rowChars - 1).trimEnd() + '…')
295
296    return { lines: shown, hidden: lines.length - i - 1, isCut: true }
297  }
298
299  return { lines: shown, hidden: 0, isCut: false }
300}
301
302// How many of the user's messages the panel keeps
303const ASKS_KEPT = 100
304// How many prompts the panel keeps noted, and how long one may be
305const QUEUE_LIMIT = 30
306const NOTE_LIMIT = 4000
307
308function withQueued(b: Board, text: string): Board {
309  const id = b.queue.reduce((max, item) => Math.max(max, item.id), 0) + 1
310  return { ...b, queue: [...b.queue, { id, text: text.trim().slice(0, NOTE_LIMIT) }] }
311}
312
313// How many lines the user's notes keep, and how long one line may be: 200 KB for a session at most, in a
314// store of 4 MiB that every session shares
315const NOTES_LIMIT = 200
316const NOTE_LINE_LIMIT = 1000
317
318// Adds a text to the user's notes, one entry for each of its lines. Returns false when the notes are full.
319async function withNote($: EngineInterface, text: string): Promise<boolean> {
320  const lines = text
321    .replace(/\r\n?/g, '\n')
322    .split('\n')
323    .map(line => line.trimEnd().slice(0, NOTE_LINE_LIMIT))
324  if (lines.every(line => line === '')) return true
325  // The limit is checked where the lines are added, so two writes at once cannot pass it together
326  let isAdded = false
327  await commit($, now => {
328    const notes = now.notes ?? []
329    isAdded = notes.length + lines.length <= NOTES_LIMIT
330
331    return isAdded ? { ...now, notes: [...notes, ...lines] } : now
332  })
333  if (!isAdded) {
334    $.ui.toast(t('Anotações já tem {n} linhas, que é o limite. Exclua alguma antes de escrever outra.', { n: NOTES_LIMIT }))
335  }
336
337  return isAdded
338}
339
340// Notes a prompt, unless the panel already keeps as many as it may: the oldest is never dropped in silence
341async function noted($: EngineInterface, text: string): Promise<boolean> {
342  if (text.trim() === '') return false
343  // A prompt longer than the panel keeps is refused, never cut: what is queued is what is sent as the user
344  if (text.trim().length > NOTE_LIMIT) {
345    $.ui.toast(
346      t('O prompt tem {n} caracteres e o limite é {max}. Ele não foi anotado.', { n: text.trim().length, max: NOTE_LIMIT }),
347    )
348
349    return false
350  }
351  if ((await read($, board)).queue.length >= QUEUE_LIMIT) {
352    $.ui.toast(t('Próximos prompts já tem {n} prompts anotados, que é o limite. Envie ou exclua um.', { n: QUEUE_LIMIT }))
353
354    return false
355  }
356  await commit($, now => (now.queue.length >= QUEUE_LIMIT ? now : withQueued(now, text)))
357
358  return true
359}
360
361// What the model reads beside a prompt: what the panel shows now. How to update it is in the tool's description.
362function contextFor(b: Board): string {
363  const steps =
364    b.steps.length === 0
365      ? t('nenhum passo registrado')
366      : b.steps.map((step, i) => i + 1 + '. [' + step.status + '] ' + step.title).join('; ')
367  const terms = b.terms.length === 0 ? t('nenhum') : b.terms.map(one => one.term).join(', ')
368  const points = b.points.length === 0 ? t('nenhum') : b.points.join('; ')
369  const decisions = (b.decisions ?? []).length === 0 ? t('nenhuma') : (b.decisions ?? []).join('; ')
370
371  return [
372    t('Painel da sessão (mod painel-sessao), que o usuário acompanha. Atualize com a ferramenta mcp__painel-sessao__atualizar quando passos, decisões, termos ou pontos mudarem.'),
373    t('Passos no painel agora: ') + steps + '.',
374    t('Decisões que o usuário precisa tomar, no painel agora: ') + decisions + '.',
375    t('Termos no painel agora: ') + terms + '.',
376    t('Pontos no painel agora: ') + points + '.',
377  ].join('\n')
378}
379
380// The sections that are open when the user has not opened or closed any: the two that ask something of the
381// user. The others show their summary in one line.
382const OPEN_AT_FIRST = ['decisoes', 'prompts']
383
384// How long ago, as the panel says it: "agora", "há 12 min", "há 2 h 05 min", "há 3 dias"
385function ago(ms: number): string {
386  const minutes = Math.floor(ms / 60000)
387  if (minutes < 1) return t('agora')
388  if (minutes < 60) return t('há {n} min', { n: minutes })
389  const hours = Math.floor(minutes / 60)
390  if (hours < 24) return t('há {h} h {m} min', { h: hours, m: two(minutes % 60) })
391  const days = Math.floor(hours / 24)
392
393  return t(days === 1 ? 'há {n} dia' : t('há {n} dias'), { n: days })
394}
395
396// A session that saved nothing for this long is not answering nor waiting: it is stopped
397const STOPPED_AFTER_MS = 12 * 60 * 60 * 1000
398
399// One line of the list of sessions, from what a session's panel saved
400function rowOf(id: string, b: Board, now: number, isThis: boolean): SessionRow {
401  const at = b.steps.findIndex(step => step.status === 'current')
402  const isStale = now - (b.savedAt ?? 0) > STOPPED_AFTER_MS
403  const state: SessionRow['state'] =
404    b.ended === true
405      ? 'encerrada'
406      : isStale
407        ? 'parada'
408        : b.working === true
409          ? 'respondendo'
410          : b.turnEndedAt !== undefined
411            ? 'aguardando'
412            : 'nova'
413
414  return {
415    id,
416    isThis,
417    name: b.name ?? t('Sessão ') + id.slice(0, 8),
418    state,
419    at:
420      state === 'respondendo'
421        ? (b.turnStartedAt ?? b.savedAt)
422        : state === 'aguardando'
423          ? (b.turnEndedAt ?? b.savedAt)
424          : b.savedAt,
425    stepsDone: b.steps.filter(step => step.status === 'done').length,
426    stepsTotal: b.steps.length,
427    step: at < 0 ? '' : (b.steps[at]?.title ?? ''),
428    decisions: (b.decisions ?? []).length,
429    queued: b.queue.length,
430  }
431}
432
433// The sessions of this machine that run the mod, from the store they all write to: this one first, then
434// the ones that wait for the user, then the rest, the most recent first
435async function sessionRows($: EngineInterface): Promise<SessionRow[]> {
436  const mine = await $.session.id()
437  const now = await $.clock.now()
438  const rows: SessionRow[] = []
439  for (const key of await $.store.keys()) {
440    if (!key.startsWith('board:')) continue
441    const id = key.slice('board:'.length)
442    const saved = id === mine ? { ...(await read($, board)), savedAt: now } : await $.store.get(key)
443    if (isBoard(saved)) rows.push(rowOf(id, saved, now, id === mine))
444  }
445  // A session that has not saved its board yet is still listed
446  if (!rows.some(row => row.isThis)) rows.push(rowOf(mine, { ...(await read($, board)), savedAt: now }, now, true))
447  const rank = (row: SessionRow): number => (row.isThis ? 0 : row.state === 'aguardando' ? 1 : 2)
448
449  return rows.sort((a, b) => rank(a) - rank(b) || b.at - a.at).slice(0, 20)
450}
451
452// What the model last read about the panel: the same state is not sent again with every prompt. Emptied
453// when the conversation is cleared, resumed or compacted, so the state goes again.
454let lastContext = ''
455
456// The writes of the board to the store, one after the other: each reads the state when its turn comes,
457// so the last write always holds the latest board, whatever order the changes finished in
458let saving: Promise<void> = Promise.resolve()
459// True after the user was told that the store refused a write, so the notice is given once
460let hasToldUnsaved = false
461
462// Writes the board to the session's state, which redraws the panel, and to the store, which survives a restart
463async function commit($: EngineInterface, change: (b: Board) => Board): Promise<void> {
464  await update($, board, change)
465  saving = saving.then(async () => {
466    try {
467      const value = await read($, board)
468      await $.store.set('board:' + (await $.session.id()), { ...value, savedAt: await $.clock.now() })
469      hasToldUnsaved = false
470    } catch {
471      // A full store keeps the board in the session's state only: the panel goes on working until a restart
472      if (hasToldUnsaved) return
473      hasToldUnsaved = true
474      $.ui.toast(
475        t('O painel não foi gravado: o arquivo do mod está cheio. O que aparece continua na tela até o aplicativo reiniciar.'),
476        { timeoutMs: 10000 },
477      )
478    }
479  })
480  await saving
481}
482
483// Loads the board this session saved before a restart or a resume
484async function restore($: EngineInterface): Promise<void> {
485  const current = await read($, board)
486  if (!isEmpty(current)) return
487  const id = await $.session.id()
488  const saved = await $.store.get('board:' + id)
489  if (!isBoard(saved)) return
490  // A prompt that was waiting to enter when the app closed never entered: it can be sent again. The automatic
491  // sending starts off, and no sending is waiting.
492  const queue = saved.queue.map(one => ({ id: one.id, text: one.text }))
493  // Nor does a message that was waiting for its row get one after a restart
494  const asks = saved.asks.map(ask => (ask.row === undefined ? { at: ask.at, text: ask.text } : ask))
495  // No turn is under way in a session that has just started
496  await update($, board, () => ({
497    ...saved,
498    asks,
499    queue,
500    auto: false,
501    pending: undefined,
502    hold: undefined,
503    stopped: undefined,
504    working: false,
505    ended: false,
506  }))
507}
508
509// How many boards of other sessions the store keeps: the store is one file of 4 MiB for every session of the
510// machine, and each /clear and each resumed session writes a board of its own
511const BOARDS_KEPT = 40
512
513// Deletes from the store, which every session shares: the boards of sessions that saved nothing for 30 days,
514// the boards beyond the 40 that saved last, the rows kept for a session that has no board, and any key this
515// version of the mod does not write
516async function prune($: EngineInterface): Promise<void> {
517  const id = await $.session.id()
518  const now = await $.clock.now()
519  const keys = await $.store.keys()
520  // The boards of the other sessions, the one that saved last first: the ones after the 40th are deleted
521  const others: { key: string; savedAt: number }[] = []
522  for (const key of keys) {
523    if (!key.startsWith('board:') || key === 'board:' + id) continue
524    const saved = await $.store.get(key)
525    if (isBoard(saved)) others.push({ key, savedAt: saved.savedAt ?? 0 })
526  }
527  const beyond = new Set(
528    others
529      .sort((a, b) => b.savedAt - a.savedAt)
530      .slice(BOARDS_KEPT)
531      .map(one => one.key),
532  )
533  for (const key of keys) {
534    if (key === 'board:' + id || key === 'rows:' + id || key === 'trace:' + id) continue
535    if (key.startsWith('rows:') || key.startsWith('trace:')) {
536      // The rows of a session with no board are those of a session that is open and has not written one
537      // yet, or of one that never will: only their own age tells, so they go after 30 days without a write.
538      // The record of clicks of a session follows the same rule.
539      if (keys.includes('board:' + key.slice(key.indexOf(':') + 1))) continue
540      const kept = (await $.store.get(key)) as { savedAt?: unknown } | null
541      const savedAt = typeof kept?.savedAt === 'number' ? kept.savedAt : 0
542      if (now - savedAt > 30 * DAY_MS) await $.store.delete(key)
543      continue
544    }
545    const saved = key.startsWith('board:') ? await $.store.get(key) : null
546    if (!isBoard(saved) || now - (saved.savedAt ?? 0) > 30 * DAY_MS || beyond.has(key)) {
547      await $.store.delete(key)
548      if (key.startsWith('board:')) {
549        await $.store.delete('rows:' + key.slice('board:'.length))
550        await $.store.delete('trace:' + key.slice('board:'.length))
551      }
552    }
553  }
554}
555
556// How long the automatic sending waits after an answer, so the user can cancel it
557const AUTO_WAIT_MS = 5000
558// How often the automatic sending looks again at a session that is not answering: a sending that failed, or
559// one that waited for something, is tried again without a new answer
560const AUTO_LOOK_MS = 15000
561// How long the app's survey about the session holds the automatic sending. After that the prompt goes anyway:
562// the band may not be drawn again to say the survey is gone.
563const SURVEY_HOLD_MS = 3 * 60 * 1000
564// A prompt sent that started no turn, with no answer running for this long, did not enter: it can go again
565const STUCK_AFTER_MS = 2 * 60 * 1000
566// How many automatic sendings in a row may fail before the automatic sending turns itself off
567const AUTO_TRIES = 3
568
569// The noted prompts being sent right now, so a second click on "Enviar" does not send one twice
570const inFlight = new Set<number>()
571// When each noted prompt was sent, by id
572const sentAt = new Map<number, number>()
573// When the app's survey about the session was first drawn; 0 while there is none
574let surveyAt = 0
575// How many automatic sendings in a row did not enter the conversation
576let autoFailures = 0
577
578// The board with one more line of what the automatic sending did: the panel shows the last one
579function logged(b: Board, at: number, what: string): Board {
580  return { ...b, autoLog: [...(b.autoLog ?? []), { at, what }].slice(-12) }
581}
582
583// What the automatic sending waits for now, if anything; written to the board so the panel says it
584async function held($: EngineInterface): Promise<boolean> {
585  const at = await $.clock.now()
586  const hold =
587    surveyAt !== 0 && at - surveyAt < SURVEY_HOLD_MS
588      ? ('pesquisa' as const)
589      : (await $.agent.list()).some(agent => agent.status === 'running')
590        ? ('agente' as const)
591        : undefined
592  if ((await read($, board)).hold !== hold) {
593    await commit($, now =>
594      hold === undefined
595        ? { ...now, hold }
596        : logged(
597            { ...now, hold },
598            at,
599            hold === 'agente' ? t('aguardando um agente terminar') : t('aguardando a pesquisa do aplicativo sair da tela'),
600          ),
601    )
602  }
603
604  return hold !== undefined
605}
606
607// Starts the 5 seconds of the automatic sending when it is on, a prompt is noted and no answer runs. Called
608// at the end of each answer, when the user turns it on, and every 15 seconds.
609async function arm($: EngineInterface): Promise<void> {
610  const b = await read($, board)
611  if (b.auto !== true || b.working === true || b.pending !== undefined) return
612  // A prompt already sent has to enter the conversation first
613  const item = b.queue.some(one => one.sending === true) ? undefined : b.queue[0]
614  if (item === undefined || (await held($))) return
615  const at = (await $.clock.now()) + AUTO_WAIT_MS
616  await commit($, now => ({ ...now, pending: { id: item.id, at } }))
617  $.ui.toast(t('Envio automático: o próximo prompt entra em 5 segundos. Para parar, clique em Cancelar.'))
618  $.clock.after(AUTO_WAIT_MS, () => void fire($, item.id, at))
619}
620
621// The end of the 5 seconds: sends the prompt, unless the wait was called off or something came up to wait for
622async function fire($: EngineInterface, id: number, at: number): Promise<void> {
623  const b = await read($, board)
624  // Called off, or replaced by a later wait: this timer does nothing
625  if (b.auto !== true || b.pending?.id !== id || b.pending.at !== at) return
626  const item = b.queue.find(one => one.id === id && one.sending !== true)
627  await commit($, now => ({ ...now, pending: undefined }))
628  if (item === undefined || (await held($))) return
629  const isSent = await dispatch($, item)
630  const now = await $.clock.now()
631  if (isSent) {
632    autoFailures = 0
633    await commit($, state => logged(state, now, t('prompt enviado: ') + oneLine(item.text, 60)))
634
635    return
636  }
637  autoFailures += 1
638  if (autoFailures < AUTO_TRIES) {
639    await commit($, state => logged(state, now, t('o prompt não entrou na conversa; nova tentativa em seguida')))
640
641    return
642  }
643  // The same prompt failed every time: the user is told why nothing else is sent, in the panel and in a notice
644  autoFailures = 0
645  const why = t('o prompt não entrou na conversa em {n} tentativas', { n: AUTO_TRIES })
646  await commit($, state => logged({ ...state, auto: false, hold: undefined, stopped: { at: now, why } }, now, t('desligado: ') + why))
647  $.ui.toast(t('Envio automático desligado: {why}.', { why }), { timeoutMs: 10000 })
648}
649
650// A prompt sent that started no turn, with no answer running for two minutes, did not enter the conversation:
651// it is noted again, so "Enviar" and the automatic sending can send it
652async function unstick($: EngineInterface): Promise<void> {
653  const b = await read($, board)
654  if (b.working === true || surveyAt !== 0) return
655  const now = await $.clock.now()
656  const stuck = b.queue.filter(
657    one => one.sending === true && now - Math.max(sentAt.get(one.id) ?? 0, b.turnEndedAt ?? 0) > STUCK_AFTER_MS,
658  )
659  if (stuck.length === 0) return
660  const ids = new Set(stuck.map(one => one.id))
661  const texts = new Set(stuck.map(one => oneLine(one.text, 300)))
662  for (const id of ids) {
663    inFlight.delete(id)
664    sentAt.delete(id)
665  }
666  await commit($, state =>
667    logged(
668      {
669        ...state,
670        queue: state.queue.map(one => (ids.has(one.id) ? { id: one.id, text: one.text } : one)),
671        asks: state.asks.filter(ask => !(ask.isEntering === true && texts.has(ask.text))),
672      },
673      now,
674      t('um prompt enviado não entrou na conversa e voltou a ficar anotado'),
675    ),
676  )
677}
678
679// Sends a noted prompt as the user's own words, and tells whether it entered. A prompt sent while Claude
680// answers waits for that answer to end: the item says so until its turn starts.
681async function dispatch($: EngineInterface, item: Queued): Promise<boolean> {
682  if (inFlight.has(item.id)) return false
683  inFlight.add(item.id)
684  const marked = (isSending: boolean) => (now: Board): Board => ({
685    ...now,
686    queue: now.queue.map(one => (one.id === item.id ? { ...one, sending: isSending } : one)),
687  })
688  const at = await $.clock.now()
689  sentAt.set(item.id, at)
690  try {
691    // The message is listed here: a mod's prompt.submit hook does not run for the mod's own prompt
692    await commit($, now => ({
693      ...marked(true)(now),
694      asks: [...now.asks, { at, text: oneLine(item.text, 300), isEntering: true }].slice(-ASKS_KEPT),
695    }))
696    // For the same reason the panel's state, when it changed, goes as a row only the model reads
697    const context = contextFor(await read($, board))
698    if (context !== lastContext) {
699      try {
700        await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: context }] } })
701        lastContext = context
702      } catch {
703        // The session takes no row from a mod: the prompt goes without the panel's state
704      }
705    }
706    const entered = await $.prompt.submit({ text: item.text, asUser: true })
707    if (entered.drop !== undefined) throw new Error(entered.drop)
708    await commit($, now => ({ ...now, queue: now.queue.filter(one => one.id !== item.id) }))
709
710    return true
711  } catch (error) {
712    // The prompt did not enter: it stays noted, and it leaves the list of messages sent
713    await commit($, now => ({
714      ...marked(false)(now),
715      asks: now.asks.filter(ask => !(ask.at === at && ask.isEntering === true)),
716    }))
717    $.ui.toast(
718      t('O prompt não foi enviado e continua em Próximos prompts') +
719        (error instanceof Error && error.message !== '' ? ': ' + error.message : '.'),
720    )
721
722    return false
723  } finally {
724    inFlight.delete(item.id)
725    sentAt.delete(item.id)
726  }
727}
728
729// Rows the conversation files under the user's role that the user did not type: commands, notices, hook output
730const NOT_TYPED = /^(<|\[SYSTEM NOTIFICATION|Stop hook feedback|Tool loaded\.|Another Claude session)/
731
732// What the model is asked, over the whole conversation, to fill the panel of a session that had no panel
733const reloadPrompt = (): string => [
734  t('Resuma esta sessão para o painel que o usuário acompanha.'),
735  t('Responda só com um objeto JSON, sem texto antes ou depois e sem bloco de código, com três chaves:'),
736  t('"steps": a lista ordenada dos passos da sessão, cada um {"title": frase curta, "status": "done" | "current" | "next"}, com um único "current", até 20;'),
737  t('"terms": as siglas, os termos e os conceitos que o usuário precisa lembrar, cada um {"term": texto, "meaning": uma frase}, até 10;'),
738  t('"points": as decisões e os fatos importantes da sessão, em frases curtas, até 8.'),
739  t('Escreva em português do Brasil.'),
740].join('\n')
741
742// The object in a reply that may carry text around it
743function objectIn(text: string): Record<string, unknown> | null {
744  const start = text.indexOf('{')
745  const end = text.lastIndexOf('}')
746  if (start < 0 || end <= start) return null
747  try {
748    const value: unknown = JSON.parse(text.slice(start, end + 1))
749
750    return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
751  } catch {
752    return null
753  }
754}
755
756// Fills the panel from the conversation, for a session that ran without the panel: the messages the user
757// sent, with no time, because the conversation as the mod reads it carries none; the context and the limits;
758// and the steps, terms and points, which the model writes from the whole conversation. Returns what it did.
759async function reload($: EngineInterface): Promise<string> {
760  if ((await read($, view)).isReloading === true) return t('O painel já está sendo recarregado.')
761  await update($, view, now => ({ ...now, isReloading: true }))
762  try {
763    const rows = (await $.session.messages()) as readonly { role: string; text: string }[]
764    const past = rows
765      .filter(row => row.role === 'user')
766      .map(row => oneLine(row.text, 300))
767      .filter(text => text !== '' && !NOT_TYPED.test(text))
768    let added = 0
769    await commit($, now => {
770      const known = new Set(now.asks.map(ask => rowKey(ask.text)))
771      const older: Ask[] = []
772      for (const text of past) {
773        if (known.has(rowKey(text))) continue
774        known.add(rowKey(text))
775        older.push({ at: 0, text })
776      }
777      added = older.length
778
779      return { ...now, asks: [...older, ...now.asks].slice(-ASKS_KEPT) }
780    })
781    const figures = await $.session.usage()
782    await update($, usage, now => measured(now, figures))
783
784    const head = t('Mensagens anteriores recarregadas: {n}. ', { n: added })
785    const answer = await $.model.fork({ prompt: reloadPrompt() })
786    if (!answer.isAnswered) {
787      const why =
788        answer.reason === 'nothing-to-fork'
789          ? t('a conversa ainda não tem nenhuma resposta')
790          : answer.reason === 'api-error'
791            ? t('a API retornou erro') + (answer.status === null ? '' : ' ' + answer.status)
792            : answer.reason === 'empty-reply'
793              ? t('o modelo respondeu sem texto')
794              : t('a leitura foi interrompida')
795
796      return head + t('Passos, termos e pontos não foram recarregados: {why}.', { why })
797    }
798    const data = objectIn(answer.text)
799    const steps = data === null ? null : asSteps(data.steps)
800    if (data === null || steps === null) {
801      return (
802        head +
803        t('Passos, termos e pontos não foram recarregados: {why}.', { why: t('a resposta do modelo veio fora do formato') })
804      )
805    }
806    const terms = asTerms(data.terms)
807    const points = asStrings(data.points, 200) ?? []
808    await commit($, now => {
809      const fresh = new Set(terms.map(one => one.term.toLowerCase()))
810
811      return {
812        ...now,
813        steps,
814        terms: [...now.terms.filter(one => !fresh.has(one.term.toLowerCase())), ...terms].slice(-12),
815        points: points.slice(-8),
816      }
817    })
818
819    return (
820      head +
821      t('Passos: {steps}. Termos: {terms}. Pontos: {points}.', {
822        steps: steps.length,
823        terms: terms.length,
824        points: Math.min(8, points.length),
825      })
826    )
827  } catch (error) {
828    // Whoever asked, the button or the command, gets a text to show: a failure here is never silent
829    return t('O painel não foi recarregado: {why}.', {
830      why: error instanceof Error && error.message !== '' ? error.message : t('erro sem descrição'),
831    })
832  } finally {
833    await update($, view, now => ({ ...now, isReloading: false }))
834  }
835}
836
837// The rows saved for the session id in force, which changes on /clear and on a resume, and the key they
838// are saved under. A text sent more than once has one row for each time.
839async function savedRows($: EngineInterface): Promise<{ key: string; rows: Map<string, string[]> }> {
840  const key = 'rows:' + (await $.session.id())
841  const kept = (await $.store.get(key)) as { rows?: unknown } | null
842  const rows = new Map<string, string[]>()
843  if (typeof kept !== 'object' || kept === null) return { key, rows }
844  // Since 06/10/2026 the rows are saved under `rows`, beside the time of the write; before, they were the
845  // whole value
846  const saved = typeof kept.rows === 'object' && kept.rows !== null ? kept.rows : kept
847  for (const [text, value] of Object.entries(saved)) {
848    const ids = (Array.isArray(value) ? value : [value]).filter((one): one is string => typeof one === 'string')
849    if (ids.length > 0) rows.set(text, ids)
850  }
851
852  return { key, rows }
853}
854
855export const register: Register = (on, options) => {
856  // The plugin's options: the language of the panel, the time zone its times are shown in, whether the pane
857  // opens by itself when a session starts, and whether a line with the times goes under each answer
858  setLang(options.language)
859  const isZoneKnown = setZone(options.timeZone)
860  const opensOnStart = options.openOnStart !== false
861  const hasAnswerLine = options.answerLine !== false
862
863  // What the user has typed in the panel's field and not noted yet, kept so a redraw does not empty the field
864  let typing = ''
865  // The rows of the conversation that hold each message of the user, by the start of the message's text and
866  // in the order they entered. Saved to the store every 2 seconds, because a reload empties this memory.
867  let rowIds = new Map<string, string[]>()
868  let areRowsDirty = false
869  let rowsKey = ''
870  const sawRow = (text: string, row: string): void => {
871    const key = rowKey(text)
872    const ids = rowIds.get(key) ?? []
873    if (ids.includes(row)) return
874    rowIds.set(key, [...ids, row])
875    areRowsDirty = true
876  }
877  // The noted prompts of a conversation the user cleared, kept for the conversation that follows
878  let carried: Queued[] = []
879  let carriedNotes: string[] = []
880  // What the user has typed in the field of the notes and not written yet
881  let noteTyping = ''
882  // What a field of the panel must hold at its next drawings, by the field's key: set when the mod itself
883  // changes the field (emptied after Enter, filled with a line to change) and dropped when the user types in
884  // it. The desktop app draws the panel anew at every change and keeps what was typed only when the value
885  // drawn for the field is the one it drew before, or none (read in the app's own code on 07/10/2026): a
886  // value that followed the typing emptied the field or moved the cursor at a redraw.
887  const fieldSet = new Map<string, string>()
888  // The surface that last drew the pane: an event about the pane's focus does not say which one it came from
889  let paneSurface = ''
890  // The last presses and moves of focus in the pane, saved to the store so a click that did nothing can be
891  // looked into afterwards
892  let trace: { at: number; kind: string; element: string }[] = []
893  let isTraceDirty = false
894  const traced = (at: number, kind: string, element: string): void => {
895    trace = [...trace, { at, kind, element }].slice(-60)
896    isTraceDirty = true
897  }
898
899  on('session.start', async ($, e, next) => {
900    if (!isZoneKnown) {
901      $.ui.toast('painel-sessao: timeZone "' + String(options.timeZone) + '" ' + t('não é um fuso horário conhecido; o painel usa o do sistema.'), {
902        timeoutMs: 10000,
903      })
904    }
905    await $.command.register({ name: commandName('panel'), description: t('Abre o painel da sessão') })
906    await $.command.register({
907      name: commandName('queue'),
908      description: t('Anota um prompt em Próximos prompts, no painel da sessão, para enviar depois'),
909      argumentHint: t('[texto do prompt]'),
910      immediate: true,
911    })
912    await $.tool.register({
913      name: 'atualizar',
914      description:
915        t('Atualiza o painel da sessão que o usuário acompanha. Chame sempre que apresentar próximos passos, alterar o plano, concluir um passo, ou quando surgir sigla, termo, conceito ou decisão que o usuário precisa lembrar. Cada campo é opcional; envie só o que mudou.'),
916      inputSchema: {
917        type: 'object',
918        properties: {
919          steps: {
920            type: 'array',
921            description:
922              t('Lista completa e ordenada dos passos da sessão. Substitui a lista do painel. Um único passo com status "current".'),
923            items: {
924              type: 'object',
925              properties: {
926                title: { type: 'string', description: t('O passo em uma frase curta') },
927                status: { type: 'string', enum: ['done', 'current', 'next'] },
928              },
929              required: ['title', 'status'],
930            },
931          },
932          terms: {
933            type: 'array',
934            description: t('Siglas, termos e conceitos a adicionar. Um `term` que já existe tem o significado substituído.'),
935            items: {
936              type: 'object',
937              properties: {
938                term: { type: 'string' },
939                meaning: { type: 'string', description: t('O significado em uma frase') },
940              },
941              required: ['term', 'meaning'],
942            },
943          },
944          remove_terms: { type: 'array', items: { type: 'string' }, description: t('Termos a remover do painel') },
945          points: {
946            type: 'array',
947            items: { type: 'string' },
948            description: t('Lista completa de decisões e fatos importantes da sessão. Substitui a lista do painel. Até 8.'),
949          },
950          decisions: {
951            type: 'array',
952            items: { type: 'string' },
953            description:
954              t('Lista completa das decisões que você deixou para o usuário tomar e que ele ainda não respondeu, cada uma como uma pergunta curta. Substitui a lista do painel. Envie a lista sem a decisão quando o usuário a responder, e lista vazia quando não restar nenhuma. Até 6.'),
955          },
956          envio_automatico: {
957            type: 'boolean',
958            description:
959              t('Liga (true) ou desliga (false) o envio automático dos prompts que o usuário anotou. Envie só quando o usuário pedir para ligar ou desligar.'),
960          },
961        },
962      },
963    })
964    await $.command.register({
965      name: commandName('auto'),
966      description: t('Liga ou desliga o envio automático dos prompts anotados no painel da sessão'),
967      argumentHint: t('[ligar | desligar]'),
968      immediate: true,
969    })
970    await $.command.register({
971      name: commandName('note'),
972      description: t('Escreve nas Anotações do painel da sessão; aceita várias linhas e texto colado'),
973      argumentHint: t('[texto]'),
974      immediate: true,
975    })
976    await $.command.register({
977      name: commandName('reload'),
978      description:
979        t('Preenche o painel da sessão a partir da conversa: mensagens enviadas, contexto, passos, termos e pontos'),
980    })
981    await restore($)
982    // The list of sessions names this one by the folder it runs in, until the app gives its title
983    const folder = (await $.session.cwd()).replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
984    await commit($, now => ({ ...now, name: now.name ?? (folder === '' ? undefined : folder), ended: false }))
985    // A reload drops the timer of a sending that was waiting: the wait is called off, the choice stays
986    if ((await read($, board)).pending !== undefined) await commit($, now => ({ ...now, pending: undefined }))
987    // And it drops a filling of the panel that was under way
988    if ((await read($, view)).isReloading === true) await update($, view, now => ({ ...now, isReloading: false }))
989    await prune($)
990    const bound = await savedRows($)
991    rowsKey = bound.key
992    rowIds = bound.rows
993    const figures = await $.session.usage()
994    await update($, usage, now => measured(now, figures))
995    // The pane opens by itself once in a session with a person at it: a reload does not open again a pane
996    // the user closed
997    if (opensOnStart && e.isInteractive && !(await read($, view)).hasOpened) {
998      await update($, view, now => ({ ...now, hasOpened: true }))
999      void $.ui.open({ id: PANE, title: title() })
1000    }
1001    $.clock.every(2000, () => {
1002      if (isTraceDirty) {
1003        isTraceDirty = false
1004        void $.store.set(rowsKey.replace('rows:', 'trace:'), { savedAt: Date.now(), events: trace }).catch(() => {})
1005      }
1006      if (!areRowsDirty) return
1007      areRowsDirty = false
1008      void $.store
1009        .set(rowsKey, { savedAt: Date.now(), rows: Object.fromEntries([...rowIds].slice(-300)) })
1010        .catch(() => {})
1011    })
1012    // The automatic sending looks at the session again: a sending that failed or waited is tried without a
1013    // new answer, and a prompt sent that never entered is noted again
1014    $.clock.every(AUTO_LOOK_MS, () => {
1015      void unstick($)
1016        .then(() => arm($))
1017        .catch(() => {})
1018    })
1019    // Once a minute: the list of sessions is read again, and the write redraws the panel, so "há 12 min"
1020    // and the count of sessions waiting stay current
1021    const refresh = async (): Promise<void> => {
1022      try {
1023        const sessions = await sessionRows($)
1024        await update($, view, now => ({ ...now, sessions, minuteTick: (now.minuteTick ?? 0) + 1 }))
1025      } catch {
1026        // The store could not be read: the list keeps what it had
1027      }
1028    }
1029    void refresh()
1030    $.clock.every(60000, () => void refresh())
1031    // After a reload the surface still shows the drawing of the module that was replaced, whose buttons
1032    // no longer answer: drawing again gives every button a handler of this module
1033    $.ui.invalidate('ui.render')
1034
1035    return next(e)
1036  })
1037
1038  // /clear ends the conversation and no session.start follows: the noted prompts and the user's notes are
1039  // kept for the next one
1040  on('session.end', async ($, e, next) => {
1041    if (e.reason === 'clear') {
1042      const ending = await read($, board)
1043      carried = ending.queue.map(one => ({ id: one.id, text: one.text }))
1044      carriedNotes = ending.notes ?? []
1045    }
1046    // The list of sessions of the other sessions shows this one as ended
1047    await commit($, now => ({ ...now, ended: true, working: false }))
1048
1049    return next(e)
1050  })
1051
1052  // The title the app gives the session is the name the user knows it by
1053  on('classic.UserPromptSubmit', async ($, e, next) => {
1054    const title = typeof e.session_title === 'string' ? oneLine(e.session_title, 60) : ''
1055    if (title !== '' && (await read($, board)).name !== title) await commit($, now => ({ ...now, name: title }))
1056
1057    return next(e)
1058  })
1059
1060  // After /clear, a resume or a branch the session id is another and the state is empty: the rows and the
1061  // board are those of the new id, and after a /clear the noted prompts come along
1062  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
1063    const bound = await savedRows($)
1064    rowsKey = bound.key
1065    rowIds = bound.rows
1066    areRowsDirty = false
1067    lastContext = ''
1068    await restore($)
1069    if (e.source === 'clear' && carried.length + carriedNotes.length > 0) {
1070      const queue = carried
1071      const notes = carriedNotes
1072      carried = []
1073      carriedNotes = []
1074      await commit($, now => ({ ...now, queue, notes }))
1075    }
1076    // The conversation that ended was marked as ended; the one that starts is not
1077    await commit($, now => ({ ...now, ended: false, working: false }))
1078
1079    return next(e)
1080  })
1081
1082  // Each command has a Portuguese name and an English one, written out here, where `claude plugin validate`
1083  // reads them. The mod registers and answers only the name of the language in force: the other one may be
1084  // a command of the user's or of another plugin, and goes on to it.
1085  on('command.run', { command: ['painel', 'panel'] }, async ($, e, next) => {
1086    if (e.command !== commandName('panel')) return next(e)
1087    await $.ui.open({ id: PANE, title: title() })
1088
1089    return {}
1090  })
1091
1092  on('command.run', { command: ['anotar', 'queue'] }, async ($, e, next) => {
1093    if (e.command !== commandName('queue')) return next(e)
1094    const text = e.args.trim()
1095    if (text === '') {
1096      await $.ui.open({ id: PANE, title: title(), focus: true })
1097
1098      return {}
1099    }
1100    if (await noted($, text)) $.ui.toast(t('Prompt anotado: ') + oneLine(text, 60))
1101
1102    return {}
1103  })
1104
1105  on('command.run', { command: ['painel-recarregar', 'panel-reload'] }, async ($, e, next) =>
1106    e.command === commandName('reload') ? { text: await reload($) } : next(e),
1107  )
1108
1109  // The notes are the user's own: the model does not read them, and nothing here starts a turn
1110  on('command.run', { command: ['nota', 'note'] }, async ($, e, next) => {
1111    if (e.command !== commandName('note')) return next(e)
1112    if (e.args.trim() === '') {
1113      await $.ui.open({ id: PANE, title: title(), focus: true })
1114
1115      return {}
1116    }
1117    if (await withNote($, e.args)) $.ui.toast(t('Escrito em Anotações: ') + oneLine(e.args, 60))
1118
1119    return {}
1120  })
1121
1122  // For a surface that draws no panel, as the phone: the same switch the panel's button is
1123  on('command.run', { command: ['envio-automatico', 'auto-send'] }, async ($, e, next) => {
1124    if (e.command !== commandName('auto')) return next(e)
1125    const word = e.args.trim().toLowerCase()
1126    const isOn = word === '' ? (await read($, board)).auto !== true : word === 'ligar' || word === 'on'
1127    if (word !== '' && !isOn && word !== 'desligar' && word !== 'off') {
1128      return { text: t('Use /envio-automatico ligar ou /envio-automatico desligar.') }
1129    }
1130    autoFailures = 0
1131    await commit($, now => ({ ...now, auto: isOn, pending: undefined, hold: undefined, stopped: undefined }))
1132    // With no answer running, the first prompt does not wait for one: its 5 seconds start now
1133    if (isOn) await arm($)
1134    const count = (await read($, board)).queue.length
1135
1136    return {
1137      text: isOn
1138        ? t(
1139            'Envio automático ligado. Prompts anotados: {n}. O primeiro entra 5 segundos depois de cada resposta, ou agora, se nenhuma resposta está em curso.',
1140            { n: count },
1141          )
1142        : t('Envio automático desligado. Prompts anotados: {n}.', { n: count }),
1143    }
1144  })
1145
1146  // A prompt the user typed. A prompt sent from the panel does not pass here (a mod's hook does not run
1147  // for the mod's own call): the button "Enviar" lists it.
1148  on('prompt.submit', async ($, e, next) => {
1149    const origin = e.origin
1150    if (origin.kind !== 'composer' && origin.kind !== 'bridge' && origin.kind !== 'sdk') return next(e)
1151
1152    const at = await $.clock.now()
1153    await commit($, b => ({
1154      ...b,
1155      asks: [...b.asks, { at, text: oneLine(e.text, 300), isEntering: true }].slice(-ASKS_KEPT),
1156    }))
1157    // The model reads the panel's state again only when it changed since the last prompt
1158    const context = contextFor(await read($, board))
1159    if (context === lastContext) return next(e)
1160    lastContext = context
1161
1162    return next({ ...e, context: [...(e.context ?? []), context] })
1163  })
1164
1165  // The row a prompt is stored in, as it enters the conversation: the panel scrolls to it later, whether or
1166  // not the surface ever drew it
1167  on('session.append', { door: 'prompt' }, async ($, e, next) => {
1168    if (e.agentId === undefined && e.message.isMeta !== true) {
1169      const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join(' ')
1170      const key = rowKey(text)
1171      // A message listed when it was sent waits for its row. One read back from the conversation, or listed
1172      // before rows were kept, does not: it never takes the row of a later message with the same text.
1173      const isWaiting = (ask: Ask): boolean => ask.isEntering === true && rowKey(ask.text) === key
1174      if (text.trim() !== '') sawRow(text, e.uuid)
1175      // The oldest message waiting with this text is the one that just entered
1176      if (text.trim() !== '' && (await read($, board)).asks.some(isWaiting)) {
1177        await commit($, now => {
1178          const at = now.asks.findIndex(isWaiting)
1179
1180          return {
1181            ...now,
1182            asks: now.asks.map((ask, i) => (i === at ? { at: ask.at, text: ask.text, row: e.uuid } : ask)),
1183          }
1184        })
1185      }
1186    }
1187
1188    return next(e)
1189  })
1190
1191  // Keeps the panel's tool in the prompt's own list, where the model reads its description
1192  on('tool.describe', { tool: 'mcp__painel-sessao__atualizar' }, async ($, e, next) => ({
1193    ...(await next(e)),
1194    isDeferred: false,
1195  }))
1196
1197  // A call that only writes to this panel, and would ask the user, runs without the prompt. A rule that
1198  // denies the tool stays in force. A call that turns the automatic sending on is not such a call: after
1199  // it the user's queued prompts are sent as the user, so the user is asked as for any other tool.
1200  on('tool.check', { tool: 'mcp__painel-sessao__atualizar' }, async ($, e, next) => {
hooks/texts.ts 353 lines
1// The texts of the panel in the two languages it is published in. The code is written with the Portuguese text,
2// which is also the key: `t('Nenhum prompt anotado.')` answers the same text in Portuguese and its English
3// version when the plugin's `language` option is `en`. A text with no English version is shown in Portuguese.
4
5export type Lang = 'pt-BR' | 'en'
6
7let lang: Lang = 'pt-BR'
8
9export function setLang(value: unknown): void {
10  lang = value === 'en' ? 'en' : 'pt-BR'
11}
12
13export function isEnglish(): boolean {
14  return lang === 'en'
15}
16
17// The text in the language in force, with each `{name}` replaced by the value of that name
18export function t(text: string, values?: Record<string, string | number>): string {
19  const base = lang === 'en' ? (EN[text] ?? text) : text
20  if (values === undefined) return base
21
22  return base.replace(/\{(\w+)\}/g, (whole, name: string) => (name in values ? String(values[name]) : whole))
23}
24
25// The slash commands of the mod, by what each one does: the name in Portuguese and the name in English. Both
26// names are answered whatever the language; only the ones of the language in force are listed to the user.
27export const COMMANDS = {
28  panel: ['painel', 'panel'],
29  queue: ['anotar', 'queue'],
30  note: ['nota', 'note'],
31  auto: ['envio-automatico', 'auto-send'],
32  reload: ['painel-recarregar', 'panel-reload'],
33} as const
34
35export function commandName(which: keyof typeof COMMANDS): string {
36  return COMMANDS[which][lang === 'en' ? 1 : 0]
37}
38
39const EN: Record<string, string> = {
40  // Names of the panel and of its sections
41  'Painel da sessão': 'Session panel',
42  Painel: 'Panel',
43  'Decisões suas': 'Your decisions',
44  Passos: 'Steps',
45  'Próximos prompts': 'Next prompts',
46  'Minhas últimas mensagens': 'My last messages',
47  Notas: 'Notes',
48  'Contexto e limites': 'Context and limits',
49  'Esta sessão': 'This session',
50  Sessões: 'Sessions',
51
52  // Time
53  agora: 'just now',
54  'há {n} min': '{n} min ago',
55  'há {h} h {m} min': '{h} h {m} min ago',
56  'há {n} dia': '{n} day ago',
57  'há {n} dias': '{n} days ago',
58  Hoje: 'Today',
59  'Ontem · {date}': 'Yesterday · {date}',
60  '{date} · há {n} dias': '{date} · {n} days ago',
61  'Antes de o painel ser ligado': 'Before the panel was turned on',
62  'reinicia {when}': 'resets {when}',
63  '5 horas': '5 hours',
64  Semana: 'Week',
65  'Limite de gasto': 'Spend limit',
66  '⏱ {date} · pergunta {start} · {ended} {end} · {span} ({zone})':
67    '⏱ {date} · asked {start} · {ended} {end} · {span} ({zone})',
68  resposta: 'answered',
69  interrompida: 'interrupted',
70  'encerrada com erro': 'ended with an error',
71
72  'não é um fuso horário conhecido; o painel usa o do sistema.':
73    "is not a time zone the system knows; the panel uses the system's.",
74
75  // Limits of the lists
76  'Anotações já tem {n} linhas, que é o limite. Exclua alguma antes de escrever outra.':
77    'Your notes already have {n} lines, which is the limit. Delete one before writing another.',
78  'Próximos prompts já tem {n} prompts anotados, que é o limite. Envie ou exclua um.':
79    'Next prompts already has {n} queued prompts, which is the limit. Send or delete one.',
80
81  'O prompt tem {n} caracteres e o limite é {max}. Ele não foi anotado.':
82    'The prompt has {n} characters and the limit is {max}. It was not queued.',
83
84  // What the model reads about the panel
85  'nenhum passo registrado': 'no step recorded',
86  nenhum: 'none',
87  nenhuma: 'none',
88  'Painel da sessão (mod painel-sessao), que o usuário acompanha. Atualize com a ferramenta mcp__painel-sessao__atualizar quando passos, decisões, termos ou pontos mudarem.':
89    'Session panel (mod painel-sessao), which the user follows. Update it with the tool mcp__painel-sessao__atualizar when steps, decisions, terms or points change.',
90  'Passos no painel agora: ': 'Steps in the panel now: ',
91  'Decisões que o usuário precisa tomar, no painel agora: ': 'Decisions the user has to make, in the panel now: ',
92  'Termos no painel agora: ': 'Terms in the panel now: ',
93  'Pontos no painel agora: ': 'Points in the panel now: ',
94  'Sessão ': 'Session ',
95  'O painel não foi gravado: o arquivo do mod está cheio. O que aparece continua na tela até o aplicativo reiniciar.':
96    "The panel was not saved: the mod's file is full. What is shown stays on screen until the app restarts.",
97
98  // Automatic sending
99  'aguardando um agente terminar': 'waiting for an agent to finish',
100  'aguardando a pesquisa do aplicativo sair da tela': 'waiting for the app survey to leave the screen',
101  'Envio automático: o próximo prompt entra em 5 segundos. Para parar, clique em Cancelar.':
102    'Auto-send: the next prompt enters in 5 seconds. To stop it, click Cancel.',
103  'prompt enviado: ': 'prompt sent: ',
104  'o prompt não entrou na conversa; nova tentativa em seguida':
105    'the prompt did not enter the conversation; trying again shortly',
106  'o prompt não entrou na conversa em {n} tentativas': 'the prompt did not enter the conversation in {n} attempts',
107  'Envio automático desligado: {why}.': 'Auto-send turned off: {why}.',
108  'desligado: ': 'turned off: ',
109  'um prompt enviado não entrou na conversa e voltou a ficar anotado':
110    'a prompt that was sent did not enter the conversation and is queued again',
111  'O prompt não foi enviado e continua em Próximos prompts': 'The prompt was not sent and stays in Next prompts',
112  'a resposta foi interrompida': 'the answer was interrupted',
113  'a resposta terminou com erro': 'the answer ended with an error',
114  'Envio automático: ligado': 'Auto-send: on',
115  'Envio automático: desligado': 'Auto-send: off',
116  'Depois de cada resposta, o primeiro prompt entra em 5 segundos.':
117    'After each answer, the first prompt enters in 5 seconds.',
118  'Aguardando a pesquisa do aplicativo sair da tela para enviar.':
119    'Waiting for the app survey to leave the screen before sending.',
120  'Aguardando um agente em segundo plano terminar para enviar.':
121    'Waiting for a background agent to finish before sending.',
122  'O primeiro prompt entra às {time}.': 'The first prompt enters at {time}.',
123  'Desligado às {time}: {why}.': 'Turned off at {time}: {why}.',
124  'Último evento do envio automático: {time} · {what}': 'Last auto-send event: {time} · {what}',
125  'Envio automático às {time}': 'Auto-send at {time}',
126  'envio automático ligado': 'auto-send on',
127  'envio automático · ': 'auto-send · ',
128  '✕ Cancelar': '✕ Cancel',
129
130  // Filling the panel from the conversation
131  'Resuma esta sessão para o painel que o usuário acompanha.': 'Summarize this session for the panel the user follows.',
132  'Responda só com um objeto JSON, sem texto antes ou depois e sem bloco de código, com três chaves:':
133    'Answer with one JSON object only, with no text before or after it and no code block, with three keys:',
134  '"steps": a lista ordenada dos passos da sessão, cada um {"title": frase curta, "status": "done" | "current" | "next"}, com um único "current", até 20;':
135    '"steps": the ordered list of the steps of the session, each one {"title": short sentence, "status": "done" | "current" | "next"}, with a single "current", up to 20;',
136  '"terms": as siglas, os termos e os conceitos que o usuário precisa lembrar, cada um {"term": texto, "meaning": uma frase}, até 10;':
137    '"terms": the acronyms, terms and concepts the user needs to remember, each one {"term": text, "meaning": one sentence}, up to 10;',
138  '"points": as decisões e os fatos importantes da sessão, em frases curtas, até 8.':
139    '"points": the decisions and the important facts of the session, in short sentences, up to 8.',
140  'Escreva em português do Brasil.': 'Write in English.',
141  'O painel já está sendo recarregado.': 'The panel is already being reloaded.',
142  'Mensagens anteriores recarregadas: {n}. ': 'Earlier messages reloaded: {n}. ',
143  'a conversa ainda não tem nenhuma resposta': 'the conversation has no answer yet',
144  'a API retornou erro': 'the API returned an error',
145  'o modelo respondeu sem texto': 'the model answered with no text',
146  'a leitura foi interrompida': 'the reading was interrupted',
147  'Passos, termos e pontos não foram recarregados: {why}.': 'Steps, terms and points were not reloaded: {why}.',
148  'a resposta do modelo veio fora do formato': "the model's answer was not in the expected format",
149  'Passos: {steps}. Termos: {terms}. Pontos: {points}.': 'Steps: {steps}. Terms: {terms}. Points: {points}.',
150  'O painel não foi recarregado: {why}.': 'The panel was not reloaded: {why}.',
151  'erro sem descrição': 'error with no description',
152  'Lendo a conversa para preencher o painel. Leva alguns segundos.':
153    'Reading the conversation to fill the panel. It takes a few seconds.',
154  '↻ Preencher o painel a partir da conversa': '↻ Fill the panel from the conversation',
155
156  // Commands and the tool the model calls
157  'Abre o painel da sessão': 'Opens the session panel',
158  'Anota um prompt em Próximos prompts, no painel da sessão, para enviar depois':
159    'Queues a prompt in Next prompts, in the session panel, to send later',
160  '[texto do prompt]': '[text of the prompt]',
161  'Atualiza o painel da sessão que o usuário acompanha. Chame sempre que apresentar próximos passos, alterar o plano, concluir um passo, ou quando surgir sigla, termo, conceito ou decisão que o usuário precisa lembrar. Cada campo é opcional; envie só o que mudou.':
162    'Updates the session panel the user follows. Call it whenever you present next steps, change the plan, complete a step, or when an acronym, term, concept or decision comes up that the user needs to remember. Every field is optional; send only what changed.',
163  'Lista completa e ordenada dos passos da sessão. Substitui a lista do painel. Um único passo com status "current".':
164    'The complete, ordered list of the steps of the session. It replaces the list of the panel. A single step with status "current".',
165  'O passo em uma frase curta': 'The step in one short sentence',
166  'Siglas, termos e conceitos a adicionar. Um `term` que já existe tem o significado substituído.':
167    'Acronyms, terms and concepts to add. A `term` that already exists has its meaning replaced.',
168  'O significado em uma frase': 'The meaning in one sentence',
169  'Termos a remover do painel': 'Terms to remove from the panel',
170  'Lista completa de decisões e fatos importantes da sessão. Substitui a lista do painel. Até 8.':
171    'The complete list of decisions and important facts of the session. It replaces the list of the panel. Up to 8.',
172  'Lista completa das decisões que você deixou para o usuário tomar e que ele ainda não respondeu, cada uma como uma pergunta curta. Substitui a lista do painel. Envie a lista sem a decisão quando o usuário a responder, e lista vazia quando não restar nenhuma. Até 6.':
173    'The complete list of the decisions you left for the user to make and the user has not answered yet, each one as a short question. It replaces the list of the panel. Send the list without a decision once the user answers it, and an empty list when none is left. Up to 6.',
174  'Liga (true) ou desliga (false) o envio automático dos prompts que o usuário anotou. Envie só quando o usuário pedir para ligar ou desligar.':
175    'Turns auto-send of the prompts the user queued on (true) or off (false). Send it only when the user asks to turn it on or off.',
176  'Liga ou desliga o envio automático dos prompts anotados no painel da sessão':
177    'Turns auto-send of the queued prompts in the session panel on or off',
178  '[ligar | desligar]': '[on | off]',
179  'Escreve nas Anotações do painel da sessão; aceita várias linhas e texto colado':
180    'Writes to your notes in the session panel; takes several lines and pasted text',
181  '[texto]': '[text]',
182  'Preenche o painel da sessão a partir da conversa: mensagens enviadas, contexto, passos, termos e pontos':
183    'Fills the session panel from the conversation: messages sent, context, steps, terms and points',
184  'Prompt anotado: ': 'Prompt queued: ',
185  'Escrito em Anotações: ': 'Written to your notes: ',
186  'Use /envio-automatico ligar ou /envio-automatico desligar.': 'Use /auto-send on or /auto-send off.',
187  'Envio automático ligado. Prompts anotados: {n}. O primeiro entra 5 segundos depois de cada resposta, ou agora, se nenhuma resposta está em curso.':
188    'Auto-send is on. Queued prompts: {n}. The first one enters 5 seconds after each answer, or now, if no answer is under way.',
189  'Envio automático desligado. Prompts anotados: {n}.': 'Auto-send is off. Queued prompts: {n}.',
190  'painel-sessao: a ferramenta só grava no painel da sessão': 'painel-sessao: the tool only writes to the session panel',
191  'painel-sessao: só a conversa principal atualiza o painel da sessão.':
192    'painel-sessao: only the main conversation updates the session panel.',
193  'Painel atualizado: {steps} passos, {terms} termos, {points} pontos.':
194    'Panel updated: {steps} steps, {terms} terms, {points} points.',
195  ' Decisões do usuário: {n}.': ' Decisions for the user: {n}.',
196  ' Envio automático ligado. Prompts anotados: {n}.': ' Auto-send is on. Queued prompts: {n}.',
197  ' Envio automático desligado. Prompts anotados: {n}.': ' Auto-send is off. Queued prompts: {n}.',
198  'A caixa de várias linhas não funcionou neste aplicativo: {reason}. O painel voltou ao campo de uma linha.':
199    'The multi-line box did not work in this app: {reason}. The panel is back to the one-line field.',
200
201  // The band above the prompt and the top of the pane
202  '● Passo {n} de {m}': '● Step {n} of {m}',
203  'Passo {n} de {m}': 'Step {n} of {m}',
204  'prompt anotado': 'queued prompt',
205  'prompts anotados': 'queued prompts',
206  'Respondendo ': 'Answering · started ',
207  'Aguardando você ': 'Waiting for you · ',
208  'Nenhuma resposta nesta sessão desde que o painel foi ligado': 'No answer in this session since the panel was turned on',
209  'resposta {time}': 'answered {time}',
210  '{a} de {b} passos concluídos': '{a} of {b} steps done',
211
212  // The list of sessions
213  respondendo: 'answering',
214  'aguardando você': 'waiting for you',
215  encerrada: 'ended',
216  'sem atividade': 'no activity',
217  'sem resposta ainda': 'no answer yet',
218  'Nenhuma sessão com o painel foi encontrada nesta máquina.': 'No sessions with the panel were found on this machine.',
219  ' · esta sessão': ' · this session',
220  'sem passos': 'no steps',
221  'decisão sua': 'decision for you',
222  'decisões suas': 'decisions for you',
223  'Só aparecem as sessões desta máquina que têm o painel ligado. A lista é lida a cada minuto.':
224    'Only the sessions of this machine with the panel on are listed. The list is refreshed every minute.',
225
226  // Summaries beside the title of a closed section
227  'nenhuma pendente': 'none pending',
228  'nenhum registrado': 'none recorded',
229  '{a} de {b}': '{a} of {b}',
230  ' · a seguir: ': ' · next: ',
231  'nenhum anotado': 'none queued',
232  anotação: 'note',
233  anotações: 'notes',
234  ponto: 'point',
235  pontos: 'points',
236  termo: 'term',
237  termos: 'terms',
238  'sem medição ainda': 'not measured yet',
239  '{used}% · {tokens} de {window}': '{used}% · {tokens} of {window}',
240  ' · semana {p}%': ' · week {p}%',
241
242  // Context and limits
243  'janela de ': 'window of ',
244  'Sem medição ainda. Os números aparecem depois da próxima resposta.':
245    'Not measured yet. The figures appear after the next answer.',
246  '{p}% da janela de contexto em uso': '{p}% of the context window in use',
247  'Contexto livre': 'Free context',
248  Compactações: 'Compactions',
249  'última ': 'last ',
250  'Último turno: entrada {input} · saída {output} · cache lido {read} · cache gravado {written}':
251    'Last turn: input {input} · output {output} · cache read {read} · cache written {written}',
252
253  // Decisions and steps
254  'Nenhuma decisão pendente.': 'No pending decisions.',
255  '✓ Resolvida': '✓ Resolved',
256  '{a} de {b} concluídos': '{a} of {b} done',
257  'Nenhum passo registrado nesta sessão.': 'No steps recorded in this session.',
258  '⋮ {n} passo concluído antes': '⋮ {n} earlier step done',
259  '⋮ {n} passos concluídos antes': '⋮ {n} earlier steps done',
260  ' AGORA ': ' NOW ',
261  '⋮ mais {n} passo depois': '⋮ {n} more step',
262  '⋮ mais {n} passos depois': '⋮ {n} more steps',
263  'Concluir passo atual': 'Complete current step',
264
265  // Next prompts
266  '{n} anotado': '{n} queued',
267  '{n} anotados': '{n} queued',
268  'Clique aqui e escreva um prompt para enviar depois': 'Click here and write a prompt to send later',
269  'Enter anota · Ctrl+Shift+Enter quebra a linha': 'Enter queues · Ctrl+Shift+Enter breaks the line',
270  'Linhas já guardadas deste prompt': 'Lines of this prompt so far',
271  'Escreva um prompt para enviar depois e tecle Enter para anotar. Para várias linhas, use Nova linha.':
272    'Write a prompt to send later and press Enter to queue it. For several lines, use New line.',
273  'Escreva a próxima linha e tecle Enter para anotar, ou use Nova linha para continuar o prompt.':
274    'Write the next line and press Enter to queue it, or use New line to go on with the prompt.',
275  Anotar: 'Queue',
276  '↵ Nova linha': '↵ New line',
277  '✕ Limpar linhas': '✕ Clear lines',
278  'Voltar ao campo de uma linha': 'Back to the one-line field',
279  'Testar caixa larga de várias linhas': 'Try the wide multi-line box',
280  'Nenhum prompt anotado.': 'No queued prompts.',
281  '⋮ mais {n} linha': '⋮ {n} more line',
282  '⋮ mais {n} linhas': '⋮ {n} more lines',
283  'Enviado. Entra na conversa quando a resposta atual terminar.':
284    'Sent. It enters the conversation when the current answer ends.',
285  '➤ Enviar': '➤ Send',
286  '▴ Ver menos': '▴ Show less',
287  '▾ Ver tudo': '▾ Show all',
288  '↑ Subir': '↑ Move up',
289  '↓ Descer': '↓ Move down',
290  '✎ Pôr na caixa': '✎ Move to the prompt box',
291  '⧉ Copiar': '⧉ Copy',
292  '✕ Excluir': '✕ Delete',
293  'A caixa de prompt não aceitou o texto. O prompt continua em Próximos prompts.':
294    'The prompt box did not take the text. The prompt stays in Next prompts.',
295
296  // My last messages
297  'Nenhuma mensagem enviada desde que o painel foi ligado.': 'No messages sent since the panel was turned on.',
298  'antes ↗': 'earlier ↗',
299  '▾ Carregar mais {n}': '▾ Load {n} more',
300  '▴ Mostrar só 5': '▴ Show only 5',
301  'Não achei essa mensagem na conversa. Ela ainda não apareceu na tela com o painel ligado.':
302    'This message was not found in the conversation. It has not been shown on screen yet with the panel on.',
303  'A conversa não rolou até a mensagem: ': 'The conversation did not scroll to the message: ',
304
305  // Notes
306  'Pontos importantes': 'Important points',
307  'Nenhum ponto registrado.': 'No points recorded.',
308  Termos: 'Terms',
309  'Nenhum termo registrado.': 'No terms recorded.',
310  'Suas anotações': 'Your notes',
311  '{n} linha': '{n} line',
312  '{n} linhas': '{n} lines',
313  'Nenhuma anotação nesta sessão. O que você escreve aqui fica só no painel.':
314    'No notes in this session. What you write here stays in the panel only.',
315  'Escreva uma anotação e tecle Enter. Cada Enter começa uma linha.': 'Write a note and press Enter. Each Enter starts a line.',
316  'Altere a linha {n} e tecle Enter.': 'Change line {n} and press Enter.',
317  Escrever: 'Write',
318  Salvar: 'Save',
319  '↑ Subir linha': '↑ Move line up',
320  '↓ Descer linha': '↓ Move line down',
321  'Cancelar a alteração': 'Cancel the change',
322  '⧉ Copiar tudo': '⧉ Copy all',
323  '✕ Limpar tudo': '✕ Clear all',
324  'Excluir todas as anotações?': 'Delete all notes?',
325  Excluir: 'Delete',
326  Manter: 'Keep',
327  '⧉ Copiar passos, decisões, pontos e termos': '⧉ Copy steps, decisions, points and terms',
328}
329
330// The names the list of messages and the limits of the plan give the days of the week and the months
331export const WEEKDAYS: Record<Lang, readonly string[]> = {
332  'pt-BR': ['dom.', 'seg.', 'ter.', 'qua.', 'qui.', 'sex.', 'sáb.'],
333  en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
334}
335const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
336
337const two = (n: number): string => String(n).padStart(2, '0')
338
339// A date as each language writes it: 02/10/2026 in Portuguese, 02 Oct 2026 in English, which no reader takes
340// for the 10th of February
341export function dateText(year: number, month: number, day: number): string {
342  return lang === 'en' ? two(day) + ' ' + (MONTHS[month - 1] ?? '') + ' ' + year : two(day) + '/' + two(month) + '/' + year
343}
344
345export function weekdayText(weekday: number): string {
346  return WEEKDAYS[lang][weekday] ?? ''
347}
348
349// A number with one decimal place as each language writes it: 441,2 in Portuguese, 441.2 in English
350export function decimal(value: number | string): string {
351  return lang === 'en' ? String(value) : String(value).replace('.', ',')
352}
353
hooks/textarea.tsx 182 lines
1import type { ClientKeyEvent, ClientModule } from 'claude-code'
2
3type Props = { placeholder: string; hint: string; accent: string; minRows: number }
4type State = { text: string; caret: number; isActive: boolean }
5type Row = { start: number; end: number }
6
7const START: State = { text: '', caret: 0, isActive: false }
8
9// Keys that arrive by name and type nothing
10const NAMED = new Set([
11  'tab',
12  'escape',
13  'insert',
14  'pageup',
15  'pagedown',
16  'f1',
17  'f2',
18  'f3',
19  'f4',
20  'f5',
21  'f6',
22  'f7',
23  'f8',
24  'f9',
25  'f10',
26  'f11',
27  'f12',
28])
29
30// The text as the rows the box shows: each line of the text, cut every `width` characters
31function rowsOf(text: string, width: number): Row[] {
32  const rows: Row[] = []
33  let lineStart = 0
34  for (const line of text.split('\n')) {
35    const lineEnd = lineStart + line.length
36    let at = lineStart
37    do {
38      const end = Math.min(at + width, lineEnd)
39      rows.push({ start: at, end })
40      at = end
41    } while (at < lineEnd)
42    lineStart = lineEnd + 1
43  }
44  return rows
45}
46
47// The row the caret is on: the last row that starts at or before it
48function rowOf(rows: Row[], caret: number): number {
49  let found = 0
50  rows.forEach((row, i) => {
51    if (row.start <= caret) found = i
52  })
53  return found
54}
55
56// The longest text the box holds: the same limit the panel gives a noted prompt
57const LIMIT = 4000
58
59function typed(state: State, piece: string): State {
60  if (state.text.length + piece.length > LIMIT) return state
61
62  return {
63    ...state,
64    text: state.text.slice(0, state.caret) + piece + state.text.slice(state.caret),
65    caret: state.caret + piece.length,
66  }
67}
68
69// The caret one row up or down, in the same column where the row is long enough
70function moved(state: State, width: number, by: number): State {
71  const rows = rowsOf(state.text, width)
72  const from = rowOf(rows, state.caret)
73  const target = rows[from + by]
74  const here = rows[from]
75  if (target === undefined || here === undefined) return state
76
77  return { ...state, caret: Math.min(target.start + (state.caret - here.start), target.end) }
78}
79
80// What one key does to the text. `null` means Enter alone: the text is ready to be noted.
81function afterKey(state: State, event: ClientKeyEvent, width: number): State | null {
82  const { key } = event
83  const hasModifier = event.ctrl === true || event.shift === true || event.meta === true
84
85  if (key === 'return' || key === 'enter') return hasModifier ? typed(state, '\n') : null
86  if (key === 'backspace') {
87    if (state.caret === 0) return state
88    return {
89      ...state,
90      text: state.text.slice(0, state.caret - 1) + state.text.slice(state.caret),
91      caret: state.caret - 1,
92    }
93  }
94  if (key === 'delete') {
95    return { ...state, text: state.text.slice(0, state.caret) + state.text.slice(state.caret + 1) }
96  }
97  if (key === 'left') return { ...state, caret: Math.max(0, state.caret - 1) }
98  if (key === 'right') return { ...state, caret: Math.min(state.text.length, state.caret + 1) }
99  if (key === 'up') return moved(state, width, -1)
100  if (key === 'down') return moved(state, width, 1)
101  if (key === 'home' || key === 'end') {
102    const rows = rowsOf(state.text, width)
103    const row = rows[rowOf(rows, state.caret)]
104    if (row === undefined) return state
105    return { ...state, caret: key === 'home' ? row.start : row.end }
106  }
107  if (key === 'space') return typed(state, ' ')
108  // Ctrl or Meta with a letter is a shortcut, not typing
109  if (event.ctrl === true || event.meta === true) return state
110  // Any other key of more than one character is the name of a key that types nothing
111  if (NAMED.has(key) || [...key].length !== 1) return state
112
113  return typed(state, key)
114}
115
116// A text box of several rows: Enter notes the text, Enter with Ctrl, Shift or both breaks the line
117const TextArea: ClientModule<Props, State> = (props, surface) => {
118  const { Box, Text } = surface.elements
119  const state = surface.state ?? START
120  // The box's body: the region less the frame, the padding and one cell for the caret at a row's end
121  const width = Math.max(8, (surface.columns || 44) - 5)
122
123  surface.onPointer(event => {
124    const now = surface.state ?? START
125    if (event.type === 'down' && !now.isActive) surface.setState({ ...now, isActive: true })
126  })
127
128  surface.onKey(event => {
129    const now = { ...(surface.state ?? START), isActive: true }
130    const next = afterKey(now, event, width)
131    if (next !== null) {
132      surface.setState(next)
133      return
134    }
135    if (now.text.trim() === '') return
136    surface.post({ kind: 'submit', text: now.text })
137    surface.setState({ ...START, isActive: true })
138  })
139
140  const rows = rowsOf(state.text, width)
141  const caretRow = rowOf(rows, state.caret)
142  const blank = Math.max(0, props.minRows - rows.length)
143
144  return (
145    <Box flexDirection="column">
146      <Box
147        flexDirection="column"
148        borderStyle="round"
149        borderColor={state.isActive ? props.accent : undefined}
150        borderDimColor={!state.isActive}
151        paddingX={1}
152      >
153        {state.text === '' && !state.isActive && (
154          <Text dimColor italic>
155            {props.placeholder}
156          </Text>
157        )}
158        {(state.text !== '' || state.isActive) &&
159          rows.map((row, i) => {
160            const line = state.text.slice(row.start, row.end)
161            if (i !== caretRow || !state.isActive) return <Text>{line === '' ? ' ' : line}</Text>
162            const column = state.caret - row.start
163
164            return (
165              <Box flexDirection="row">
166                <Text>{line.slice(0, column)}</Text>
167                <Text inverse>{line.slice(column, column + 1) || ' '}</Text>
168                <Text>{line.slice(column + 1)}</Text>
169              </Box>
170            )
171          })}
172        {Array.from({ length: state.text === '' && !state.isActive ? Math.max(0, blank - 1) : blank }, () => (
173          <Text> </Text>
174        ))}
175      </Box>
176      <Text dimColor>{props.hint}</Text>
177    </Box>
178  )
179}
180
181export default TextArea
182
types/index.d.ts 140 lines
1/** One prompt the user sent, with the time it was submitted (epoch ms). */
2export type Ask = {
3  /** When the message was sent (epoch ms); 0 for a message read back from the conversation, whose time is not known. */
4  at: number
5  text: string
6  /** The id of the row of the conversation that holds the message, set when the message enters the conversation. */
7  row?: string
8  /** True from the moment the message is sent until it enters the conversation and gets its row. */
9  isEntering?: boolean
10}
11
12export type StepStatus = 'done' | 'current' | 'next'
13
14/** One step of the session's plan, as the model reported it. */
15export type Step = { title: string; status: StepStatus }
16
17/** An acronym, term or concept the user needs to remember in this session. */
18export type Term = { term: string; meaning: string }
19
20/** A prompt the user wrote down to send later. */
21export type Queued = {
22  id: number
23  text: string
24  /** True from the click on "Enviar" until the prompt's turn starts: a prompt sent during an answer waits for it to end. */
25  sending?: boolean
26}
27
28export type Board = {
29  asks: Ask[]
30  steps: Step[]
31  terms: Term[]
32  points: string[]
33  queue: Queued[]
34  /** The lines already kept of the prompt the user is writing in the panel; absent in a board saved before 02/10/2026. */
35  draft?: string[]
36  /** The user's free notes of this session, one entry for each line; absent in a board saved before 06/10/2026. */
37  notes?: string[]
38  /** True while the automatic sending is on: after each answer, the first noted prompt is sent. Off in a new session. */
39  auto?: boolean
40  /** The automatic sending that is waiting: which noted prompt, and when it enters (epoch ms). */
41  pending?: { id: number; at: number }
42  /**
43   * What the automatic sending waits for before it starts the 5 seconds: an agent that is still running, or the
44   * app's survey about the session. A board saved before 07/10/2026 holds `true`, which meant the agent.
45   */
46  hold?: 'agente' | 'pesquisa' | true
47  /** Why and when the automatic sending turned itself off, until the user turns it on again. */
48  stopped?: { at: number; why: string }
49  /** The last things the automatic sending did, the most recent last; the panel shows the last one. */
50  autoLog?: { at: number; what: string }[]
51  /** The sections of the panel the user closed, by id. Not read since 07/10/2026, when `open` replaced it. */
52  collapsed?: string[]
53  /**
54   * True while the user tries, on the desktop app, the text box of several lines in place of the app's own
55   * field of one line. Off by itself when the app fails to draw the box.
56   */
57  wideBox?: boolean
58  /** The sections of the panel that are open, by id; absent until the user opens or closes one. */
59  open?: string[]
60  /** The questions the model left for the user to decide, the full list as the model last sent it. */
61  decisions?: string[]
62  /** True from the start of a turn of the main conversation until its end. */
63  working?: boolean
64  /** When the turn under way started, and when the last one ended (epoch ms). */
65  turnStartedAt?: number
66  turnEndedAt?: number
67  /** What the list of sessions calls this one: the session's title in the app, or the folder it runs in. */
68  name?: string
69  /** True after the session ended, until it starts again. */
70  ended?: boolean
71  /** When the board was last written to the store (epoch ms); 0 before the first write. */
72  savedAt: number
73}
74
75/** One limit of the plan: how much of its window is used and when the window resets (ISO 8601). */
76export type Limit = { kind: string; percent: number; resetsAt?: string }
77
78/** The token counts of one turn, as the API reported them. */
79export type TurnTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
80
81/** What the panel shows about the context window, the plan's limits, the last turn and compactions. */
82export type Usage = {
83  /** Input tokens the last response was answered over; absent before the first response. */
84  tokens?: number
85  /** The context window of the session's model, in tokens; 0 before the first measurement. */
86  window: number
87  percent?: number
88  limits: Limit[]
89  lastTurn?: TurnTokens
90  /** How many times the conversation was compacted since the panel was turned on. */
91  compactions: number
92  lastCompaction?: { at: number; before?: number; after?: number }
93}
94
95/** What the user chose to see in the panel: how many of their messages, and which noted prompts whole (by id). */
96export type View = {
97  shownAsks: number
98  expanded: number[]
99  /** True once the pane opened by itself in this session, so a reload does not open again a pane the user closed. */
100  hasOpened?: boolean
101  /** True while the panel is being filled from the conversation, so a second request does not start. */
102  isReloading?: boolean
103  /** The line of the notes being changed in the field: Enter replaces it instead of adding a line. */
104  editingNote?: number
105  /** True after the first click on "Limpar tudo" of the notes, until the user confirms or cancels. */
106  isClearingNotes?: boolean
107  /** Counted up to have the field of the notes drawn again, empty, when nothing else changed. */
108  fieldTick?: number
109  /** Counted up once a minute, so "há 12 min" and the list of sessions stay current. */
110  minuteTick?: number
111  /** The noted prompt whose other actions (move up, move down, put in the box, copy, delete) are shown, by id. */
112  menu?: number
113  /** Which of the two views the pane shows; absent means this session's. */
114  tab?: 'sessao' | 'sessoes'
115  /** The sessions of this machine that run the mod, as last read from the store. */
116  sessions?: SessionRow[]
117}
118
119/** One session in the list of sessions: what its own panel last saved. */
120export type SessionRow = {
121  id: string
122  /** True for the session that draws the list. */
123  isThis: boolean
124  name: string
125  state: 'respondendo' | 'aguardando' | 'encerrada' | 'parada' | 'nova'
126  /** When the state began, or when the session last saved (epoch ms). */
127  at: number
128  stepsDone: number
129  stepsTotal: number
130  step: string
131  decisions: number
132  queued: number
133}
134
135declare module 'claude-code' {
136  interface PluginState {
137    'painel-sessao': { board: Board; usage: Usage; view: View; typed: { prompt: string; note: string } }
138  }
139}
140