Panel lateral de consumo: tokens del último pedido, gasto de la sesión, contexto, herramientas y tiempo

hooks/register.js 208 lines1// ─────────────────────────────────────────────────────────────
2// Mod "consumo": panel lateral con métricas de la sesión
3// Abre con /consumo · Esc lo cierra
4// La marca y el color de acento se cambian en /config
5// ─────────────────────────────────────────────────────────────
6
7const PANE = 'consumo'
8const PANE_COLUMNS = 34 // ancho pedido para el panel acoplado
9const INNER = PANE_COLUMNS - 4 // ancho útil, descontando el marco del panel
10
11// Estado del panel (se reinicia si el mod se recarga)
12// Un "pedido" es el turno completo: todas las llamadas al modelo que hace
13// Claude para responder un mensaje, subagentes incluidos.
14let turn = { fresh: 0, output: 0, cached: 0, calls: 0 } // turno en curso
15let last = { fresh: 0, output: 0, cached: 0, calls: 0 } // último pedido
16let tools = 0 // herramientas usadas en la sesión
17let loadedAt = Date.now()
18// Contexto, gasto e inicio de sesión los lleva Claude Code; aquí solo se leen
19let usage = null
20
21// ── Helpers de formato ──────────────────────────────────────
22function fmtTokens(n) {
23 if (n >= 1_000_000) return trim(n / 1_000_000) + 'M'
24 if (n >= 1000) return trim(n / 1000) + 'k'
25 return String(n)
26}
27function trim(n) {
28 return n.toFixed(1).replace(/\.0$/, '')
29}
30function fmtClock(ms) {
31 const s = Math.max(0, Math.floor(ms / 1000))
32 const hh = Math.floor(s / 3600)
33 const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
34 const ss = String(s % 60).padStart(2, '0')
35 return hh > 0 ? hh + ':' + mm + ':' + ss : mm + ':' + ss
36}
37// Color del contexto según el nivel: verde, ámbar y rojo del tema
38function levelColor(pct) {
39 return pct > 85 ? 'error' : pct > 60 ? 'warning' : 'success'
40}
41
42// Lee las cifras de la sesión (las mismas de la barra de estado y /cost)
43async function refresh($) {
44 try {
45 usage = await $.session.usage()
46 } catch {
47 // sin lectura: el panel conserva la anterior
48 }
49 $.ui.invalidate('ui.render')
50}
51
52export function register(on, options) {
53 const BRAND = String(options?.marca || 'Alantia').toUpperCase()
54 const ACCENT = String(options?.acento || '#D97757')
55
56 // 1) Al iniciar: registra /consumo y arranca el reloj de la sesión
57 on('session.start', async ($, e, next) => {
58 await $.command.register({
59 name: 'consumo',
60 description: 'Abre el panel de consumo',
61 immediate: true, // se puede abrir mientras Claude trabaja
62 })
63 loadedAt = Date.now()
64 await refresh($)
65 // Redibuja cada segundo para que avance el reloj
66 $.clock.every(1000, () => $.ui.invalidate('ui.render'))
67 return next(e)
68 })
69
70 // 2) /consumo abre el panel
71 on('command.run', { command: 'consumo' }, async ($) => {
72 await refresh($)
73 await $.ui.open({ id: PANE, title: 'Consumo', closeOnEscape: true, columns: PANE_COLUMNS })
74 return {}
75 })
76
77 // 3) Cuenta herramientas (observa y deja pasar)
78 on('tool.call', async ($, e, next) => {
79 tools += 1
80 $.ui.invalidate('ui.render')
81 return next(e)
82 })
83
84 // 4) Nuevo pedido: reinicia el acumulador del turno
85 on('turn.start', async ($, e, next) => {
86 if (!e.agentId) turn = { fresh: 0, output: 0, cached: 0, calls: 0 }
87 return next(e)
88 })
89
90 // 5) Cada llamada al modelo: suma los tokens del pedido
91 on('turn.step', async function* ($, e, next) {
92 const result = yield* next(e)
93 const u = result.usage
94 if (u) {
95 // Entrada nueva: lo que el modelo lee por primera vez (se cachea o no).
96 // La caché leída es el contexto que se reenvía en cada llamada; se cuenta
97 // aparte porque sumarla infla el total: un pedido de 3 llamadas sobre un
98 // contexto de 70k "consumiría" 210k sin haber añadido casi nada.
99 turn.fresh += (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
100 turn.output += u.output_tokens ?? 0
101 turn.cached += u.cache_read_input_tokens ?? 0
102 turn.calls += 1
103 last = { ...turn }
104 await refresh($)
105 }
106 return result
107 })
108
109 // 6) Dibuja el panel
110 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
111 if (e.requestId !== PANE) return next(e)
112 const { Box, Text } = $.ui.resolve(e)
113
114 const ctx = usage?.context
115 const ctxTokens = ctx?.tokens ?? 0
116 const ctxWindow = ctx?.window ?? 0
117 const pct = ctx?.percent ?? (ctxWindow ? Math.min(100, Math.round((ctxTokens / ctxWindow) * 100)) : 0)
118 const level = levelColor(pct)
119 const cost = usage?.cost
120 const startedAt = usage?.startedAt ?? loadedAt
121
122 const barWidth = INNER - 4 // borde y relleno de la tarjeta de contexto
123 const filled = Math.min(barWidth, Math.round((pct / 100) * barWidth))
124 const blink = Math.floor(Date.now() / 1000) % 2 === 0
125
126 const label = (text) => Text({ dimColor: true, children: [text] })
127 const row = (key, children) =>
128 Box({ key, flexDirection: 'row', justifyContent: 'space-between', children })
129
130 // Cifra protagonista: etiqueta tenue arriba, valor en negrita, nota debajo
131 const stat = (key, title, value, note, color) =>
132 Box({
133 key,
134 flexDirection: 'column',
135 flexGrow: 1,
136 children: [label(title), Text({ bold: true, color, children: [value] }), label(note)],
137 })
138
139 return Box({
140 flexDirection: 'column',
141 gap: 1,
142 children: [
143 Box({
144 key: 'head',
145 flexDirection: 'column',
146 children: [
147 row('brand', [
148 Text({ bold: true, color: ACCENT, children: ['◆ ' + BRAND] }),
149 Box({
150 flexDirection: 'row',
151 gap: 1,
152 children: [Text({ color: 'success', dimColor: !blink, children: ['●'] }), label('en vivo')],
153 }),
154 ]),
155 label(' consumo de la sesión'),
156 Text({ color: ACCENT, dimColor: true, children: ['━'.repeat(INNER)] }),
157 ],
158 }),
159 Box({
160 key: 'stats',
161 flexDirection: 'row',
162 gap: 2,
163 children: [
164 stat('tok', 'tokens nuevos', fmtTokens(last.fresh + last.output), 'último pedido'),
165 stat('usd', 'gasto', cost ? '$' + cost.usd.toFixed(2) : '—', 'sesión', ACCENT),
166 ],
167 }),
168 Box({
169 key: 'split',
170 flexDirection: 'column',
171 children: [
172 row('io', [
173 label('↑ entrada ' + fmtTokens(last.fresh)),
174 label('↓ salida ' + fmtTokens(last.output)),
175 ]),
176 row('cache', [
177 label('⟳ caché releída ' + fmtTokens(last.cached)),
178 label(last.calls + (last.calls === 1 ? ' llamada' : ' llamadas')),
179 ]),
180 ],
181 }),
182 Box({
183 key: 'ctx',
184 flexDirection: 'column',
185 borderStyle: 'round',
186 borderDimColor: true,
187 paddingX: 1,
188 children: [
189 row('ctxHead', [label('contexto'), Text({ bold: true, color: level, children: [pct + '%'] })]),
190 Box({
191 flexDirection: 'row',
192 children: [
193 Text({ color: level, children: ['━'.repeat(filled)] }),
194 label('╌'.repeat(barWidth - filled)),
195 ],
196 }),
197 row('ctxFoot', [
198 label(fmtTokens(ctxTokens) + ' / ' + (ctxWindow ? fmtTokens(ctxWindow) : '—')),
199 label(ctxWindow ? fmtTokens(Math.max(0, ctxWindow - ctxTokens)) + ' libres' : ' '),
200 ]),
201 ],
202 }),
203 row('foot', [label('⚒ ' + tools + ' herramientas'), label('⏱ ' + fmtClock(Date.now() - startedAt))]),
204 ],
205 })
206 })
207}
208