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…

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
A pane that stays beside the conversation and answers, at a glance, where the session is and what it waits for.
| Section | What it shows |
|---|---|
| Top | Whether Claude is answering or waiting for you, for how long, the current step and a progress bar |
| Your decisions | The questions Claude left for you to decide, until you answer them |
| Steps | The last two steps done, the current one marked, and the next ones |
| Next prompts | Prompts you queue while Claude works. Send one with a click, reorder them, or turn on auto-send |
| My last messages | Your last messages with their time. Click a time to scroll the conversation to that message |
| Notes | Important points and terms of the session, and your own free notes |
| Context and limits | How 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.
Three things happen without you asking, and two of them have an option to turn off:
openOnStart).answerLine).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.
| Option | Values | Default |
|---|---|---|
language | pt-BR or en | pt-BR |
timeZone | An IANA time zone name, such as America/Sao_Paulo or Europe/Lisbon | empty: the time zone of the system |
openOnStart | true or false | true |
answerLine | true or false | true |
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.
| Portuguese | English | What it does | ||
|---|---|---|---|---|
/painel | /panel | Opens 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-reload | Fills 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.
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.
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.
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./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.~/.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.
--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS have the same name: with both, the installed one does not load.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).
hooks/register.tsx 2508 lines1import { 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 lines1// 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}
353hooks/textarea.tsx 182 lines1import 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
182types/index.d.ts 140 lines1/** 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