Panel /consumo: tokens por pedido, uso del plan, contexto, herramientas y tiempo de sesión

Mods propios para Claude Code.
Panel con cinco datos de la sesión en vivo: tokens del último pedido y de la sesión, uso del plan (ventanas de 5 horas y semanal, cuándo se reinician, equivalente en API), contexto lleno, herramientas usadas y tiempo de sesión. Solo lee datos de la sesión y dibuja el panel: no usa red, archivos ni procesos.
Instalarlo:
/plugin marketplace add alejoaguilarg/claude-mods
/plugin install tablero-consumo@claude-mods
Usarlo: escribe /consumo para abrir el panel; otra vez /consumo o Esc lo cierra.
Desarrollarlo: claude plugin validate plugins/tablero-consumo y claude plugin test plugins/tablero-consumo.
hooks/register.tsx 183 lines1// Tablero de consumo: /consumo abre o cierra un panel con cinco datos en vivo.
2// 1. Tokens del último pedido y total de la sesión
3// 2. Uso del plan (cada ventana, cuándo se reinicia, equivalente en API)
4// 3. Contexto lleno
5// 4. Herramientas usadas
6// 5. Tiempo de sesión
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Stats } from '../types'
11
12const PANE = 'consumo'
13const EMPTY: Stats = { sessionTokens: 0, requests: 0, toolCalls: 0, last: null }
14const stats = atom({ plugin: 'tablero-consumo', key: 'stats' } as const, EMPTY)
15
16export const formatTokens = (n: number | undefined): string => {
17 if (n === undefined || !Number.isFinite(n)) return '0'
18 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
19 if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k'
20 return String(Math.round(n))
21}
22
23export const formatDuration = (ms: number): string => {
24 const total = Math.max(0, Math.floor(ms / 1000))
25 const h = Math.floor(total / 3600)
26 const m = Math.floor((total % 3600) / 60)
27 const s = total % 60
28 const pad = (v: number) => String(v).padStart(2, '0')
29 return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
30}
31
32export const formatReset = (resetsAt: string | undefined, now: number): string => {
33 if (!resetsAt) return ''
34 const target = Date.parse(resetsAt)
35 if (!Number.isFinite(target)) return ''
36 const minutes = Math.ceil((target - now) / 60_000)
37 if (minutes <= 0) return 'se reinicia ya'
38 if (minutes < 60) return `se reinicia en ${minutes} min`
39 const hours = Math.floor(minutes / 60)
40 if (hours < 24) return `se reinicia en ${hours} h ${minutes % 60} min`
41 return `se reinicia en ${Math.floor(hours / 24)} d ${hours % 24} h`
42}
43
44const limitLabel = (kind: string): string => {
45 if (kind === 'five_hour') return 'Ventana de 5 horas'
46 if (kind === 'seven_day') return 'Semanal'
47 if (kind === 'spend_limit') return 'Límite de gasto'
48 return kind
49}
50
51const bar = (percent: number, width: number): string => {
52 const p = Math.min(100, Math.max(0, percent))
53 const filled = Math.round((p / 100) * width)
54 return '█'.repeat(filled) + '░'.repeat(width - filled)
55}
56
57const colorFor = (percent: number) => (percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'green')
58
59const isOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE)
60
61export const register: Register = on => {
62 on('session.start', async ($, e, next) => {
63 await $.command.register({
64 name: 'consumo',
65 description: 'Abre o cierra el tablero de consumo de la sesión',
66 immediate: true,
67 })
68 // El reloj de la sesión avanza aunque no cambie nada más
69 $.clock.every(1000, () => $.ui.invalidate('ui.render'))
70 return next(e)
71 })
72
73 on('command.run', { command: 'consumo' }, async $ => {
74 if (await isOpen($)) {
75 await $.ui.close({ id: PANE })
76 return {}
77 }
78 const placed = await $.ui.open({ id: PANE, title: 'Consumo', closeOnEscape: true, columns: 44 })
79 if (!placed.isPlaced) $.ui.toast('El tablero espera una terminal más ancha')
80 return {}
81 })
82
83 on('tool.call', async ($, e, next) => {
84 await update($, stats, s => ({ ...s, toolCalls: s.toolCalls + 1 }))
85 return next(e)
86 })
87
88 on('turn.step', async function* ($, e, next) {
89 const result = yield* next(e)
90 const usage = result.usage
91 if (usage) {
92 const input = usage.input_tokens ?? 0
93 const output = usage.output_tokens ?? 0
94 const cacheRead = usage.cache_read_input_tokens ?? 0
95 const cacheWrite = usage.cache_creation_input_tokens ?? 0
96 await update($, stats, s => ({
97 sessionTokens: s.sessionTokens + input + output + cacheRead + cacheWrite,
98 requests: s.requests + 1,
99 toolCalls: s.toolCalls,
100 // El último pedido es el de la conversación principal, no el de un subagente
101 last: e.agentId ? s.last : { input, output, cacheRead, cacheWrite, model: usage.model },
102 }))
103 }
104 return result
105 })
106
107 on('session.measure', async ($, e, next) => {
108 $.ui.invalidate('ui.render')
109 return next(e)
110 })
111
112 on('session.end', async ($, e, next) => {
113 if (e.reason === 'clear') await update($, stats, () => EMPTY)
114 return next(e)
115 })
116
117 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
118 const { Box, Text } = $.ui.resolve(e)
119 const s = await read($, stats)
120 const usage = await $.session.usage()
121 const now = await $.clock.now()
122 const columns = Math.max(24, e.props.bodyColumns || 40)
123 const width = Math.max(10, Math.min(24, columns - 16))
124
125 const meter = (percent: number) => (
126 <Box flexDirection="row" columnGap={1}>
127 <Text color={colorFor(percent)}>{bar(percent, width)}</Text>
128 <Text>{`${Math.round(percent)}%`}</Text>
129 </Box>
130 )
131 const last = s.last
132 const usd = usage.cost?.usd
133 const context = usage.context
134
135 return (
136 <Box flexDirection="column">
137 <Text bold>1. Tokens</Text>
138 {last ? (
139 <Box flexDirection="column">
140 <Text>{`Último pedido ${formatTokens(last.input + last.output + last.cacheRead + last.cacheWrite)}`}</Text>
141 <Text dimColor>{`entrada ${formatTokens(last.input)} salida ${formatTokens(last.output)}`}</Text>
142 <Text dimColor>{`caché leída ${formatTokens(last.cacheRead)} escrita ${formatTokens(last.cacheWrite)}`}</Text>
143 </Box>
144 ) : (
145 <Text dimColor>Todavía no hay pedidos</Text>
146 )}
147 <Text>{`Sesión ${formatTokens(s.sessionTokens)} en ${s.requests} pedidos`}</Text>
148 <Text> </Text>
149
150 <Text bold>2. Uso del plan</Text>
151 {usage.rateLimits.length === 0 && <Text dimColor>Sin datos del plan en esta sesión</Text>}
152 {usage.rateLimits.map(limit => (
153 <Box flexDirection="column">
154 <Text>{limitLabel(limit.kind)}</Text>
155 {meter(limit.percentUsed)}
156 {limit.resetsAt && <Text dimColor>{formatReset(limit.resetsAt, now)}</Text>}
157 </Box>
158 ))}
159 {usd !== undefined && usd > 0 && <Text dimColor>{`Equivalente en API US$ ${usd.toFixed(2)}`}</Text>}
160 <Text> </Text>
161
162 <Text bold>3. Contexto lleno</Text>
163 {context.percent !== undefined ? (
164 <Box flexDirection="column">
165 {meter(context.percent)}
166 <Text dimColor>{`${formatTokens(context.tokens)} de ${formatTokens(context.window)} tokens`}</Text>
167 </Box>
168 ) : (
169 <Text dimColor>Sin datos de contexto todavía</Text>
170 )}
171 <Text> </Text>
172
173 <Text bold>4. Herramientas usadas</Text>
174 <Text>{`${s.toolCalls} llamadas`}</Text>
175 <Text> </Text>
176
177 <Text bold>5. Tiempo de sesión</Text>
178 <Text>{formatDuration(now - usage.startedAt)}</Text>
179 </Box>
180 )
181 })
182}
183types/index.d.ts 21 lines1export type LastRequest = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6 model: string
7}
8
9export type Stats = {
10 sessionTokens: number
11 requests: number
12 toolCalls: number
13 last: LastRequest | null
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'tablero-consumo': { stats: Stats }
19 }
20}
21