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

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% │
╰──────────────────────────────────────────────────────────╯
| Where | Behaviour |
|---|---|
| Status line | Totals from rtk gain -f json, refreshed at session start and after each Bash command (RTK only counts shell commands) |
/rtk-gain | Runs 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.
rtk on the PATH. Without it the status line stays empty and /rtk-gain reports the error.
hooks/register.tsx 124 lines1import 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}
124hooks/i18n.ts 40 lines1import { 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}
40hooks/report.ts 75 lines1/** 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')
75locales/en.ts 14 lines1export 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
14locales/fr.ts 14 lines1import 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