SLOPSHOPPER

barra-cache

Sopra il prompt: memoria della chat, uso delle 5 ore, della settimana e limite di spesa con data di scadenza, durata della sessione, cache calda, stato della…

newbandcommandtoastprocesstimer
v1.0.6MITupdated 2026-10-07andreabrugnoli/mods/barra-cache
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · barra-cache
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /barra ⎿ barra-cache: barra-cache · spenta 🔥 cache in attesa della prima richiesta ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🔥 cache in attesa della prima richiesta ⟨Claude Code's own drawing⟩
README

mods

License: MIT Claude Code Mods

Un marketplace di mod per Claude Code, pensato per capire e controllare una sessione: barra della cache e dei consumi, registro di ciò che viene scritto fuori dalla repo, conferma delle proposte, raccolta degli inciampi, modalità rec per le registrazioni e inbox con bozze, etichette e task. Ogni mod è un plugin indipendente, installabile singolarmente con un comando.


Le sei mod

Ogni mod si accende e si spegne con un comando /..., che funziona in terminale, nell'app Desktop e da iPad. Lo stato si ricorda tra una sessione e l'altra.

⏳ barra-cache (/barra) — tre righe sopra il prompt. La prima è la memoria della chat: una barra con i token del contesto occupato e quanto è costata la sessione a listino (verde fino al 60%, gialla fino all'80%, rossa oltre, con il suggerimento Nuova chat). La seconda mostra l'*uso delle 5 ore* e l'*uso della settimana* dell'abbonamento, solo dove il motore riporta le cifre. La terza dice da quanto è aperta la sessione e quanto resta di cache calda: la prompt cache dura un'ora dall'ultima richiesta della conversazione principale (le richieste dei subagent non la rinnovano), poi la richiesta successiva riscrive tutto il contesto a prezzo pieno. Con la cache scaduta e almeno 20mila token di contesto compare un avviso rosso. Sotto, una riga con branch, file modificati e commit da pubblicare (letta da git, solo dove $.process esiste: nel cloud non compare) e i bottoni: Commit e push (tasto g), Push (tasto p, compare solo quando ci sono commit da pubblicare e nessuna modifica da committare, senza token) e Handoff (tasto n), che fa scrivere al modello un riassunto di ripartenza in ~/.claude/handoffs/, svuota la chat con /clear e riparte da quel file. Dove la banda non viene disegnata (ad esempio l'app su iPad) restano /cache, che mostra minuti rimasti, contesto, costo dell'ultimo turno e quota di cache letta, /push e /handoff (alias /nuova). I comandi sono anche file statici in barra-cache/commands/, perché l'elenco dello slash dell'app non mostra quelli registrati a runtime; l'hook risponde prima del modello, quindi non costano token.

📒 scritture-esterne (/scritture) — a fine turno elenca tutto ciò che il turno ha scritto fuori dalla repo: Notion, Postpickr, Spreaker, Gmail, Calendar, Drive, git push, gh, curl -X POST e gli script con --applica o --elimina. Ogni riga ha servizio, azione, bersaglio, un link apri se la risposta ne contiene uno, e una croce rossa se la chiamata è fallita o negata. Le letture non compaiono. Il registro sparisce all'inizio del turno successivo. Riconosce dal nome i connettori più comuni; per quelli con id opaco serve l'opzione servizi del plugin.

✅ conferma-proposte (/conferma) — quando l'ultima risposta di Claude chiude con una proposta o una domanda di conferma (una domanda, oppure formule come "procedo", "vuoi che", "confermi"), mostra sopra il prompt il passaggio che propone e due bottoni: Sì, procedi (tasto s) e No, fermati (tasto x). Il bottone invia la risposta come se l'avessi scritta tu. Non usa il modello, quindi non consuma token. Funziona anche nel cloud e da iPad.

🛠 correggi (/correggi) — durante la sessione annota gli inciampi: tool che falliscono, azioni negate dal sistema di permessi e le tue correzioni ("non vedo", "hai sbagliato", "riprova"). Con almeno due inciampi compare una riga con il conteggio, le skill usate e il bottone Proponi correzione (tasto l), che chiede a Claude di individuare la causa e proporre la modifica esatta alla skill o al file di istruzioni, senza applicarla. /correggi elenco mostra gli inciampi raccolti. Consuma token solo quando premi il bottone.

🔴 rec (/rec) — la modalità per registrare un video o lavorare in una sessione live con ospiti. Maschera a schermo chiavi e valori dei .env, email, nomi, telefoni, indirizzi, codice fiscale, partita IVA, IBAN, importi in euro e cifre vicino a parole come fatturato, margine, compenso, preventivo. I risultati di posta, chat, task, file, calendario, Notion e strumenti di pagamento si disegnano nascosti. Claude continua a lavorare sui dati reali: cambia solo ciò che si vede (se modifica un file da 29 a 39 euro, il file cambia davvero, lo schermo no). Tiene chiusi i file privati (.env, credenziali, fatture, contratti, preventivi, buste paga) e gli strumenti di pagamento, e ogni prompt porta una nota nascosta che chiede a Claude di usare segnaposto al posto di nomi e cifre. Un ● REC rosso sopra il prompt e nel piè di pagina ricorda che è acceso. /rec alterna, /rec rigoroso maschera anche ogni cifra grande, /rec off spegne, /rec config crea ~/.claude/mods-data/rec/config.json per il tuo nome (nomiVisibili), le persone da nascondere (nomiNascosti), le cartelle private (percorsiPrivati) e gli strumenti extra (strumentiAffari, strumentiChiusi). Limiti: cambia ciò che è disegnato, non ciò che è memorizzato; il riconoscimento per pattern non prende tutto quello che è scritto a parole, quindi riguarda il girato prima di pubblicarlo; i titoli delle chat nella barra laterale dell'app Desktop non si possono mascherare. Adattata da recording-mode di Nate Herk (MIT, vedi rec/NOTICE.md).

📬 posta (posta o /posta) — apre un pannello con la inbox Gmail: l'elenco delle ultime 12 mail per account (una finestra di 5 righe che scorre con j e k), i bottoni e, sotto, il testo intero della mail selezionata (tutti i messaggi del thread, dal più recente, con allegati), letto da Gmail senza modello. I bottoni sono Bozza (b), Label (l), Bozza+Label (m), Task (t), più Aggiorna (r) e Chiudi (x). Le quattro azioni girano dentro la mod, senza subagent: il modello riceve il thread senza alcun tool e sceglie solo i contenuti, mentre le scritture su Gmail e Notion le fa il codice dopo averle validate (la chat resta pulita: l'esito compare nel pannello e in un avviso). Bozza scrive con le regole di ~/.claude/mods-data/posta/sistematore.md e crea una bozza di risposta (la mail non viene mai inviata). Label fa scegliere al modello economico un'etichetta tra quelle esistenti e la applica solo se il nome coincide (non ne crea). Task fa proporre al modello economico titolo, scadenza, urgenza, importanza, impegno e contesto, li riporta ai valori ammessi (scadenza non valida o passata: domani alle 09:00) e crea la pagina nel database Tasks di Notion con il link alla mail. Solo Label e Bozza+Label tolgono la mail dall'inbox (finisce nella cartella dell'etichetta); Bozza e Task la lasciano in inbox. I lavori in corso mostrano uno spinner, quelli riusciti spariscono dopo 8 secondi e quelli falliti si rilanciano con Riprova (y). Nel dettaglio compare l'ultimo messaggio del thread; i precedenti sono righe ripiegate e si aprono con Mostra precedenti (p). Scrivendo posta senza barra il pannello si apre senza avviso dell'app e senza token; posta off lo chiude. Gli account sono in DEFAULT_ACCOUNTS e si sostituiscono con la chiave accounts dello store. In modalità Auto anche le chiamate della mod passano dal classificatore, che sulle scritture non dà verdetto: vanno autorizzate in permissions.allow le letture Gmail (search_threads, get_thread, list_labels) e le quattro scritture (create_draft, label_thread, unlabel_thread di Gmail, notion-create-pages di Notion), con il prefisso mcp__<id connettore>__. Un errore resta visibile solo sulla mail a cui appartiene e sparisce quando la stessa azione riesce.


Prerequisiti

  • Claude Code 2.1.287 o successivo: le mod sono attive di default da questa versione. Verifica con claude --version.
  • Terminale o tab Code dell'app Desktop: le sessioni WSL nell'app Desktop non eseguono le mod.

Una mod è codice eseguito con i tuoi permessi: può leggere e scrivere file, avviare processi e vedere prompt e tool call della sessione. Il sorgente di ogni mod è in hooks/: leggilo prima dell'installazione.


Installazione

  1. Aggiungi il marketplace a Claude Code:
   claude plugin marketplace add andreabrugnoli/mods
  1. Installa le mod che ti interessano, una per comando:
   claude plugin install barra-cache@andrea-mods
   claude plugin install scritture-esterne@andrea-mods
   claude plugin install conferma-proposte@andrea-mods
   claude plugin install correggi@andrea-mods
   claude plugin install rec@andrea-mods
   claude plugin install posta@andrea-mods
  1. Avvia una nuova sessione con claude.

Verifica: apri /plugin e controlla che le mod risultino attive. Per provare una mod in una sola sessione senza installarla, clona la repo e avvia claude --plugin-dir ./mods/barra-cache. Per disattivarla, usa il tab Installed di /plugin.


Uso in cloud e da iPad

Le sessioni cloud (claude.ai/code, app per iPad) non leggono le tue impostazioni locali: partono da un contenitore pulito e caricano i plugin dichiarati nella repo su cui lavori. Per attivare una mod in una repo, aggiungi a .claude/settings.json di quella repo:

{
  "extraKnownMarketplaces": {
    "andrea-mods": { "source": { "source": "github", "repo": "andreabrugnoli/mods" } }
  },
  "enabledPlugins": { "barra-cache@andrea-mods": true, "rec@andrea-mods": true }
}

Lo fa per te scripts/abilita-cloud.sh <percorso-repo> [mod ...], che unisce la voce alle impostazioni esistenti. Se il tuo gitignore (anche globale) esclude .claude/settings.json, aggiungilo con git add -f .claude/settings.json, poi commit e push. La sessione cloud legge il file dal branch.

Su iPad la banda sopra il prompt può non essere disegnata: restano /cache, /push e /nuova, e tutti i comandi on/off (/barra, /scritture, /conferma, /correggi, /rec) funzionano uguale. Lo stato ($.store) e i riassunti in ~/.claude/handoffs/ vivono nel contenitore della sessione e si perdono alla sua chiusura.


Comandi disponibili

Accendere e spegnere (senza argomento alternano; accettano anche on e off):

  • /barra: la barra sopra il prompt.
  • /scritture: il registro delle scritture esterne.
  • /conferma: i bottoni Sì e No sulle proposte.
  • /correggi: la raccolta degli inciampi (/correggi elenco li mostra).
  • /rec: la modalità registrazione (/rec rigoroso, /rec off, /rec config).
  • posta o /posta: la inbox con i bottoni Bozza, Label e Task (posta aggiorna, posta off).

Altri comandi di barra-cache:

  • /cache: minuti di cache rimasti, contesto occupato, costo dell'ultimo turno e quota di cache letta.
  • /push: pubblica i commit del branch corrente.
  • /nuova: riassume la sessione in un file e riparte da una chat pulita.

Personalizzazione

Durata della cache in barra-cache: parte da un'ora (TTL_MS) e si corregge da sola. Dopo una pausa di almeno 6 minuti guarda la quota di cache letta: se la cache è stata riscritta, passa a 5 minuti e lo ricorda tra le sessioni; se è stata letta, conferma l'ora.

Soglie dei colori in barra-cache: CONTEXT_WARN, CONTEXT_BAD, COST_WARN, CACHE_OK e CACHE_BAD, in cima allo stesso file.

Nomi dei connettori in scritture-esterne: opzione servizi del plugin.

Per modificare una mod, clona la repo e caricala con claude --plugin-dir: ogni salvataggio ricarica il modulo nella sessione aperta.


Struttura del progetto

mods/
├── .claude-plugin/marketplace.json   # elenco delle mod installabili
├── barra-cache/
├── scritture-esterne/
├── conferma-proposte/
├── correggi/
├── rec/
├── posta/
└── scripts/abilita-cloud.sh

Ogni cartella di mod è un plugin completo: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.js e tests/ (eseguibili con claude plugin test ./<mod>). claude plugin validate ./<mod> elenca gli eventi intercettati e le chiamate al motore.


Licenza: MIT.

Source 1 files
hooks/register.js 501 lines
1// Quanto dura la prompt cache dall'ultima richiesta che l'ha letta o scritta
2const TTL_MS = 60 * 60 * 1000
3// La durata breve della cache (5 minuti), e la pausa minima per capire quale delle due è in uso
4const SHORT_TTL_MS = 5 * 60 * 1000
5const GAP_MIN_MS = 6 * 60 * 1000
6// La durata in uso: parte da un'ora e si corregge da sola osservando la cache letta dopo una pausa
7let ttlMs = TTL_MS
8// Ogni quanto la barra si ridisegna da sola
9const TICK_MS = 30 * 1000
10
11// L'istante dell'ultima richiesta della conversazione principale: da lì riparte l'ora
12let lastAt = null
13// L'ora letta all'ultimo tick o all'ultima richiesta
14let now = 0
15// Il timer che fa scorrere la barra, avviato alla prima richiesta
16let ticking = null
17
18// Soglie dei colori: contesto in percentuale, costo di un turno in dollari, cache letta in percentuale
19const CONTEXT_WARN = 60
20const CONTEXT_BAD = 80
21const COST_WARN = 0.5
22const CACHE_OK = 80
23const CACHE_BAD = 50
24
25// Il contesto e il costo letti dal motore: { percent, tokens, window, usd }, null dove non rispondono
26let usage = null
27// Il turno concluso: { usd, fresh, out }, dove "fresh" sono i token pagati interi (non letti dalla cache)
28let lastTurn = null
29// Il costo della sessione al termine del turno precedente, per sottrarlo
30let costBefore = null
31// I token del turno in corso, sommati richiesta per richiesta
32let turnFresh = 0
33let turnOut = 0
34// Quanta parte dell'ultima richiesta è stata letta dalla cache, da 0 a 100
35let cacheReadPct = null
36
37// L'istante in cui è cominciata la sessione, ripreso dall'archivio se è la stessa sessione
38let startedAt = null
39
40// Durata in forma breve: 45 min, 2h 14m
41function duration(ms) {
42  const minutes = Math.max(0, Math.floor(ms / 60000))
43  return minutes < 60 ? minutes + ' min' : Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm'
44}
45
46// Token in forma breve: 950, 12k, 1,2M
47function short(n) {
48  const one = (x) => x.toFixed(1).replace('.', ',').replace(/,0$/, '')
49  if (n >= 1e6) return one(n / 1e6) + 'M'
50  if (n >= 1e4) return Math.round(n / 1e3) + 'k'
51  if (n >= 1e3) return one(n / 1e3) + 'k'
52  return String(n)
53}
54
55// Token in parole: 950, 12mila, 1,2 milioni
56function tokensIt(n) {
57  const one = (x) => x.toFixed(1).replace('.', ',').replace(/,0$/, '')
58  if (n >= 1e6) return one(n / 1e6) + ' milioni'
59  if (n >= 1e4) return Math.round(n / 1e3) + 'mila'
60  if (n >= 1e3) return one(n / 1e3) + 'mila'
61  return String(n)
62}
63
64// Dollari con due decimali, la virgola e il simbolo dopo la cifra: 0,42 $
65function dollars(n) {
66  return n.toFixed(2).replace('.', ',') + ' $'
67}
68
69const DAYS_IT = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab']
70const MONTHS_IT = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic']
71
72// Il nome di una finestra di consumo: quelle note in italiano, le altre con il nome del motore
73function windowName(kind) {
74  const names = { five_hour: 'uso 5 ore', seven_day: 'uso settimana', spend_limit: 'limite di spesa' }
75  return names[kind] ?? 'limite ' + kind.replace(/_/g, ' ')
76}
77
78// Quando si rinnova una finestra, in ora locale: "14:30" se è oggi, altrimenti "lun 12 ott 14:30". Vuoto se la data manca o non è valida
79function resetLabel(resetsAt, nowMs) {
80  const at = new Date(resetsAt ?? NaN)
81  if (Number.isNaN(at.getTime())) return ''
82  const two = (n) => String(n).padStart(2, '0')
83  const time = two(at.getHours()) + ':' + two(at.getMinutes())
84  const today = new Date(nowMs)
85  const sameDay = at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate()
86  return sameDay ? time : DAYS_IT[at.getDay()] + ' ' + at.getDate() + ' ' + MONTHS_IT[at.getMonth()] + ' ' + time
87}
88
89// Le finestre di consumo dell'abbonamento riportate dal motore: [{ kind, percentUsed }]
90let limits = []
91// Acceso o spento: lo decide /barra e resta nello store tra una sessione e l'altra
92let enabled = true
93
94// Il nuovo stato dopo un comando: "on" accende, "off" spegne, senza argomento alterna
95export function parseToggle(args, current) {
96  const arg = String(args ?? '').trim().toLowerCase()
97  if (/^(on|acceso|attiva|si|sì)$/.test(arg)) return true
98  if (/^(off|spento|disattiva|no)$/.test(arg)) return false
99  return !current
100}
101
102function contextColor(percent) {
103  return percent > CONTEXT_BAD ? 'red' : percent > CONTEXT_WARN ? 'yellow' : 'green'
104}
105
106function cacheColor(percent) {
107  return percent >= CACHE_OK ? 'green' : percent >= CACHE_BAD ? 'yellow' : 'red'
108}
109
110// Registra i cifre che il motore riporta a ogni misura: contesto e costo (la banda si ridisegna da sola)
111function applyMeasure(e) {
112  usage = e.context.percent === undefined ? usage : { percent: e.context.percent, tokens: e.context.tokens ?? 0, window: e.context.window, usd: e.cost?.usd }
113  if (e.cost && usage) usage.usd = e.cost.usd
114  if (Array.isArray(e.rateLimits)) limits = e.rateLimits
115}
116
117// Chiude il turno: costo e token del turno appena finito, poi si riparte da zero
118function closeTurn() {
119  if (turnFresh > 0 || turnOut > 0) {
120    const usd = usage && usage.usd !== undefined && costBefore !== null ? Math.max(0, usage.usd - costBefore) : null
121    lastTurn = { usd, fresh: turnFresh, out: turnOut }
122  }
123  if (usage && usage.usd !== undefined) costBefore = usage.usd
124  turnFresh = 0
125  turnOut = 0
126}
127
128// Le stesse cifre della banda, in righe di testo per il comando /cache
129function usageLines() {
130  const lines = []
131  if (usage) lines.push('contesto · ' + usage.percent + '% (' + short(usage.tokens) + ' su ' + short(usage.window) + ')' + (usage.percent > CONTEXT_BAD ? ', conviene /nuova' : ''))
132  if (lastTurn) lines.push('ultimo turno · ' + (lastTurn.usd === null ? '' : dollars(lastTurn.usd) + ' · ') + short(lastTurn.fresh) + ' token nuovi, ' + short(lastTurn.out) + ' generati')
133  if (usage && usage.usd !== undefined) lines.push('sessione · ' + dollars(usage.usd))
134  if (startedAt !== null) lines.push('sessione · ' + duration(Math.max(0, now - startedAt)))
135  if (cacheReadPct !== null) lines.push('cache letta · ' + cacheReadPct + '% dell\'ultima richiesta')
136  for (const l of limits) {
137    const reset = resetLabel(l.resetsAt, now)
138    lines.push(windowName(l.kind) + ' · ' + Math.round(l.percentUsed) + '%' + (reset ? ', scade ' + reset : ''))
139  }
140  return lines
141}
142
143// Il prompt del bottone "Commit e push"
144const COMMIT_PROMPT =
145  'Esegui ora: git status, poi git add delle modifiche pertinenti, un commit con messaggio breve in italiano e git push sul branch corrente (git push -u origin <branch>). Prima di aggiungere, controlla i file: se tra le modifiche ci sono file sensibili (.env, chiavi, token, credenziali, certificati), non aggiungerli e chiedimi conferma. Se non ci sono modifiche, dillo e fermati. Non modificare altro.'
146
147// Il prompt del push quando git non è raggiungibile
148const PUSH_PROMPT = 'Esegui ora git push sul branch corrente (git push -u origin <branch> se manca l\'upstream). Non modificare altro.'
149
150// Il prompt del bottone "Crea repo": cartella senza git, repo GitHub sempre privata
151const CREATE_REPO_PROMPT =
152  'Questa cartella non è una repo git. Esegui ora: git init (branch main), un .gitignore adatto se manca, git add delle sole modifiche pertinenti, un primo commit con messaggio breve in italiano, poi gh repo create <nome-cartella-in-kebab-case> --private --source . --push. Prima di aggiungere, controlla i file: se ci sono file sensibili (.env, chiavi, token, credenziali, certificati), non aggiungerli e chiedimi conferma. Non rendere mai pubblica la repo. Non modificare altro.'
153
154// Vero quando la cartella non è dentro una repo git (git risponde, ma con "not a git repository")
155let noRepo = false
156
157// Lo stato della repo letto da git: { branch, changed, ahead, hasUpstream }, null se non disponibile
158// (fuori da una repo, o dove $.process non esiste, come nelle sessioni cloud)
159let git = null
160
161// Legge l'intestazione e le righe di `git status --porcelain=v1 -b`
162function parseGit(stdout) {
163  const lines = stdout.split('\n').filter(Boolean)
164  const head = (lines.shift() ?? '').replace(/^## /, '')
165  const m = head.match(/^(.+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/)
166  const ahead = Number(/ahead (\d+)/.exec(m?.[3] ?? '')?.[1] ?? 0)
167  return { branch: m?.[1] ?? head, changed: lines.length, ahead, hasUpstream: Boolean(m?.[2]) }
168}
169
170// Rilegge lo stato; in caso di errore la banda torna ai bottoni che passano dal modello
171async function refreshGit($) {
172  try {
173    const { exitCode, stdout, stderr } = await $.process.run(['git', 'status', '--porcelain=v1', '-b'])
174    git = exitCode === 0 ? parseGit(stdout) : null
175    noRepo = exitCode !== 0 && /not a git repository/i.test(stderr ?? '')
176  } catch {
177    git = null
178    noRepo = false
179  }
180  $.ui.invalidate('ui.render')
181}
182
183// Fa il push direttamente con git, senza token. Dove non si può, lo chiede al modello
184async function pushNow($) {
185  if (git === null) return $.prompt.submit({ text: PUSH_PROMPT })
186  const argv = git.hasUpstream ? ['git', 'push'] : ['git', 'push', '-u', 'origin', 'HEAD']
187  try {
188    const { exitCode, stderr } = await $.process.run(argv, { timeoutMs: 60000 })
189    const last = stderr.trim().split('\n').pop() ?? ''
190    $.ui.toast(exitCode === 0 ? 'Push fatto su ' + git.branch : 'Push fallito: ' + last.slice(0, 120))
191  } catch {
192    $.ui.toast('Push non riuscito: git non risponde')
193  }
194  await refreshGit($)
195}
196
197// Il prompt del bottone "Nuova chat": il riassunto va in un file, poi la chat riparte da lì
198function handoffPrompt(file) {
199  return (
200    'Prepara il passaggio a una nuova chat per risparmiare contesto. Scrivi in ' +
201    file +
202    ' (crea la cartella se manca) un riassunto che permetta di riprendere dallo stesso punto: obiettivo, decisioni prese, stato attuale, file toccati con percorso assoluto, cartella di lavoro corrente, comandi utili, prossimi passi, punti aperti. Solo fatti, niente cronaca. Non fare altro. Rispondi con una riga.'
203  )
204}
205
206// Un'azione chiesta da un comando: prompt.submit non si può chiamare dentro command.run, parte a fine turno
207let queued = null
208
209// Il file di passaggio per la sessione in corso, atteso dopo il riassunto
210let handoffFile = null
211
212// Avvia il passaggio a una nuova chat: il riassunto va in un file, il resto lo fa turn.complete
213async function startHandoff($) {
214  const id = await $.session.id()
215  handoffFile = '~/.claude/handoffs/' + id + '.md'
216  $.ui.toast('Riassunto in corso, poi la chat riparte pulita')
217  await $.prompt.submit({ text: handoffPrompt(handoffFile) })
218}
219
220// Fa scorrere la barra, una volta sola
221function startTicking($) {
222  ticking ??= $.clock.every(TICK_MS, async () => {
223    try {
224      now = await $.clock.now()
225      $.ui.invalidate('ui.render')
226    } catch {}
227  })
228}
229
230// Verde finché c'è margine, rosso quando la cache sta per scadere
231function leftColor(minutes) {
232  if (minutes > 20) return 'green'
233  if (minutes > 5) return 'yellow'
234  return 'red'
235}
236
237// Gestori dei comandi, a livello di modulo: ognuno è agganciato sia a /nome sia a /barra-cache:nome
238async function runBarra($, e) {
239  enabled = parseToggle(e.args, enabled)
240  try {
241    await $.store.set('attiva', enabled)
242  } catch {}
243  if (enabled) void refreshGit($)
244  $.ui.invalidate('ui.render')
245  return { text: 'barra-cache · ' + (enabled ? 'accesa' : 'spenta') }
246}
247
248async function runCache($) {
249  const extra = usageLines()
250  const join = (first) => [first, ...extra].join('\n')
251  if (lastAt === null) return { text: join('cache · in attesa della prima richiesta') }
252  const t = await $.clock.now()
253  const leftMs = Math.max(0, ttlMs - (t - lastAt))
254  if (leftMs === 0) return { text: join('cache scaduta: la prossima richiesta la riscrive') }
255  const minutes = Math.ceil(leftMs / 60000)
256  return { text: join('cache · ' + minutes + ' min rimasti, ' + (60 - minutes) + ' min dall\'ultima richiesta') }
257}
258
259async function runPush() {
260  queued = 'push'
261  return { text: 'Push avviato.' }
262}
263
264async function runHandoff() {
265  queued = 'nuova'
266  return { text: 'Passaggio avviato: riassunto, poi chat pulita.' }
267}
268
269export function register(on) {
270
271  // Gli stessi servizi come comandi, per le superfici che non disegnano la banda
272  on('session.start', async ($, e, next) => {
273    // Un ricaricamento della mod o una ripresa della stessa sessione non azzerano la barra
274    try {
275      const saved = await $.store.get('last')
276      const t = await $.clock.now()
277      const savedTtl = await $.store.get('ttl')
278      if (savedTtl === SHORT_TTL_MS || savedTtl === TTL_MS) ttlMs = savedTtl
279      const started = await $.store.get('started')
280      if (started && started.id === (await $.session.id())) startedAt = started.at
281      if (saved && saved.id === (await $.session.id()) && t - saved.at < ttlMs) {
282        lastAt = saved.at
283        now = t
284        startTicking($)
285      }
286    } catch {}
287    try {
288      if ((await $.store.get('attiva')) === false) enabled = false
289    } catch {}
290    const specs = [
291      { name: 'barra', description: 'Accende o spegne la barra sopra il prompt', argumentHint: '[on|off]', immediate: true },
292      { name: 'cache', description: 'Quanto resta della prompt cache' },
293      { name: 'push', description: 'Pubblica i commit del branch corrente' },
294      { name: 'nuova', description: 'Riassume e riparte da una chat pulita' },
295      { name: 'handoff', description: 'Riassume e riparte da una chat pulita (come il bottone Handoff)' },
296    ]
297    for (const spec of specs) await $.command.register(spec)
298    void refreshGit($)
299    // Nelle chat nuove l'elenco dei comandi può essere già chiuso a questo punto: si ripete dopo poco
300    for (const ms of [1500, 5000]) {
301      $.clock.after(ms, () => {
302        for (const spec of specs) void $.command.register(spec).catch(() => {})
303      })
304    }
305    return next(e)
306  })
307
308  on('command.run', { command: 'barra' }, runBarra)
309  on('command.run', { command: 'barra-cache:barra' }, runBarra)
310  on('command.run', { command: 'cache' }, runCache)
311  on('command.run', { command: 'barra-cache:cache' }, runCache)
312  on('command.run', { command: 'push' }, runPush)
313  on('command.run', { command: 'barra-cache:push' }, runPush)
314  on('command.run', { command: 'nuova' }, runHandoff)
315  on('command.run', { command: 'barra-cache:nuova' }, runHandoff)
316  on('command.run', { command: 'handoff' }, runHandoff)
317  on('command.run', { command: 'barra-cache:handoff' }, runHandoff)
318
319  // Dopo il riassunto: svuota la chat e riparte dal file. Il comando va in coda, non si attende dentro il turno
320  on('turn.complete', async ($, e, next) => {
321    // git status dura pochi millisecondi: lo si attende, così la banda è già aggiornata a fine turno
322    if (!e.agentId) {
323      if (enabled) await refreshGit($)
324      closeTurn()
325    }
326    if (!e.agentId && handoffFile) {
327      const file = handoffFile
328      handoffFile = null
329      void (async () => {
330        try {
331          await $.command.run({ command: 'clear' })
332          await $.prompt.submit({ text: 'Riprendi il lavoro dal punto in cui eravamo: leggi ' + file + ' e continua da lì. Conferma in una riga cosa hai capito, poi procedi.' })
333        } catch {
334          $.ui.toast('Riassunto in ' + file + ': lancia /clear e fallo leggere')
335        }
336      })()
337    }
338    if (!e.agentId && queued) {
339      const action = queued
340      queued = null
341      void (action === 'push' ? pushNow($) : startHandoff($))
342    }
343    return next(e)
344  })
345
346  // Il motore riporta contesto e costo quando si muovono: la banda li mostra
347  on('session.measure', ($, e, next) => {
348    applyMeasure(e)
349    $.ui.invalidate('ui.render')
350    return next(e)
351  })
352
353  // Una richiesta al modello rinnova la cache: l'ora riparte dal suo risultato
354  on('turn.step', async function* ($, e, next) {
355    const result = yield* next(e)
356    // Solo la conversazione principale: i subagent hanno una cache loro
357    if (!e.agentId && result.usage) {
358      const u = result.usage
359      turnFresh += u.input_tokens + u.cache_creation_input_tokens
360      turnOut += u.output_tokens
361      const total = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
362      cacheReadPct = total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : null
363      const t = await $.clock.now()
364      // Dopo una pausa lunga la cache letta dice quale durata ha davvero: letta = dura, riscritta = è scaduta
365      if (lastAt !== null && total >= 2000 && t - lastAt >= GAP_MIN_MS) {
366        const ratio = u.cache_read_input_tokens / total
367        const learned = ratio > 0.6 ? TTL_MS : ratio < 0.2 && t - lastAt < TTL_MS ? SHORT_TTL_MS : null
368        if (learned !== null && learned !== ttlMs) {
369          ttlMs = learned
370          $.ui.toast('Cache: durata rilevata ' + Math.round(ttlMs / 60000) + ' min')
371          try {
372            await $.store.set('ttl', ttlMs)
373          } catch {}
374        }
375      }
376      lastAt = now = t
377      if (startedAt === null) {
378        startedAt = lastAt
379        try {
380          await $.store.set('started', { id: await $.session.id(), at: startedAt })
381        } catch {}
382      }
383      startTicking($)
384      try {
385        await $.store.set('last', { id: await $.session.id(), at: lastAt })
386      } catch {}
387      $.ui.invalidate('ui.render')
388    }
389    return result
390  })
391
392  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
393    // Quello che disegnano le altre mod nella banda resta sotto le nostre righe
394    const theirs = await next(e)
395    if (!enabled) return theirs
396    // L'ora si rilegge a ogni disegno: non dipende dal timer, che può fermarsi
397    try {
398      now = await $.clock.now()
399    } catch {}
400    const { Box, Text, Button } = $.ui.resolve(e)
401    const dim = (text) => Text({ dimColor: true, children: [text] })
402    const strong = (text, color) => Text(color ? { bold: true, color, children: [text] } : { bold: true, children: [text] })
403    const row = (children) => Box({ flexDirection: 'row', flexWrap: 'wrap', children: children.filter(Boolean) })
404    const gap = Text({ children: ['  '] })
405    // Con lo stato di git i bottoni compaiono solo se c'è qualcosa da fare
406    const hasChanges = git === null || git.changed > 0
407    const canPush = git !== null && git.changed === 0 && (git.ahead > 0 || !git.hasUpstream)
408    const buttons = Box({
409      flexDirection: 'row',
410      children: [
411        noRepo && Button({ key: 'create-repo', label: 'Crea repo', hotkey: 'g', onPress: () => $.prompt.submit({ text: CREATE_REPO_PROMPT }) }),
412        noRepo && gap,
413        !noRepo && hasChanges && Button({ key: 'commit', label: 'Commit e push', hotkey: 'g', onPress: () => $.prompt.submit({ text: COMMIT_PROMPT }) }),
414        !noRepo && hasChanges && gap,
415        !noRepo && canPush && Button({ key: 'push', label: 'Push', hotkey: 'p', onPress: () => pushNow($) }),
416        !noRepo && canPush && gap,
417        Button({
418          key: 'fresh',
419          label: 'Handoff',
420          hotkey: 'n',
421          onPress: () => startHandoff($),
422        }),
423      ].filter(Boolean),
424    })
425    // Niente props indefinite: il colore è scelto prima
426    const paint = (text, color) => Text(color ? { color, children: [text] } : { dimColor: true, children: [text] })
427    const barWidth = e.props.bodyColumns >= 110 ? 12 : 8
428
429    // Una barra piena per la parte usata, vuota per il resto
430    const bar = (fraction, color) => {
431      const full = Math.min(barWidth, Math.max(0, Math.ceil(fraction * barWidth)))
432      return [paint('█'.repeat(full), color), dim('░'.repeat(barWidth - full))]
433    }
434
435    // Riga 1: la memoria della chat, cioè il contesto occupato, e quanto è costata a listino
436    const memory = usage
437      ? row([
438          dim('🧠 memoria della chat '),
439          ...bar(usage.percent / 100, contextColor(usage.percent)),
440          dim(' '),
441          strong(tokensIt(usage.tokens) + ' token'),
442          usage.usd !== undefined && dim(' · speso ' + dollars(usage.usd) + ' a listino'),
443          usage.percent > CONTEXT_BAD && paint(' · Nuova chat?', 'red'),
444        ])
445      : null
446
447    // Riga 2: i consumi dell'abbonamento, ognuno solo se il motore ha riportato la cifra
448    const limitRow = (kind, icon, label) => {
449      const found = limits.find((l) => l.kind === kind)
450      if (!found) return null
451      const percent = Math.round(found.percentUsed)
452      const reset = resetLabel(found.resetsAt, now)
453      return row([
454        dim(icon + ' ' + label + ' '),
455        ...bar(found.percentUsed / 100, contextColor(percent)),
456        dim(' '),
457        strong(percent + '%', percent > CONTEXT_BAD ? 'red' : null),
458        reset && dim(' scade ' + reset),
459      ])
460    }
461    // Le finestre note hanno un'etichetta; una finestra che il motore aggiunge in futuro (un mese, per esempio) compare con il suo nome
462    const known = ['five_hour', 'seven_day', 'spend_limit']
463    const windows = [
464      limitRow('five_hour', '⏳', 'uso 5 ore'),
465      limitRow('seven_day', '📅', 'uso settimana'),
466      limitRow('spend_limit', '💳', 'limite di spesa'),
467      ...limits.filter((l) => !known.includes(l.kind)).map((l) => limitRow(l.kind, '📆', windowName(l.kind))),
468    ].filter(Boolean)
469    const usageRow = windows.length ? row(windows.flatMap((w, i) => (i === 0 ? [w] : [gap, w]))) : null
470
471    // Riga 3: da quanto è aperta la sessione e quanto resta della cache
472    let isCold = false
473    let cacheSeg
474    if (lastAt === null) {
475      cacheSeg = dim('🔥 cache in attesa della prima richiesta')
476    } else {
477      const leftMs = Math.max(0, ttlMs - (now - lastAt))
478      if (leftMs === 0) {
479        isCold = true
480        cacheSeg = row([dim('🧊 cache '), paint('scaduta', 'red')])
481      } else {
482        const minutes = Math.ceil(leftMs / 60000)
483        cacheSeg = row([dim('🔥 cache calda ancora '), strong(minutes + ' min', leftColor(minutes) === 'green' ? null : leftColor(minutes))])
484      }
485    }
486    const sessionRow = row([startedAt !== null && dim('🕒 sessione aperta da '), startedAt !== null && strong(duration(Math.max(0, now - startedAt))), startedAt !== null && gap, cacheSeg])
487
488    // Cache scaduta con un contesto grande: il prossimo prompt riscrive tutto a prezzo pieno
489    const coldWarn =
490      isCold && usage && usage.tokens >= 20000
491        ? Box({ flexDirection: 'row', children: [paint('Cache scaduta · il prossimo prompt riscrive ' + tokensIt(usage.tokens) + ' token', 'red'), dim(' · se cambi argomento conviene Nuova chat')] })
492        : null
493
494    const lines = [memory, usageRow, sessionRow]
495    return Box({
496      flexDirection: 'column',
497      children: (e.props.isWorking ? [...lines, theirs] : [...lines, coldWarn, buttons, theirs]).filter(Boolean),
498    })
499  })
500}
501