SLOPSHOPPER

tablero-consumo

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

newpaneguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tablero-consumo
│ ┃ Consumo ✕ › fix the failing auth test and add an audit log call │ ┃ 1. Tokens │ ┃ Todavía no hay pedidos ⏺ Read(src/auth.ts) │ ┃ Sesión 0 en 0 pedidos ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 2. Uso del plan ⎿ Added 2 lines, removed 1 line │ ┃ Ventana de 5 horas ⏺ Bash(bun test) │ ┃ ███████░░░░░░░░░░░░░░░░░ 31% ⎿ 3 pass, 1 fail │ ┃ │ ┃ Equivalente en API US$ 0.42 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ 3. Contexto lleno ✻ Worked for 42s · done 4:20 PM │ ┃ ████████████░░░░░░░░░░░░ 49% │ ┃ 97.4k de 200.0k tokens › /consumo │ ┃ │ ┃ 4. Herramientas usadas │ ┃ 9 llamadas │ ┃ │ ┃ 5. Tiempo de sesión │ ┃ 30:00 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Consumo
1. Tokens Todavía no hay pedidos Sesión 0 en 0 pedidos 2. Uso del plan Ventana de 5 horas ███████░░░░░░░░░░░░░░░░░ 31% Equivalente en API US$ 0.42 3. Contexto lleno ████████████░░░░░░░░░░░░ 49% 97.4k de 200.0k tokens 4. Herramientas usadas 9 llamadas 5. Tiempo de sesión 30:00
README

claude-mods

Mods propios para Claude Code.

tablero-consumo

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.

Source 2 files
hooks/register.tsx 183 lines
1// 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}
183
types/index.d.ts 21 lines
1export 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