SLOPSHOPPER

rtk-gain

RTK token savings in the status line, and /rtk-gain draws the full rtk gain report as a card.

newrowsguardcommandstatusprocess
★ 1v0.1.0MITupdated 2026-10-07Fazzani/claude-mods/plugins/rtk-gain
A shopper browsing a rack in a slop shop
README

rtk-gain

Shows the tokens RTK has saved, inside Claude Code.

RTK: 10.2K tokens saved (23.0%, 66 cmds)

╭──────────────────────────────────────────────────────────╮
│ ⚡ RTK token savings                                     │
│                                                          │
│ 10.2K saved  ██████░░░░░░░░░░░░░░░░░░  23.0%             │
│ 66 commands · 44.3K in → 34.1K out · 8.2s (avg 124ms)    │
│                                                          │
│ Command                     Saved         Runs   Rate    │
│ rtk grep                    ████████ 8.4K ×19    31.9%   │
│ rtk read                    ░░░░░░░░ 426  ×8     6.1%    │
╰──────────────────────────────────────────────────────────╯

What it does

WhereBehaviour
Status lineTotals from rtk gain -f json, refreshed at session start and after each Bash command (RTK only counts shell commands)
/rtk-gainRuns rtk gain and draws it as a card: total saved with a gauge, totals, and the per-command table with bars scaled to the top command and rates colored green (≥ 50 %), yellow (≥ 20 %) or gray

The transcript keeps the raw rtk gain text, which is what the model reads. If the report's layout isn't recognised, the raw text is shown instead of the card.

Requirements

rtk on the PATH. Without it the status line stays empty and /rtk-gain reports the error.

Source 5 files
hooks/register.tsx 124 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { type Locale, LOCALES, pickLocale } from './i18n'
4import { bar, parseReport, parseSummary, rateColor, tokens } from './report'
5
6const COMMAND = 'rtk-gain'
7
8/** The plugin's option, Claude Code's `language` setting, then the process locale. */
9async function resolveLocale($: EngineInterface, option: unknown): Promise<Locale> {
10  const settings = await $.settings.read()
11
12  return pickLocale(
13    option,
14    settings.language,
15    await $.env.get('LC_ALL'),
16    await $.env.get('LC_MESSAGES'),
17    await $.env.get('LANG'),
18  )
19}
20
21/** rtk missing or failing must never break the session: the line is cleared instead. */
22async function refresh($: EngineInterface, locale: Locale) {
23  try {
24    const { exitCode, stdout } = await $.process.run(['rtk', 'gain', '-f', 'json'])
25    if (exitCode !== 0) {
26      $.ui.status(undefined)
27      return
28    }
29    const { saved, pct, commands } = parseSummary(stdout)
30    $.ui.status(LOCALES[locale].status(saved, pct, commands))
31  } catch {
32    $.ui.status(undefined)
33  }
34}
35
36export const register: Register = (on, options) => {
37  let locale: Locale = 'en'
38  const t = () => LOCALES[locale]
39
40  on('session.start', async ($, e, next) => {
41    locale = await resolveLocale($, options.language)
42    await $.command.register({ name: COMMAND, description: t().commandDescription })
43    void refresh($, locale)
44
45    return next(e)
46  })
47
48  // RTK records savings only through shell commands, so refresh after each one.
49  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
50    try {
51      return await next(e)
52    } finally {
53      void refresh($, locale)
54    }
55  })
56
57  // The row keeps rtk's raw text, which the model reads; ui.render draws it as a card.
58  on('command.run', { command: COMMAND }, async $ => {
59    try {
60      const { stdout, stderr } = await $.process.run(['rtk', 'gain'])
61
62      return { text: stdout || stderr }
63    } catch (err) {
64      return { text: t().failed(String(err)) }
65    }
66  })
67
68  on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, ($, e, next) => {
69    const report = e.props.isErrored ? undefined : parseReport(e.props.text)
70    if (!report) {
71      return next(e)
72    }
73
74    const m = t()
75    const { Box, Text } = $.ui.resolve(e)
76    const [gaugeFill, gaugeRest] = bar(report.pct / 100, 24)
77    const top = Math.max(1, ...report.rows.map(row => tokens(row.saved)))
78
79    return (
80      <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
81        <Box gap={1}>
82          <Text color="cyan" bold>⚡ RTK</Text>
83          <Text dimColor>{m.heading}</Text>
84        </Box>
85        <Box gap={2} marginTop={1}>
86          <Text color="green" bold>{m.saved(report.saved)}</Text>
87          <Box>
88            <Text color="green">{gaugeFill}</Text>
89            <Text dimColor>{gaugeRest}</Text>
90          </Box>
91          <Text color={rateColor(report.pct)} bold>{report.pct.toFixed(1)}%</Text>
92        </Box>
93        <Text dimColor>{m.details(report.commands, report.input, report.output, report.time, report.avg)}</Text>
94        {report.rows.length > 0 && (
95          <Box flexDirection="column" marginTop={1}>
96            <Box>
97              <Box width={28}><Text dimColor bold>{m.columns.command}</Text></Box>
98              <Box width={14}><Text dimColor bold>{m.columns.saved}</Text></Box>
99              <Box width={7}><Text dimColor bold>{m.columns.runs}</Text></Box>
100              <Box width={7}><Text dimColor bold>{m.columns.rate}</Text></Box>
101            </Box>
102            {report.rows.map(row => {
103              const [fill, rest] = bar(tokens(row.saved) / top, 8)
104
105              return (
106                <Box>
107                  <Box width={28}><Text wrap="truncate-end">{row.name}</Text></Box>
108                  <Box width={14}>
109                    <Text color="cyan">{fill}</Text>
110                    <Text dimColor>{rest}</Text>
111                    <Text> {row.saved}</Text>
112                  </Box>
113                  <Box width={7}><Text dimColor>×{row.count}</Text></Box>
114                  <Box width={7}><Text color={rateColor(row.pct)}>{row.pct.toFixed(1)}%</Text></Box>
115                </Box>
116              )
117            })}
118          </Box>
119        )}
120      </Box>
121    )
122  })
123}
124
hooks/i18n.ts 40 lines
1import { en } from '../locales/en'
2import { fr } from '../locales/fr'
3
4export type { Messages } from '../locales/en'
5
6export const LOCALES = { en, fr }
7
8export type Locale = keyof typeof LOCALES
9
10function match(value: unknown): Locale | null {
11  if (typeof value !== 'string') {
12    return null
13  }
14  const tag = value.trim().toLowerCase()
15  if (tag.startsWith('fr')) {
16    return 'fr'
17  }
18  if (tag.startsWith('en')) {
19    return 'en'
20  }
21
22  return null
23}
24
25/**
26 * The first candidate naming a supported locale, English otherwise. Callers pass,
27 * in order: the plugin's `language` option, Claude Code's `language` setting,
28 * then LC_ALL, LC_MESSAGES and LANG.
29 */
30export function pickLocale(...candidates: unknown[]): Locale {
31  for (const candidate of candidates) {
32    const locale = match(candidate)
33    if (locale) {
34      return locale
35    }
36  }
37
38  return 'en'
39}
40
hooks/report.ts 75 lines
1/** What the status line shows, read from `rtk gain -f json`. */
2export type Summary = { saved: string; pct: string; commands: number }
3
4type Row = { name: string; count: string; saved: string; pct: number }
5
6/** What the `/rtk-gain` card shows, read from the text report of `rtk gain`. */
7export type Report = {
8  commands: string
9  input: string
10  output: string
11  saved: string
12  pct: number
13  time: string
14  avg: string
15  rows: Row[]
16}
17
18type GainJson = { summary: { total_commands: number; total_saved: number; avg_savings_pct: number } }
19
20const compact = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n))
21
22export function parseSummary(json: string): Summary {
23  const { summary } = JSON.parse(json) as GainJson
24
25  return {
26    saved: compact(summary.total_saved),
27    pct: summary.avg_savings_pct.toFixed(1),
28    commands: summary.total_commands,
29  }
30}
31
32/**
33 * Parses the text report of `rtk gain`; the JSON format has no per-command
34 * table. Returns undefined when the layout is not recognised, so the caller
35 * can fall back to the raw text.
36 */
37export function parseReport(text: string): Report | undefined {
38  const pick = (re: RegExp) => text.match(re)?.slice(1)
39  const commands = pick(/Total commands:\s+(\S+)/)
40  const input = pick(/Input tokens:\s+(\S+)/)
41  const output = pick(/Output tokens:\s+(\S+)/)
42  const saved = pick(/Tokens saved:\s+(\S+)\s+\(([\d.]+)%\)/)
43  const time = pick(/Total exec time:\s+(\S+)\s+\(avg (\S+)\)/)
44  if (!commands || !input || !output || !saved || !time) {
45    return undefined
46  }
47
48  const rows = [...text.matchAll(/^[ \t]*\d+\.[ \t]+(.+?)[ \t]{2,}(\d+)[ \t]+(\S+)[ \t]+([\d.]+)%/gm)].map(
49    ([, name = '', count = '', tokens = '', pct = '0']) => ({ name, count, saved: tokens, pct: Number(pct) }),
50  )
51
52  return {
53    commands: commands[0] ?? '',
54    input: input[0] ?? '',
55    output: output[0] ?? '',
56    saved: saved[0] ?? '',
57    pct: Number(saved[1]),
58    time: time[0] ?? '',
59    avg: time[1] ?? '',
60    rows,
61  }
62}
63
64/** "8.4K" → 8400, so rows can be scaled against each other. */
65export const tokens = (s: string) => parseFloat(s) * (/M$/.test(s) ? 1e6 : /K$/.test(s) ? 1e3 : 1)
66
67/** A bar of `width` cells, split into its filled and empty parts. */
68export function bar(ratio: number, width: number): readonly [string, string] {
69  const full = Math.round(Math.min(1, Math.max(0, ratio)) * width)
70
71  return ['█'.repeat(full), '░'.repeat(width - full)]
72}
73
74export const rateColor = (pct: number) => (pct >= 50 ? 'green' : pct >= 20 ? 'yellow' : 'gray')
75
locales/en.ts 14 lines
1export const en = {
2  commandDescription: 'Show the RTK token savings report',
3  status: (saved: string, pct: string, commands: number) =>
4    `RTK: ${saved} tokens saved (${pct}%, ${commands} cmd${commands === 1 ? '' : 's'})`,
5  heading: 'token savings',
6  saved: (tokens: string) => `${tokens} saved`,
7  details: (commands: string, input: string, output: string, time: string, avg: string) =>
8    `${commands} commands · ${input} in → ${output} out · ${time} (avg ${avg})`,
9  columns: { command: 'Command', saved: 'Saved', runs: 'Runs', rate: 'Rate' },
10  failed: (reason: string) => `rtk gain failed: ${reason}`,
11}
12
13export type Messages = typeof en
14
locales/fr.ts 14 lines
1import type { Messages } from './en'
2
3export const fr: Messages = {
4  commandDescription: 'Afficher le rapport des tokens économisés par RTK',
5  status: (saved: string, pct: string, commands: number) =>
6    `RTK : ${saved} tokens économisés (${pct} %, ${commands} cmd)`,
7  heading: 'tokens économisés',
8  saved: (tokens: string) => `${tokens} économisés`,
9  details: (commands: string, input: string, output: string, time: string, avg: string) =>
10    `${commands} commandes · ${input} en entrée → ${output} en sortie · ${time} (moy. ${avg})`,
11  columns: { command: 'Commande', saved: 'Économisé', runs: 'Exéc.', rate: 'Taux' },
12  failed: (reason: string) => `échec de rtk gain : ${reason}`,
13}
14