SLOPSHOPPER

rtk-gain

RTK's token savings, live in Claude Code: animated bars for command savings and the context breakdown, with rtk proxy support

panebandspinnerguardcommandprocess
★ 3v0.5.0Apache-2.0updated 2026-10-09rtk-ai/rtk-gain-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rtk-gain
│ ┃ rtk ✕ › fix the failing auth test and add an audit log call │ ┃ rtk · session hook only │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▀▄ ▄▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ▀▀▀▀▀▀▀▀▀▀ │ ┃ ▄▀▀▀▀▀▀▀▀▄▄ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ✻ Worked for 42s · done 4:20 PM │ ┃ COMMANDS 0% │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ › /rtk-gain │ ┃ ░░░░░░░░░░░░ │ ┃ 0 planned → 0 used │ ┃ │ ┃ CONTEXT 97.4k / 200k │ ┃ ██████████████████████████████████████████ │ ┃ ████████████ │ ┃ ■ other 97.4k │ ┃ │ ┃ last commands │ ┃ waiting for a command… │ ┃ │ ┃ estimated tokens (bytes / 4) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · rtk
rtk · session hook only ▀▄ ▄▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▄▀▀▀▀▀▀▀▀▄▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ COMMANDS 0% ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 planned → 0 used CONTEXT 97.4k / 200k ██████████████████████████████████████████████████████ ■ other 97.4k last commands waiting for a command… estimated tokens (bytes / 4)
README

rtk-gain: RTK's token savings, live in Claude Code

A Claude Code mod that shows what RTK saves while Claude works: one bar for the commands (planned, used, saved), one for the context window broken down by category, and a viking who breaks the saved tokens with a different animation each time.

Français

A viking strikes three commands in a real session: bow, raven, lightning

Tested with Claude Code 2.1.290 in the terminal. Mods need Claude Code 2.1.287 or later in the terminal, 2.1.286 in the Desktop app.

Quick start

With rtk and its hook installed (rtk init -g), in Claude Code:

/plugin marketplace add rtk-ai/rtk-gain-mod
/plugin install rtk-gain@rtk
/rtk-gain

The last command opens the pane with the viking; it does not open on its own. Details and other ways to install in Install.

What you see

Three places in Claude Code:

  • The pane, opened with /rtk-gain: the viking, both bars with their legends, and the last commands with each one's own saving. /rtk-gain again, or Esc, closes it.
  • The band above the prompt: one line, always visible once a command has gone through rtk.
  • The spinner: the running saving at the end of the line while Claude works, and a line under each answer with the turn's saving.

Reading the bars:

  • Commands: the whole bar is what was planned (what the commands would have returned without RTK). Blue is what was used, green is what was saved.
  • Context: what fills the context window, by category (the four largest, the rest folded into "other"), as Claude Code counts it for /context. The hatched part extends the bar by what RTK kept out: "without RTK", the context would be the sum of the two.

End of a turn: 34.8k planned, 8.5k used

In that turn, RTK kept 26.2k tokens out of the context: 79.7k instead of 106k.

The viking

At the top of the pane, a pixel-art viking breaks the tokens: two pixels per terminal cell (the ▀ character, its color for the top pixel, its background for the bottom one).

While a command runs he waits, weapon raised, in front of a grey block. When it returns, he strikes the command's block at the cut between what was kept (blue) and what RTK saved (green), and the saved part is destroyed. The command's name and its saving show above.

There are fifty animations, ten attacks times five ways the saved part goes:

| Attacks | Effects | | :- | :- | | axe, hammer, sword, spear, torch, twin axes, thrown axe, bow, lightning, raven | shards, explosion, melt, dust, swept |

One is drawn at random for each command, never the same attack or effect twice in a row. A big saving leans toward the loud effects (shards, explosion, swept), a small one toward the quiet ones (melt, dust); all fifty stay possible.

The viking shows in the terminal only: text in the Desktop app need not be monospaced, and the drawing would come apart there.

Screenshots

All taken in a real Claude Code 2.1.290 session, on a clone of the rtk repository, with the RTK hook installed. Claude ran git log -30, git diff HEAD~10, git show HEAD~3, git log --stat -8 and git status.

| | | | :- | :- | | The pane when it opens | git diff running | | 1. /rtk-gain when it opens: the viking at rest, the starting context, no command yet. | 2. Claude runs git diff: the viking readies his attack, the command shows as running. | | The strike | After the commands | | 3. git diff returns: the strike lands on the cut and the bars move toward their new values. | 4. After the commands: git diff 31.8k → 6.7k (−79%), git log 2.9k → 1.8k (−38%). |

Install

You need Claude Code 2.1.287 or later, rtk in your PATH, and the RTK hook installed (rtk init -g): without the hook no command goes through RTK, and the pane says so.

  1. Install the mod from the marketplace in this repository:
   /plugin marketplace add rtk-ai/rtk-gain-mod
   /plugin install rtk-gain@rtk

Or, for one session only, without installing:

   git clone https://github.com/rtk-ai/rtk-gain-mod.git
   claude --plugin-dir ./rtk-gain-mod

/plugin shows 1 mod active · rtk-gain when the mod is loaded. Right away you get the band above the prompt and the saving in the spinner.

  1. Open the pane, with the viking and both bars:
   /rtk-gain

It does not open on its own: without this step you only see the band. /rtk-gain again, or Esc, closes it.

  1. Ask Claude for a few commands (git log, git diff, cargo test) and watch the viking strike each one.

A mod is code that runs with your permissions. This one reads rtk gain, Claude Code's own context figures and, when present, the RTK proxy's database; claude plugin validate lists every call it makes before you install it.

Settings

All by environment variable:

| Variable | Effect | | :- | :- | | RTK_GAIN_LANG=<code> | Forces the language: en, zh, hi, es, ar, fr, bn or pt | | RTK_GAIN_VIKING=0 | Removes the viking from the pane | | RTK_GAIN_ANIMATION=<attack>:<effect> | Pins an attack, an effect or both, such as hammer:melt, raven or :dust | | RTK_PROXY_DB=<path> | The RTK proxy's database, when it is not in the usual place |

Languages

The mod speaks the eight most spoken languages in the world (Ethnologue, total speakers): English, Mandarin Chinese (simplified), Hindi, Spanish, Arabic, French, Bengali and Portuguese.

The language is detected. The first source that names one of the eight wins:

  1. RTK_GAIN_LANG (fr, pt_BR, spanish…)
  2. Claude Code's language setting ("language": "french" in settings.json)
  3. LC_ALL, LC_MESSAGES, then LANG (C.UTF-8 and POSIX don't count)
  4. on macOS, the system language, often the only clue when LANG is C.UTF-8
  5. otherwise English

Every text goes through hooks/lib/i18n.js, including the categories /context names in English. Numbers follow the language (48.2k, 48,2 k in French, 48,2k in Spanish and Portuguese), in Western digits everywhere.

Good to know:

  • The translations were not written by native speakers: corrections are welcome, especially for Hindi, Arabic and Bengali.
  • Arabic: a terminal without bidirectional text support draws the letters left to right.
  • Hindi and Bengali: combining marks count for zero columns; depending on the terminal font, the numbers column can shift by a character.

How it works

A mod is a JavaScript plugin that runs inside Claude Code (official documentation). This one is a few files:

.claude-plugin/plugin.json        manifest
.claude-plugin/marketplace.json   lets people install it with /plugin install
hooks/hooks.json                  points to register.js
hooks/register.js                 the hooks and the calls to Claude Code
hooks/lib/                        the model, the drawing, the viking, the translations

Where the numbers come from:

  1. Claude runs a command. The mod sees it go by (tool.call on Bash), picks the viking's attack and shows the command as running.
  2. The RTK hook rewrites it (git diff becomes rtk git diff) and RTK records what the command would have returned and what it returned.
  3. When it returns, the mod reads rtk gain --format json --project again. The difference with the previous reading is the command's saving; the difference with the session's first reading is the session's saving.
  4. The mod animates the bars toward the new totals (12 frames, 0.4 s) while the viking strikes (24 frames, 0.8 s).

The mod cannot measure by itself: settings hooks, RTK's included, run after mods, so it sees a command before it is rewritten and only its filtered output afterwards. The numbers come from RTK.

The context comes from Claude Code ($.session.usage({ breakdown: 'summary' }), the same breakdown as /context, estimated locally with no request).

With the RTK proxy

When RTK's LLM proxy is running and has written to its database since the session started, the pane adds a "PROXY" section: what the proxy removed from the requests, by stage, and the breakdown of the latest request (tool results, tool call arguments, the rest, duplicates). The pane then reads "hook + proxy".

The mod reads the proxy's database read-only with sqlite3. It looks for ~/Library/Application Support/rtk/llm-proxy/rtk-proxy.db (macOS), then ~/.config/rtk/llm-proxy/rtk-proxy.db.

Tests

claude plugin validate .   # what Claude Code reads from the mod: hooks and calls
claude plugin test         # 53 tests, no session and no network

The tests stand in for Claude Code: rtk gain output, the context breakdown, proxy rows, the clock. They cover the band, the pane (terminal and Desktop), the bars frame by frame, the line under an answer, a missing rtk, the proxy section, the eight languages, and the viking: each of the fifty animations is played through, and the random draw is checked to reach every one.

Known limits

  • Two sessions on one project mix their numbers: rtk gain --project counts by directory, not by session.
  • Estimated tokens: RTK counts bytes / 4, with no tokenizer. Percentages are reliable, absolute values approximate; the pane says so.
  • Where it draws: the terminal and the Desktop app. Nothing in the VS Code extension's panel or in a cloud session; /rtk-gain then answers with one line of text.
  • A command that chains several (git log && git status) shows as one line, with the number of RTK commands: git log +1.
  • Desktop: drawn with the same elements as the terminal and covered by the tests, not yet looked at in the app.

License

Apache-2.0, like RTK. See LICENSE.

Source 6 files
hooks/register.js 246 lines
1// rtk-gain: RTK's token savings, live in Claude Code.
2//
3// tool.call (Bash): mark the command as running; when it returns, re-read
4//   `rtk gain` and animate the bars toward the new totals.
5// turn.start / turn.complete: per-turn totals, shown as a line under the answer.
6// ui.render: a pane (/rtk-gain), one line above the prompt, the spinner's suffix.
7//
8// Claude Code reads on(...) and $.namespace.method(...) from this source, so
9// both are spelled out here; everything that needs no `$` lives in ./lib.
10
11import { shortCommand } from './lib/format.js'
12import { detectLanguage, languageOf, messages } from './lib/i18n.js'
13import {
14  createState,
15  parseProxy,
16  parseSummary,
17  prepareStrike,
18  proxyQuery,
19  readUsage,
20  recordSummary,
21  setContext,
22  startTurn,
23  step,
24} from './lib/model.js'
25import { bandTree, paneTree, spinnerSuffix, summaryLine, turnLine } from './lib/view.js'
26import { parseAnimation } from './lib/viking.js'
27
28const PANE = 'rtk-gain'
29const FRAME_MS = 33
30const READ_TIMEOUT_MS = 3000
31
32let state = createState()
33let ticker = null
34let proxyDb = null
35// Reads run one after the other, so two commands that end together can't
36// record their totals out of order
37let reads = Promise.resolve()
38
39export function register(on) {
40  on('session.start', async ($, e, next) => {
41    const result = await next(e)
42    await setup($)
43    return result
44  })
45
46  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
47    const name = shortCommand(e.command)
48    prepareStrike(state)
49    state.running = name
50    $.ui.invalidate('ui.render')
51    try {
52      return await next(e)
53    } finally {
54      state.running = null
55      // Off the tool call's path: Claude gets the result without waiting for rtk
56      $.clock.after(0, () => enqueue($, name))
57    }
58  })
59
60  on('turn.start', async ($, e, next) => {
61    startTurn(state)
62    return next(e)
63  })
64
65  on('turn.complete', async ($, e, next) => {
66    const result = await next(e)
67    // A subagent's turn ends inside the main one, which reports for both
68    if (e.agentId) return result
69    await enqueue($, null)
70    const line = turnLine(state)
71    if (!line) return result
72    // Core answers with the answer itself; any other text is shown beneath it.
73    // Keep a line another mod added, and put ours after it.
74    const other = result?.text && result.text !== e.answer ? result.text + '\n' : ''
75    return { ...result, text: other + line }
76  })
77
78  on('command.run', { command: 'rtk-gain' }, async ($) => {
79    if (state.paneOpen) {
80      state.paneOpen = false
81      await $.ui.close({ id: PANE })
82      return {}
83    }
84    await enqueue($, null)
85    // Nothing draws in a plain `claude -p` run, where an opened pane still
86    // counts as placed: answer in text there
87    const surfaces = await $.session.surfaces()
88    if (surfaces.length === 0) return { text: summaryLine(state) }
89    const pane = await $.ui.open({ id: PANE, title: messages(state.lang).paneTitle, closeOnEscape: true })
90    state.paneOpen = pane?.isPlaced === true
91    return state.paneOpen ? {} : { text: summaryLine(state) }
92  })
93
94  on('ui.close', async ($, e, next) => {
95    if (e.id === PANE) state.paneOpen = false
96    return next(e)
97  })
98
99  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
100    if (e.requestId !== PANE) return next(e)
101    return paneTree($.ui.resolve(e), state, e.props?.bodyColumns ?? 40, e.surface)
102  })
103
104  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
105    if (e.props?.hasSurvey) return next(e)
106    const band = bandTree($.ui.resolve(e), state, e.props?.bodyColumns ?? 80)
107    return band ?? next(e)
108  })
109
110  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
111    const suffix = spinnerSuffix(state)
112    if (!suffix) return next(e)
113    return next({ ...e, props: { ...e.props, suffix: (e.props?.suffix ?? '') + suffix } })
114  })
115}
116
117async function setup($) {
118  if (ticker) ticker.cancel()
119  ticker = null
120  state = createState()
121  try {
122    state.lang = await readLanguage($)
123    state.showViking = (await $.env.get('RTK_GAIN_VIKING')) !== '0'
124    state.fixed = parseAnimation(await $.env.get('RTK_GAIN_ANIMATION'))
125    state.startedAt = await $.clock.now()
126    await $.command.register({
127      name: 'rtk-gain',
128      description: messages(state.lang).commandDescription,
129      immediate: true,
130    })
131    proxyDb = await findProxyDb($)
132  } catch {
133    // A missing answer here leaves the defaults in place
134  }
135  await enqueue($, null)
136}
137
138// RTK_GAIN_LANG, then Claude Code's `language` setting, then the locale, then
139// the macOS system language (LANG is often C.UTF-8 there), then English
140async function readLanguage($) {
141  let setting = null
142  try {
143    const settings = await $.settings.read()
144    setting = settings.language
145  } catch {
146    // No settings: the next sources decide
147  }
148  const sources = {
149    explicit: await $.env.get('RTK_GAIN_LANG'),
150    setting,
151    lcAll: await $.env.get('LC_ALL'),
152    lcMessages: await $.env.get('LC_MESSAGES'),
153    lang: await $.env.get('LANG'),
154  }
155  if (!Object.values(sources).some(languageOf)) {
156    sources.system = await readSystemLocale($)
157  }
158  return detectLanguage(sources)
159}
160
161async function readSystemLocale($) {
162  try {
163    const run = await $.process.run(['defaults', 'read', '-g', 'AppleLocale'], { timeoutMs: 1000 })
164    return run.exitCode === 0 ? run.stdout.trim() : null
165  } catch {
166    // Not macOS
167    return null
168  }
169}
170
171function enqueue($, name) {
172  reads = reads.then(() => refresh($, name)).catch(() => {})
173  return reads
174}
175
176async function refresh($, name) {
177  const summary = await readGain($)
178  if (summary && recordSummary(state, summary, name)) animate($)
179  setContext(state, await readContext($))
180  state.proxy = await readProxy($)
181  $.ui.invalidate('ui.render')
182}
183
184// The session's totals come from rtk itself: the mod sees a command before the
185// rtk hook rewrites it, and only its filtered output afterwards.
186async function readGain($) {
187  try {
188    const run = await $.process.run(['rtk', 'gain', '--format', 'json', '--project'], { timeoutMs: READ_TIMEOUT_MS })
189    return run.exitCode === 0 ? parseSummary(run.stdout) : null
190  } catch {
191    state.rtk = 'missing'
192    return null
193  }
194}
195
196async function readContext($) {
197  try {
198    return readUsage(await $.session.usage({ breakdown: 'summary' }))
199  } catch {
200    return null
201  }
202}
203
204// The rtk proxy keeps one row per request in SQLite. Until the daemon exposes
205// a per-session endpoint, read the rows written since this session started.
206async function readProxy($) {
207  if (!proxyDb) return null
208  try {
209    const run = await $.process.run(
210      ['sqlite3', '-json', 'file:' + proxyDb.replace(/[%?#]/g, encodeURIComponent) + '?mode=ro', proxyQuery(state.startedAt)],
211      { timeoutMs: READ_TIMEOUT_MS },
212    )
213    return run.exitCode === 0 ? parseProxy(run.stdout) : null
214  } catch {
215    return null
216  }
217}
218
219async function findProxyDb($) {
220  const chosen = await $.env.get('RTK_PROXY_DB')
221  if (chosen) return chosen
222  const home = await $.env.get('HOME')
223  const config = await $.env.get('XDG_CONFIG_HOME')
224  const candidates = [
225    home && home + '/Library/Application Support/rtk/llm-proxy/rtk-proxy.db',
226    config && config + '/rtk/llm-proxy/rtk-proxy.db',
227    home && home + '/.config/rtk/llm-proxy/rtk-proxy.db',
228  ]
229  for (const path of candidates) {
230    if (path && (await $.fs.exists(path))) return path
231  }
232  return null
233}
234
235function animate($) {
236  if (ticker) return
237  ticker = $.clock.every(FRAME_MS, () => {
238    const more = step(state)
239    $.ui.invalidate('ui.render')
240    if (!more && ticker) {
241      ticker.cancel()
242      ticker = null
243    }
244  })
245}
246
hooks/lib/format.js 45 lines
1// Number and command formatting. Pure: no mods API in this file.
2
3import { NUMBER_STYLE } from './i18n.js'
4
5const MINUS = '−'
6
7function style(lang) {
8  return NUMBER_STYLE[lang] ?? NUMBER_STYLE.en
9}
10
11// 310 -> "310", 48200 -> "48.2k" (en) or "48,2 k" (fr), 1240000 -> "1.24M"
12export function tokens(n, lang = 'en') {
13  const value = Math.max(0, Math.round(n))
14  const { decimal, unitSpace } = style(lang)
15  if (value < 1000) return String(value)
16  if (value < 1_000_000) {
17    // Past 100k the decimal is noise: "200k", not "200.0k"
18    const text = (value / 1000).toFixed(value < 100_000 ? 1 : 0)
19    return text.replace('.', decimal) + unitSpace + 'k'
20  }
21  const text = (value / 1_000_000).toFixed(2)
22  return text.replace('.', decimal) + unitSpace + 'M'
23}
24
25// Share of `input` that was not sent: 48200, 9100 -> "−81%" (en), "−81 %" (fr)
26export function percent(input, output, lang = 'en') {
27  const unit = style(lang).percentSpace + '%'
28  if (!(input > 0)) return '0' + unit
29  const saved = Math.round(((input - output) / input) * 100)
30  return (saved > 0 ? MINUS : '') + saved + unit
31}
32
33// "RUSTFLAGS=-D cargo test --all 2>&1 | tail" -> "cargo test"
34export function shortCommand(command) {
35  if (typeof command !== 'string') return ''
36  const first = command.split(/&&|\|\||[|;\n]/)[0].trim()
37  const words = first.split(/\s+/).filter(Boolean)
38  while (words.length > 1 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift()
39  if (words[0] === 'rtk' && words.length > 1) words.shift()
40  const name = (words[0] ?? '').split('/').pop()
41  const sub = words[1] && /^[a-z][a-z0-9:_-]*$/i.test(words[1]) ? ' ' + words[1] : ''
42  const text = name + sub
43  return text.length > 18 ? text.slice(0, 17) + '…' : text
44}
45
hooks/lib/i18n.js 378 lines
1// Translations and language detection. Pure: no mods API in this file.
2//
3// The eight most spoken languages (Ethnologue 2026, total speakers): English,
4// Mandarin Chinese, Hindi, Spanish, Arabic, French, Bengali, Portuguese.
5// Placeholders in braces are filled by `fill`; every language has every key.
6
7export const LANGUAGES = ['en', 'zh', 'hi', 'es', 'ar', 'fr', 'bn', 'pt']
8
9// How each language writes 48.2k and −81 %. Western digits everywhere: they
10// are what terminals and the rest of Claude Code print.
11export const NUMBER_STYLE = {
12  en: { decimal: '.', unitSpace: '', percentSpace: '' },
13  zh: { decimal: '.', unitSpace: '', percentSpace: '' },
14  hi: { decimal: '.', unitSpace: '', percentSpace: '' },
15  es: { decimal: ',', unitSpace: '', percentSpace: ' ' },
16  ar: { decimal: '.', unitSpace: '', percentSpace: '' },
17  fr: { decimal: ',', unitSpace: ' ', percentSpace: ' ' },
18  bn: { decimal: '.', unitSpace: '', percentSpace: '' },
19  pt: { decimal: ',', unitSpace: '', percentSpace: '' },
20}
21
22export const MESSAGES = {
23  en: {
24    title: 'rtk · session',
25    hookOnly: 'hook only',
26    hookProxy: 'hook + proxy',
27    proxyOnly: 'proxy only',
28    commands: 'COMMANDS',
29    context: 'CONTEXT',
30    proxy: 'PROXY',
31    plannedUsed: '{planned} planned → {used} used',
32    avoided: 'avoided by RTK',
33    withoutRtk: 'without RTK: {n}',
34    bandContext: 'context {n} (without RTK {m})',
35    lastCommands: 'last commands',
36    running: 'running…',
37    waiting: 'waiting for a command…',
38    estimated: 'estimated tokens (bytes / 4)',
39    other: 'other',
40    toolResults: 'tool results',
41    toolArgs: 'tool call arguments',
42    rest: 'rest',
43    duplicates: 'of which duplicates: {n}',
44    removed: '{n} removed',
45    turnLine: 'rtk: {planned} planned → {used} used ({pct}) this turn',
46    summary: '{planned} planned → {used} used ({pct})',
47    noRtk: 'rtk not found in PATH',
48    noData: 'no command went through rtk in this session',
49    paneTitle: 'rtk',
50    commandDescription: 'Open or close the RTK savings pane',
51    stages: { ccr: 'CCR', filter: 'filter', pixels: 'images' },
52    categories: {},
53  },
54  zh: {
55    title: 'rtk · 会话',
56    hookOnly: '仅 hook',
57    hookProxy: 'hook + 代理',
58    proxyOnly: '仅代理',
59    commands: '命令',
60    context: '上下文',
61    proxy: '代理',
62    plannedUsed: '预计 {planned} → 实际 {used}',
63    avoided: 'RTK 省去',
64    withoutRtk: '不用 RTK:{n}',
65    bandContext: '上下文 {n}(不用 RTK {m})',
66    lastCommands: '最近的命令',
67    running: '运行中…',
68    waiting: '等待命令…',
69    estimated: '估算的 token(字节 / 4)',
70    other: '其他',
71    toolResults: '工具输出',
72    toolArgs: '工具调用参数',
73    rest: '其余',
74    duplicates: '其中重复:{n}',
75    removed: '已移除 {n}',
76    turnLine: 'rtk:本轮预计 {planned} → 实际 {used}({pct})',
77    summary: '预计 {planned} → 实际 {used}({pct})',
78    noRtk: 'PATH 中找不到 rtk',
79    noData: '本会话中还没有命令经过 rtk',
80    paneTitle: 'rtk',
81    commandDescription: '打开或关闭 RTK 节省面板',
82    stages: { ccr: 'CCR', filter: '过滤', pixels: '图像' },
83    categories: {
84      'System prompt': '系统提示词',
85      'System tools': '系统工具',
86      'MCP tools': 'MCP 工具',
87      'Custom agents': '自定义代理',
88      'Memory files': '记忆文件',
89      Skills: '技能',
90      Messages: '消息',
91    },
92  },
93  hi: {
94    title: 'rtk · सत्र',
95    hookOnly: 'केवल hook',
96    hookProxy: 'hook + प्रॉक्सी',
97    proxyOnly: 'केवल प्रॉक्सी',
98    commands: 'कमांड',
99    context: 'संदर्भ',
100    proxy: 'प्रॉक्सी',
101    plannedUsed: '{planned} अनुमानित → {used} उपयोग',
102    avoided: 'RTK द्वारा बचाया',
103    withoutRtk: 'RTK के बिना: {n}',
104    bandContext: 'संदर्भ {n} (RTK के बिना {m})',
105    lastCommands: 'हाल की कमांड',
106    running: 'चल रही है…',
107    waiting: 'कमांड की प्रतीक्षा…',
108    estimated: 'अनुमानित टोकन (बाइट / 4)',
109    other: 'अन्य',
110    toolResults: 'टूल आउटपुट',
111    toolArgs: 'टूल कॉल आर्ग्युमेंट',
112    rest: 'शेष',
113    duplicates: 'इनमें दोहराव: {n}',
114    removed: '{n} हटाए गए',
115    turnLine: 'rtk: इस बारी में {planned} अनुमानित → {used} उपयोग ({pct})',
116    summary: '{planned} अनुमानित → {used} उपयोग ({pct})',
117    noRtk: 'PATH में rtk नहीं मिला',
118    noData: 'इस सत्र में अभी कोई कमांड rtk से नहीं गुज़री',
119    paneTitle: 'rtk',
120    commandDescription: 'RTK बचत पैनल खोलें या बंद करें',
121    stages: { ccr: 'CCR', filter: 'फ़िल्टर', pixels: 'चित्र' },
122    categories: {
123      'System prompt': 'सिस्टम प्रॉम्प्ट',
124      'System tools': 'सिस्टम टूल',
125      'MCP tools': 'MCP टूल',
126      'Custom agents': 'कस्टम एजेंट',
127      'Memory files': 'मेमोरी फ़ाइलें',
128      Skills: 'स्किल',
129      Messages: 'संदेश',
130    },
131  },
132  es: {
133    title: 'rtk · sesión',
134    hookOnly: 'solo hook',
135    hookProxy: 'hook + proxy',
136    proxyOnly: 'solo proxy',
137    commands: 'COMANDOS',
138    context: 'CONTEXTO',
139    proxy: 'PROXY',
140    plannedUsed: '{planned} previstos → {used} usados',
141    avoided: 'evitado por RTK',
142    withoutRtk: 'sin RTK: {n}',
143    bandContext: 'contexto {n} (sin RTK {m})',
144    lastCommands: 'últimos comandos',
145    running: 'en curso…',
146    waiting: 'esperando un comando…',
147    estimated: 'tokens estimados (bytes / 4)',
148    other: 'otros',
149    toolResults: 'resultados de herramientas',
150    toolArgs: 'argumentos de llamadas',
151    rest: 'resto',
152    duplicates: 'de los cuales duplicados: {n}',
153    removed: '{n} eliminados',
154    turnLine: 'rtk: {planned} previstos → {used} usados ({pct}) en este turno',
155    summary: '{planned} previstos → {used} usados ({pct})',
156    noRtk: 'rtk no se encuentra en el PATH',
157    noData: 'ningún comando ha pasado por rtk en esta sesión',
158    paneTitle: 'rtk',
159    commandDescription: 'Abre o cierra el panel de ahorro de RTK',
160    stages: { ccr: 'CCR', filter: 'filtro', pixels: 'imágenes' },
161    categories: {
162      'System prompt': 'prompt del sistema',
163      'System tools': 'herramientas del sistema',
164      'MCP tools': 'herramientas MCP',
165      'Custom agents': 'agentes personalizados',
166      'Memory files': 'archivos de memoria',
167      Skills: 'skills',
168      Messages: 'mensajes',
169    },
170  },
171  ar: {
172    title: 'rtk · الجلسة',
173    hookOnly: 'hook فقط',
174    hookProxy: 'hook + الوكيل',
175    proxyOnly: 'الوكيل فقط',
176    commands: 'الأوامر',
177    context: 'السياق',
178    proxy: 'الوكيل',
179    plannedUsed: '{planned} متوقع → {used} مستخدم',
180    avoided: 'وفّره RTK',
181    withoutRtk: 'بدون RTK: {n}',
182    bandContext: 'السياق {n} (بدون RTK {m})',
183    lastCommands: 'آخر الأوامر',
184    running: 'قيد التشغيل…',
185    waiting: 'في انتظار أمر…',
186    estimated: 'رموز تقديرية (بايت / 4)',
187    other: 'أخرى',
188    toolResults: 'مخرجات الأدوات',
189    toolArgs: 'وسائط الاستدعاءات',
190    rest: 'الباقي',
191    duplicates: 'منها مكرر: {n}',
192    removed: 'أُزيل {n}',
193    turnLine: 'rtk: في هذه الجولة {planned} متوقع → {used} مستخدم ({pct})',
194    summary: '{planned} متوقع → {used} مستخدم ({pct})',
195    noRtk: 'لم يُعثر على rtk في PATH',
196    noData: 'لم يمر أي أمر عبر rtk في هذه الجلسة',
197    paneTitle: 'rtk',
198    commandDescription: 'فتح لوحة توفير RTK أو إغلاقها',
199    stages: { ccr: 'CCR', filter: 'التصفية', pixels: 'الصور' },
200    categories: {
201      'System prompt': 'موجّه النظام',
202      'System tools': 'أدوات النظام',
203      'MCP tools': 'أدوات MCP',
204      'Custom agents': 'وكلاء مخصصون',
205      'Memory files': 'ملفات الذاكرة',
206      Skills: 'المهارات',
207      Messages: 'الرسائل',
208    },
209  },
210  fr: {
211    title: 'rtk · session',
212    hookOnly: 'hook seul',
213    hookProxy: 'hook + proxy',
214    proxyOnly: 'proxy seul',
215    commands: 'COMMANDES',
216    context: 'CONTEXTE',
217    proxy: 'PROXY',
218    plannedUsed: '{planned} prévus → {used} utilisés',
219    avoided: 'évité par RTK',
220    withoutRtk: 'sans RTK : {n}',
221    bandContext: 'contexte {n} (sans RTK {m})',
222    lastCommands: 'dernières commandes',
223    running: 'en cours…',
224    waiting: 'en attente d’une commande…',
225    estimated: 'tokens estimés (octets / 4)',
226    other: 'autres',
227    toolResults: 'sorties d’outils',
228    toolArgs: 'arguments d’appels',
229    rest: 'reste',
230    duplicates: 'dont doublons : {n}',
231    removed: '{n} retiré',
232    turnLine: 'rtk : {planned} prévus → {used} utilisés ({pct}) sur ce tour',
233    summary: '{planned} prévus → {used} utilisés ({pct})',
234    noRtk: 'rtk introuvable dans le PATH',
235    noData: 'aucune commande passée par rtk dans cette session',
236    paneTitle: 'rtk',
237    commandDescription: 'Ouvre ou ferme le panneau des gains RTK',
238    stages: { ccr: 'CCR', filter: 'filtre', pixels: 'images' },
239    categories: {
240      'System prompt': 'prompt système',
241      'System tools': 'outils système',
242      'MCP tools': 'outils MCP',
243      'Custom agents': 'agents personnalisés',
244      'Memory files': 'fichiers mémoire',
245      Skills: 'skills',
246      Messages: 'messages',
247    },
248  },
249  bn: {
250    title: 'rtk · সেশন',
251    hookOnly: 'শুধু hook',
252    hookProxy: 'hook + প্রক্সি',
253    proxyOnly: 'শুধু প্রক্সি',
254    commands: 'কমান্ড',
255    context: 'কনটেক্সট',
256    proxy: 'প্রক্সি',
257    plannedUsed: '{planned} প্রত্যাশিত → {used} ব্যবহৃত',
258    avoided: 'RTK যা বাঁচিয়েছে',
259    withoutRtk: 'RTK ছাড়া: {n}',
260    bandContext: 'কনটেক্সট {n} (RTK ছাড়া {m})',
261    lastCommands: 'সাম্প্রতিক কমান্ড',
262    running: 'চলছে…',
263    waiting: 'কমান্ডের অপেক্ষায়…',
264    estimated: 'আনুমানিক টোকেন (বাইট / 4)',
265    other: 'অন্যান্য',
266    toolResults: 'টুলের আউটপুট',
267    toolArgs: 'টুল কলের আর্গুমেন্ট',
268    rest: 'বাকি',
269    duplicates: 'এর মধ্যে পুনরাবৃত্তি: {n}',
270    removed: '{n} সরানো হয়েছে',
271    turnLine: 'rtk: এই পালায় {planned} প্রত্যাশিত → {used} ব্যবহৃত ({pct})',
272    summary: '{planned} প্রত্যাশিত → {used} ব্যবহৃত ({pct})',
273    noRtk: 'PATH-এ rtk পাওয়া যায়নি',
274    noData: 'এই সেশনে এখনও কোনো কমান্ড rtk দিয়ে যায়নি',
275    paneTitle: 'rtk',
276    commandDescription: 'RTK সাশ্রয়ের প্যানেল খুলুন বা বন্ধ করুন',
277    stages: { ccr: 'CCR', filter: 'ফিল্টার', pixels: 'ছবি' },
278    categories: {
279      'System prompt': 'সিস্টেম প্রম্পট',
280      'System tools': 'সিস্টেম টুল',
281      'MCP tools': 'MCP টুল',
282      'Custom agents': 'কাস্টম এজেন্ট',
283      'Memory files': 'মেমরি ফাইল',
284      Skills: 'স্কিল',
285      Messages: 'বার্তা',
286    },
287  },
288  pt: {
289    title: 'rtk · sessão',
290    hookOnly: 'só hook',
291    hookProxy: 'hook + proxy',
292    proxyOnly: 'só proxy',
293    commands: 'COMANDOS',
294    context: 'CONTEXTO',
295    proxy: 'PROXY',
296    plannedUsed: '{planned} previstos → {used} usados',
297    avoided: 'evitado pelo RTK',
298    withoutRtk: 'sem RTK: {n}',
299    bandContext: 'contexto {n} (sem RTK {m})',
300    lastCommands: 'últimos comandos',
301    running: 'em execução…',
302    waiting: 'aguardando um comando…',
303    estimated: 'tokens estimados (bytes / 4)',
304    other: 'outros',
305    toolResults: 'saídas de ferramentas',
306    toolArgs: 'argumentos de chamadas',
307    rest: 'resto',
308    duplicates: 'dos quais duplicados: {n}',
309    removed: '{n} removidos',
310    turnLine: 'rtk: {planned} previstos → {used} usados ({pct}) neste turno',
311    summary: '{planned} previstos → {used} usados ({pct})',
312    noRtk: 'rtk não encontrado no PATH',
313    noData: 'nenhum comando passou pelo rtk nesta sessão',
314    paneTitle: 'rtk',
315    commandDescription: 'Abre ou fecha o painel de economia do RTK',
316    stages: { ccr: 'CCR', filter: 'filtro', pixels: 'imagens' },
317    categories: {
318      'System prompt': 'prompt do sistema',
319      'System tools': 'ferramentas do sistema',
320      'MCP tools': 'ferramentas MCP',
321      'Custom agents': 'agentes personalizados',
322      'Memory files': 'arquivos de memória',
323      Skills: 'skills',
324      Messages: 'mensagens',
325    },
326  },
327}
328
329export function messages(lang) {
330  return MESSAGES[lang] ?? MESSAGES.en
331}
332
333// "{planned} → {used}" with { planned: '1.2k', used: '310' }
334export function fill(template, values) {
335  return template.replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole))
336}
337
338// A category name from /context, in the session's language when known
339export function categoryName(lang, name) {
340  return messages(lang).categories[name] ?? name
341}
342
343// Language names as people write them in Claude Code's `language` setting,
344// in English, in the language itself, and in French
345const NAMES = {
346  en: ['english', 'anglais', 'inglés', 'ingles', 'inglês'],
347  zh: ['chinese', 'mandarin', 'mandarin chinese', 'simplified chinese', '中文', '简体中文', '汉语', '普通话', 'chinois'],
348  hi: ['hindi', 'हिन्दी', 'हिंदी'],
349  es: ['spanish', 'español', 'espanol', 'castellano', 'espagnol'],
350  ar: ['arabic', 'العربية', 'عربي', 'arabe', 'árabe'],
351  fr: ['french', 'français', 'francais', 'francés', 'francês'],
352  bn: ['bengali', 'bangla', 'বাংলা'],
353  pt: ['portuguese', 'português', 'portugues', 'portugais', 'portugués'],
354}
355
356// 'fr', 'fr_FR.UTF-8', 'zh-Hans-CN', 'Spanish', 'العربية' -> a supported
357// code, or null (C, POSIX, empty, or a language this mod has no text for)
358export function languageOf(value) {
359  if (typeof value !== 'string') return null
360  const text = value.trim().toLowerCase()
361  if (!text) return null
362  for (const [code, names] of Object.entries(NAMES)) {
363    if (names.includes(text)) return code
364  }
365  const code = text.split(/[_.@-]/)[0]
366  return LANGUAGES.includes(code) ? code : null
367}
368
369// The first source that names a supported language wins:
370// RTK_GAIN_LANG, Claude Code's `language` setting, the locale, the system's.
371export function detectLanguage({ explicit, setting, lcAll, lcMessages, lang, system } = {}) {
372  for (const value of [explicit, setting, lcAll, lcMessages, lang, system]) {
373    const code = languageOf(value)
374    if (code) return code
375  }
376  return 'en'
377}
378
hooks/lib/model.js 303 lines
1// Session state and everything derived from it. Pure: no mods API in this file.
2
3import { advanceViking, pickAttack, pickEffect } from './viking.js'
4
5// Frames of one animation: 12 frames at 33 ms is 0.4 s.
6export const FRAMES = 12
7export const MAX_COMMANDS = 5
8export const MAX_CATEGORIES = 4
9
10export function createState() {
11  return {
12    lang: 'fr',
13    startedAt: 0,
14    // 'unknown' until the first read, then 'ok' or 'missing'
15    rtk: 'unknown',
16    // Absolute `rtk gain` totals: at session start, and at the last read
17    base: null,
18    last: null,
19    // Session totals when the current turn started
20    turnBase: { input: 0, output: 0 },
21    // Newest first: { name, input, output, count }
22    commands: [],
23    running: null,
24    // { tokens, window, categories: [{ name, tokens }] }
25    context: null,
26    // Color slot of each context category, so a category keeps its color
27    slotOf: {},
28    // What the proxy recorded since the session started, or null
29    proxy: null,
30    // What the bars show now, on its way to the session totals
31    shown: { input: 0, output: 0 },
32    anim: null,
33    // The viking's strike on the last command:
34    // { frame, name, input, output, attack, effect }
35    viking: null,
36    showViking: true,
37    // The attack chosen when a command starts, so he readies the right weapon
38    nextAttack: null,
39    // An attack or an effect pinned by RTK_GAIN_ANIMATION
40    fixed: {},
41    random: Math.random,
42    paneOpen: false,
43  }
44}
45
46// `rtk gain --format json` -> { commands, input, output }, or null when unreadable
47export function parseSummary(stdout) {
48  try {
49    const summary = JSON.parse(stdout).summary
50    const input = Number(summary.total_input)
51    const output = Number(summary.total_output)
52    const commands = Number(summary.total_commands)
53    if (![input, output, commands].every(Number.isFinite)) return null
54    return { commands, input, output }
55  } catch {
56    return null
57  }
58}
59
60// What RTK did since the session started
61export function sessionTotals(state) {
62  if (!state.base || !state.last) return { commands: 0, input: 0, output: 0, saved: 0 }
63  const input = Math.max(0, state.last.input - state.base.input)
64  const output = Math.max(0, state.last.output - state.base.output)
65  return {
66    commands: Math.max(0, state.last.commands - state.base.commands),
67    input,
68    output,
69    saved: Math.max(0, input - output),
70  }
71}
72
73// Takes a fresh absolute summary. Returns what changed since the previous one,
74// or null when nothing did. The first summary of a session is the baseline.
75export function recordSummary(state, summary, commandName) {
76  state.rtk = 'ok'
77  if (!state.base) {
78    state.base = summary
79    state.last = summary
80    return null
81  }
82  const previous = state.last
83  // Totals that went down mean the history was cleared: start again from here
84  if (summary.input < previous.input || summary.commands < previous.commands) {
85    state.base = summary
86    state.last = summary
87    state.commands = []
88    state.turnBase = { input: 0, output: 0 }
89    state.shown = { input: 0, output: 0 }
90    state.anim = null
91    return null
92  }
93  const delta = {
94    commands: summary.commands - previous.commands,
95    input: summary.input - previous.input,
96    output: summary.output - previous.output,
97  }
98  state.last = summary
99  // No tokens went through rtk: nothing to show, even when rtk counted the
100  // command itself (a passthrough it logs with no output)
101  if (delta.input <= 0) return null
102  state.commands.unshift({
103    name: commandName || 'rtk',
104    input: delta.input,
105    output: delta.output,
106    count: delta.commands,
107  })
108  state.commands = state.commands.slice(0, MAX_COMMANDS)
109  state.anim = { from: { ...state.shown }, frame: 0 }
110  if (delta.input > 0) {
111    const savedRatio = (delta.input - delta.output) / delta.input
112    state.viking = {
113      frame: 0,
114      name: commandName || 'rtk',
115      input: delta.input,
116      output: delta.output,
117      attack: state.nextAttack ?? state.fixed.attack ?? pickAttack(state.viking?.attack, state.random),
118      effect: state.fixed.effect ?? pickEffect(state.viking?.effect, savedRatio, state.random),
119    }
120    state.nextAttack = null
121  }
122  return delta
123}
124
125export function startTurn(state) {
126  const totals = sessionTotals(state)
127  state.turnBase = { input: totals.input, output: totals.output }
128}
129
130export function turnTotals(state) {
131  const totals = sessionTotals(state)
132  const input = Math.max(0, totals.input - state.turnBase.input)
133  const output = Math.max(0, totals.output - state.turnBase.output)
134  return { input, output, saved: Math.max(0, input - output) }
135}
136
137// A command starts: choose the attack now, never the one just played
138export function prepareStrike(state) {
139  state.nextAttack = state.fixed.attack ?? pickAttack(state.viking?.attack, state.random)
140}
141
142// One frame of every animation. Returns true while one has frames left.
143export function step(state) {
144  const viking = advanceViking(state)
145  const bars = stepBars(state)
146  return viking || bars
147}
148
149// Moves the shown values one frame toward the session totals.
150// Returns true while there is another frame to draw.
151function stepBars(state) {
152  const target = sessionTotals(state)
153  if (!state.anim) {
154    state.shown = { input: target.input, output: target.output }
155    return false
156  }
157  state.anim.frame += 1
158  const progress = Math.min(1, state.anim.frame / FRAMES)
159  const eased = 1 - (1 - progress) ** 3
160  const from = state.anim.from
161  state.shown = {
162    input: from.input + (target.input - from.input) * eased,
163    output: from.output + (target.output - from.output) * eased,
164  }
165  if (progress >= 1) {
166    state.shown = { input: target.input, output: target.output }
167    state.anim = null
168    return false
169  }
170  return true
171}
172
173// Splits `width` cells between `values` in proportion, by largest remainder.
174// A share too small for a cell gets none: the number beside the bar stays exact.
175export function allocate(values, width) {
176  const total = values.reduce((sum, value) => sum + Math.max(0, value), 0)
177  if (!(total > 0) || width <= 0) return values.map(() => 0)
178  const exact = values.map((value) => (Math.max(0, value) / total) * width)
179  const cells = exact.map(Math.floor)
180  let left = width - cells.reduce((sum, value) => sum + value, 0)
181  const order = exact
182    .map((value, index) => ({ index, rest: value - Math.floor(value) }))
183    .sort((a, b) => b.rest - a.rest)
184  for (const { index } of order) {
185    if (left <= 0) break
186    cells[index] += 1
187    left -= 1
188  }
189  return cells
190}
191
192// `$.session.usage({ breakdown })` -> { tokens, window, categories }
193export function readUsage(usage) {
194  const context = usage?.context
195  if (!context || !(context.window > 0)) return null
196  const rows = context.breakdown?.categories ?? []
197  const categories = rows
198    .filter((row) => row.kind === 'used' && row.tokens > 0)
199    .map((row) => ({ name: String(row.name), tokens: Number(row.tokens) }))
200  const fromRows = categories.reduce((sum, row) => sum + row.tokens, 0)
201  return {
202    tokens: Number(context.tokens ?? 0) || fromRows,
203    window: Number(context.window),
204    categories,
205  }
206}
207
208export function setContext(state, context) {
209  if (!context) return
210  state.context = context
211}
212
213// Gives each shown category a slot of its own. A category keeps the slot it
214// had while no other shown category holds it; a new one takes a free slot.
215function assignSlots(state, names) {
216  const taken = new Set()
217  const keep = names.filter((name) => {
218    const slot = state.slotOf[name]
219    if (slot === undefined || taken.has(slot)) return false
220    taken.add(slot)
221    return true
222  })
223  for (const name of names) {
224    if (keep.includes(name)) continue
225    let slot = 0
226    while (taken.has(slot)) slot += 1
227    state.slotOf[name] = slot
228    taken.add(slot)
229  }
230}
231
232// The context bar: the biggest categories, the rest folded into one part,
233// and what RTK kept out of the window.
234export function contextView(state) {
235  const avoided = Math.max(0, state.shown.input - state.shown.output)
236  if (!state.context) return { parts: [], total: 0, window: 0, avoided }
237  const { tokens, window, categories } = state.context
238  const sorted = [...categories].sort((a, b) => b.tokens - a.tokens)
239  const shown = sorted.slice(0, MAX_CATEGORIES)
240  assignSlots(state, shown.map((row) => row.name))
241  const parts = shown.map((row) => ({ name: row.name, tokens: row.tokens, slot: state.slotOf[row.name] }))
242  const folded = sorted.slice(MAX_CATEGORIES).reduce((sum, row) => sum + row.tokens, 0)
243  if (folded > 0) parts.push({ name: null, tokens: folded, slot: -1 })
244  // No breakdown from this version of Claude Code: one part for the whole window
245  if (parts.length === 0 && tokens > 0) parts.push({ name: null, tokens, slot: -1 })
246  return { parts, total: tokens, window, avoided }
247}
248
249// One SELECT over the proxy's `usage_records`: totals since the session started,
250// and the context breakdown of the latest request. Only columns that every
251// proxy schema has (the OSS daemon and the Pro one).
252export function proxyQuery(startedAtMs) {
253  const since = new Date(Number(startedAtMs) || 0).toISOString().replace('Z', '')
254  return [
255    `with w as (select * from usage_records where ts >= '${since}'),`,
256    'l as (select * from w where ctx_total > 0 order by id desc limit 1)',
257    'select (select count(*) from w) as requests,',
258    '(select coalesce(sum(tokens_saved), 0) from w) as saved,',
259    '(select coalesce(sum(saved_ccr), 0) from w) as ccr,',
260    '(select coalesce(sum(saved_ingest_filter), 0) from w) as ingest,',
261    '(select coalesce(sum(saved_pixel_compress), 0) from w) as pixel,',
262    '(select ctx_total from l) as ctx_total,',
263    '(select ctx_tool_result from l) as ctx_tool_result,',
264    '(select ctx_tool_use from l) as ctx_tool_use,',
265    '(select ctx_duplicate from l) as ctx_duplicate',
266  ].join(' ')
267}
268
269// One row of `sqlite3 -json` over the proxy's usage_records -> proxy view, or null
270export function parseProxy(stdout) {
271  try {
272    const row = JSON.parse(stdout)[0]
273    const requests = Number(row.requests)
274    if (!(requests > 0)) return null
275    const number = (value) => Number(value) || 0
276    const total = number(row.ctx_total)
277    const toolResults = number(row.ctx_tool_result)
278    const toolArgs = number(row.ctx_tool_use)
279    return {
280      requests,
281      saved: number(row.saved),
282      stages: { ccr: number(row.ccr), filter: number(row.ingest), pixels: number(row.pixel) },
283      last: total > 0
284        ? {
285            total,
286            toolResults,
287            toolArgs,
288            rest: Math.max(0, total - toolResults - toolArgs),
289            duplicates: number(row.ctx_duplicate),
290          }
291        : null,
292    }
293  } catch {
294    return null
295  }
296}
297
298export function mode(state) {
299  if (state.proxy && state.rtk === 'ok') return 'hookProxy'
300  if (state.proxy) return 'proxyOnly'
301  return 'hookOnly'
302}
303
hooks/lib/view.js 277 lines
1// Element trees for each place the mod draws in. Pure: the hooks module passes
2// in the elements it got from `$.ui.resolve(e)`.
3
4import { percent, tokens } from './format.js'
5import { categoryName, fill, messages } from './i18n.js'
6import { BLOCK, BLOCK_X, runs, vikingGrid } from './viking.js'
7import { allocate, contextView, mode, sessionTotals, turnTotals } from './model.js'
8
9// Steps validated against a dark surface (dataviz palette): two hues for the
10// commands bar, a fixed order for context categories, a neutral for the rest.
11// The categories leave out the commands bar's blue and green, so no color
12// means two things in one pane.
13export const COLORS = {
14  used: '#3987e5',
15  saved: '#199e70',
16  rest: '#6b6a65',
17  slots: ['#d95926', '#9085e9', '#c98500', '#d55181'],
18}
19
20const FULL = '█'
21const HATCH = '╱'
22const EMPTY = '░'
23const MARK = '■'
24
25function colorOf(part) {
26  return part.slot < 0 ? COLORS.rest : COLORS.slots[part.slot % COLORS.slots.length]
27}
28
29function clamp(value, low, high) {
30  return Math.max(low, Math.min(high, value))
31}
32
33// One row of colored cells. Each segment is { value, color, char }.
34function bar({ Box, Text }, segments, width) {
35  const cells = allocate(segments.map((segment) => segment.value), width)
36  const children = []
37  segments.forEach((segment, index) => {
38    if (cells[index] > 0) {
39      children.push(Text({ color: segment.color, children: [(segment.char ?? FULL).repeat(cells[index])] }))
40    }
41  })
42  if (children.length === 0) children.push(Text({ dimColor: true, children: [EMPTY.repeat(width)] }))
43  return Box({ flexDirection: 'row', children })
44}
45
46function row({ Box }, left, right) {
47  return Box({ flexDirection: 'row', justifyContent: 'space-between', children: [left, right] })
48}
49
50function legend(el, color, mark, label, value) {
51  const { Box, Text } = el
52  return row(
53    el,
54    Box({ flexDirection: 'row', children: [Text({ color, children: [mark + ' '] }), Text({ children: [label] })] }),
55    Text({ bold: true, children: [value] }),
56  )
57}
58
59function commandsBar(el, state, width) {
60  const { input, output } = state.shown
61  return bar(
62    el,
63    [
64      { value: output, color: COLORS.used },
65      { value: Math.max(0, input - output), color: COLORS.saved },
66    ],
67    width,
68  )
69}
70
71function proxySection(el, state, width) {
72  const { Text } = el
73  const t = messages(state.lang)
74  const proxy = state.proxy
75  const children = [
76    Text({ children: [' '] }),
77    row(
78      el,
79      Text({ dimColor: true, children: [t.proxy] }),
80      Text({ bold: true, children: [fill(t.removed, { n: tokens(proxy.saved, state.lang) })] }),
81    ),
82  ]
83  if (proxy.last) {
84    const last = proxy.last
85    children.push(
86      bar(
87        el,
88        [
89          { value: last.toolResults, color: COLORS.slots[0] },
90          { value: last.toolArgs, color: COLORS.slots[1] },
91          { value: last.rest, color: COLORS.rest },
92        ],
93        width,
94      ),
95      legend(el, COLORS.slots[0], MARK, t.toolResults, tokens(last.toolResults, state.lang)),
96      legend(el, COLORS.slots[1], MARK, t.toolArgs, tokens(last.toolArgs, state.lang)),
97      legend(el, COLORS.rest, MARK, t.rest, tokens(last.rest, state.lang)),
98    )
99    if (last.duplicates > 0) {
100      children.push(Text({ dimColor: true, children: [fill(t.duplicates, { n: tokens(last.duplicates, state.lang) })] }))
101    }
102  }
103  const stages = Object.entries(proxy.stages)
104    .filter(([, value]) => value > 0)
105    .map(([name, value]) => (t.stages[name] ?? name) + ' ' + tokens(value, state.lang))
106  if (stages.length > 0) children.push(Text({ dimColor: true, children: [stages.join(' · ')] }))
107  return children
108}
109
110// The viking at the top of the pane, in the terminal only: the Desktop app's
111// text need not be monospaced, and the drawing would come apart there.
112const VIKING_COLORS = {
113  used: [COLORS.used, '#2f6fc0'],
114  saved: [COLORS.saved, '#13845d'],
115  idle: ['#4a4a46', '#3d3d3a'],
116}
117
118function vikingRows(el, state, width) {
119  const { Box, Text } = el
120  const anim = state.viking
121  const label = state.running
122    ? '▸ ' + state.running
123    : anim
124      ? anim.name + '  ' + percent(anim.input, anim.output, state.lang)
125      : ' '
126  const rows = runs(vikingGrid(state, width, VIKING_COLORS)).map((row) =>
127    Box({
128      flexDirection: 'row',
129      children: row.map((run) =>
130        Text({
131          ...(run.color ? { color: run.color } : {}),
132          ...(run.background ? { backgroundColor: run.background } : {}),
133          children: [run.text],
134        }),
135      ),
136    }),
137  )
138  return Box({
139    key: 'viking',
140    flexDirection: 'column',
141    children: [Box({ flexDirection: 'row', paddingLeft: BLOCK_X, children: [Text({ bold: true, children: [label] })] }), ...rows],
142  })
143}
144
145// The pane beside the transcript: the viking, both bars, their legends, the last commands.
146export function paneTree(el, state, columns, surface = 'terminal') {
147  const { Box, Text } = el
148  const lang = state.lang
149  const t = messages(lang)
150  const width = clamp(columns - 2, 16, 56)
151  const { input, output } = state.shown
152  const dim = (text) => Text({ dimColor: true, children: [text] })
153  const blank = () => Text({ children: [' '] })
154
155  const children = [row(el, Text({ bold: true, children: [t.title] }), dim(t[mode(state)]))]
156  if (state.showViking && surface === 'terminal' && width >= BLOCK_X + BLOCK + 1) {
157    children.push(blank(), vikingRows(el, state, Math.min(width, 50)))
158  }
159  if (state.rtk === 'missing') children.push(Text({ color: 'yellow', children: [t.noRtk] }))
160
161  children.push(
162    row(el, dim(t.commands), Text({ bold: true, children: [percent(input, output, lang)] })),
163    commandsBar(el, state, width),
164    dim(fill(t.plannedUsed, { planned: tokens(input, lang), used: tokens(output, lang) })),
165  )
166
167  const view = contextView(state)
168  if (view.total > 0) {
169    children.push(
170      blank(),
171      row(
172        el,
173        dim(t.context),
174        Text({ bold: true, children: [tokens(view.total, lang) + ' / ' + tokens(view.window, lang)] }),
175      ),
176      bar(
177        el,
178        [
179          ...view.parts.map((part) => ({ value: part.tokens, color: colorOf(part) })),
180          { value: view.avoided, color: COLORS.saved, char: HATCH },
181        ],
182        width,
183      ),
184    )
185    for (const part of view.parts) {
186      children.push(legend(el, colorOf(part), MARK, part.name ? categoryName(lang, part.name) : t.other, tokens(part.tokens, lang)))
187    }
188    if (view.avoided > 0) {
189      children.push(
190        legend(el, COLORS.saved, HATCH, t.avoided, tokens(view.avoided, lang)),
191        dim(fill(t.withoutRtk, { n: tokens(view.total + view.avoided, lang) })),
192      )
193    }
194  }
195
196  if (state.proxy) children.push(...proxySection(el, state, width))
197
198  children.push(blank(), dim(t.lastCommands))
199  if (state.running) {
200    children.push(row(el, Text({ bold: true, children: ['▸ ' + state.running] }), dim(t.running)))
201  }
202  state.commands.forEach((command, index) => {
203    const name = command.count > 1 ? command.name + ' +' + (command.count - 1) : command.name
204    const figures =
205      tokens(command.input, lang) + ' → ' + tokens(command.output, lang) + '  ' + percent(command.input, command.output, lang)
206    const newest = index === 0 && !state.running
207    children.push(row(el, Text({ bold: newest, children: [(newest ? '▸ ' : '  ') + name] }), dim(figures)))
208  })
209  if (state.commands.length === 0 && !state.running) children.push(dim(t.waiting))
210  children.push(blank(), dim(t.estimated))
211
212  return Box({ flexDirection: 'column', paddingX: 1, children })
213}
214
215// The band above the prompt: one line, or null while there is nothing to show.
216export function bandTree(el, state, columns) {
217  const { Box, Text } = el
218  const lang = state.lang
219  const t = messages(lang)
220  if (sessionTotals(state).input <= 0) return null
221  const { input, output } = state.shown
222  const children = [
223    Text({ bold: true, children: ['rtk '] }),
224    commandsBar(el, state, columns >= 60 ? 16 : 8),
225    Text({ children: [' ' + tokens(input, lang) + ' → ' + tokens(output, lang) + ' · '] }),
226    Text({ bold: true, children: [percent(input, output, lang)] }),
227  ]
228  const view = contextView(state)
229  if (columns >= 84 && view.total > 0) {
230    const without = tokens(view.total + view.avoided, lang)
231    children.push(
232      Text({
233        dimColor: true,
234        children: [' · ' + fill(t.bandContext, { n: tokens(view.total, lang), m: without })],
235      }),
236    )
237  }
238  if (state.running && columns >= 110) {
239    children.push(Text({ dimColor: true, children: [' · ▸ ' + state.running] }))
240  }
241  return Box({ flexDirection: 'row', paddingX: 1, children })
242}
243
244// Added after the spinner's word while Claude works
245export function spinnerSuffix(state) {
246  if (sessionTotals(state).input <= 0) return ''
247  // The animated values, so the spinner and the bars agree on every frame
248  return ' · rtk ' + percent(state.shown.input, state.shown.output, state.lang)
249}
250
251// The line under an answer: what RTK did during that turn
252export function turnLine(state) {
253  const turn = turnTotals(state)
254  if (turn.input <= 0) return ''
255  const t = messages(state.lang)
256  const lang = state.lang
257  return fill(t.turnLine, {
258    planned: tokens(turn.input, lang),
259    used: tokens(turn.output, lang),
260    pct: percent(turn.input, turn.output, lang),
261  })
262}
263
264// The text reply of /rtk-gain where no pane can be drawn
265export function summaryLine(state) {
266  const t = messages(state.lang)
267  const lang = state.lang
268  if (state.rtk === 'missing') return t.noRtk
269  const totals = sessionTotals(state)
270  if (totals.input <= 0) return t.noData
271  return fill(t.summary, {
272    planned: tokens(totals.input, lang),
273    used: tokens(totals.output, lang),
274    pct: percent(totals.input, totals.output, lang),
275  })
276}
277
hooks/lib/viking.js 511 lines
1// The viking who breaks tokens: pixel art at the top of the pane. Pure: it
2// returns rows of colored runs, and view.js turns them into Text elements.
3//
4// The scene is a canvas of pixels, two per terminal cell: each cell is drawn
5// with '▀', its color the top pixel and its background the bottom one.
6//
7// When a command returns, the viking attacks the command's block of tokens at
8// the cut between what was kept (blue) and what RTK saved (green), and the
9// saved part is destroyed. Ten attacks and five ways to destroy make fifty
10// animations; `pickAttack` and `pickEffect` choose one at random each time.
11
12export const VIKING_FRAMES = 24
13export const BLOCK = 14
14export const BLOCK_X = 21
15export const ROWS = 8
16const HEIGHT = ROWS * 2
17const BLOCK_TOP = 12
18// The attack lands on frame STRIKE; the saved part holds until IMPACT
19const STRIKE = 5
20const IMPACT = STRIKE + 2
21
22export const ATTACKS = ['axe', 'hammer', 'sword', 'spear', 'torch', 'twin', 'throw', 'bow', 'lightning', 'raven']
23export const EFFECTS = ['shards', 'explosion', 'melt', 'dust', 'swept']
24
25// Pixel colors, by the letter used in the sprites below
26const PAL = {
27  W: '#f1e9d2', // horn
28  w: '#c9bc9a', // horn, shaded
29  G: '#a3adb8', // helmet
30  g: '#6b7480', // helmet, shaded
31  s: '#e0a93b', // gold: band, buckle, beak, fletching
32  K: '#f2bd8f', // skin
33  k: '#d39468', // skin, shaded
34  E: '#1f2937', // eyes
35  R: '#e0702e', // beard
36  r: '#a9471a', // beard, braided
37  B: '#8c3b2b', // tunic
38  b: '#6a2a1f', // tunic, shaded
39  L: '#3b2a1a', // belt
40  P: '#5f6670', // trousers
41  O: '#3a2b20', // boots
42  H: '#a0703f', // wood: handles, shafts, bow
43  X: '#d6dbe0', // steel
44  x: '#ffffff', // edge, string, flash
45  M: '#8d99a6', // hammer stone
46  m: '#c3ccd6', // hammer, lit
47  V: '#8fa0b8', // raven
48  v: '#5c6b82', // raven, wings
49  Y: '#facc15', // spark, lightning
50  o: '#f59e0b', // fire
51}
52
53// The viking, 12 pixels wide and 16 high, facing right
54const BODY = [
55  'W..........W',
56  'Ww........wW',
57  '.WwGGGGGGwW.',
58  '..GGGGGGGGg.',
59  '..ssssssssg.',
60  '..KKKKKKKK..',
61  '..KEKKKKEK..',
62  '..KKKkkKKK..',
63  '.RRKKKKKKRR.',
64  '.RRRRRRRRRR.',
65  '..RRrRRrRR..',
66  '.BBBRrrRBBB.',
67  'BbBBLLsLBBbB',
68  '.bBBBBBBBBb.',
69  '..PPP..PPP..',
70  '..OOO..OOO..',
71]
72
73// --- drawing helpers: every shape is a list of [x, y, letter] pixels ---
74
75const dots = (points, letter) => points.map(([x, y]) => [x, y, letter])
76
77function rect(x0, y0, x1, y1, letter) {
78  const out = []
79  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) out.push([x, y, letter])
80  return out
81}
82
83function line(x0, y0, x1, y1, letter) {
84  const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
85  const out = []
86  for (let i = 0; i <= steps; i++) {
87    out.push([Math.round(x0 + ((x1 - x0) * i) / steps), Math.round(y0 + ((y1 - y0) * i) / steps), letter])
88  }
89  return out
90}
91
92const lerp = (a, b, t) => a + (b - a) * t
93
94// A small, fixed sequence of numbers in [0, 1): the same shards every time
95function noise(n) {
96  const v = Math.sin(n * 12.9898) * 43758.5453
97  return v - Math.floor(v)
98}
99
100// --- the ten attacks ---
101// Each gives the hand and the weapon for four moments: `ready` (raised, also
102// shown while the command runs), `swing`, `strike` (on the cut) and `rest`.
103// `c` carries the cut's column, the frame, the canvas width, and `k`, the
104// frames since the strike began.
105
106const axeReady = () => [
107  ...dots([[12, 10]], 'K'),
108  ...dots([[13, 9], [13, 8], [14, 7], [14, 6], [15, 5], [15, 4]], 'H'),
109  ...dots([[16, 0], [17, 0], [15, 1], [16, 1], [15, 2], [16, 2], [16, 3], [17, 3]], 'X'),
110  ...dots([[17, 1], [17, 2]], 'x'),
111]
112const axeSwing = () => [
113  ...dots([[12, 11]], 'K'),
114  ...line(13, 11, 17, 11, 'H'),
115  ...dots([[18, 9], [18, 10], [18, 11], [18, 12], [18, 13], [19, 10], [19, 11], [19, 12]], 'X'),
116  ...dots([[19, 9], [19, 13]], 'x'),
117]
118const axeStrike = ({ cut }) => [
119  ...dots([[12, 10]], 'K'),
120  ...line(13, 9, cut - 2, 9, 'H'),
121  ...dots([[cut - 1, 8], [cut, 8], [cut + 1, 8], [cut - 1, 9], [cut, 9], [cut + 1, 9], [cut, 10]], 'X'),
122  ...dots([[cut, 11]], 'x'),
123]
124const axeRest = () => [
125  ...dots([[12, 11]], 'K'),
126  ...dots([[13, 12], [13, 13], [14, 14]], 'H'),
127  ...dots([[14, 12], [15, 12], [15, 13], [16, 13], [15, 14], [16, 14]], 'X'),
128  ...dots([[16, 15]], 'x'),
129]
130
131const hammerReady = () => [
132  ...dots([[12, 10]], 'K'),
133  ...dots([[13, 9], [13, 8], [14, 7], [14, 6], [15, 5], [15, 4]], 'H'),
134  ...rect(14, 1, 18, 3, 'M'),
135  ...rect(14, 0, 18, 0, 'm'),
136]
137const hammerRest = () => [
138  ...dots([[12, 11]], 'K'),
139  ...dots([[13, 12], [14, 13]], 'H'),
140  ...rect(15, 14, 18, 15, 'M'),
141  ...rect(15, 13, 18, 13, 'm'),
142]
143
144// An axe in the air or stuck in the ground, its head at (x, y)
145const flyingAxe = (x, y, turned) => [
146  ...(turned ? dots([[x - 1, y - 2], [x - 2, y - 3]], 'H') : dots([[x - 1, y + 2], [x - 2, y + 3]], 'H')),
147  ...dots([[x - 1, y], [x, y], [x - 1, y + 1], [x, y + 1]], 'X'),
148  ...dots([[x + 1, y], [x + 1, y + 1]], 'x'),
149]
150const stuckAxe = (cut) => [
151  ...dots([[cut + 1, 8], [cut + 2, 7], [cut + 3, 6]], 'H'),
152  ...dots([[cut - 1, 9], [cut, 9], [cut, 10]], 'X'),
153  ...dots([[cut, 11]], 'x'),
154]
155
156const bow = (x, top) => [
157  ...dots([[x, top], [x + 1, top + 1], [x + 1, top + 2], [x + 1, top + 3], [x + 1, top + 4], [x, top + 5]], 'H'),
158  ...line(x, top + 1, x, top + 4, 'x'),
159]
160const stuckArrow = (cut) => [
161  ...dots([[cut - 3, 8]], 's'),
162  ...dots([[cut - 2, 9], [cut - 1, 10]], 'H'),
163  ...dots([[cut, 11]], 'X'),
164]
165
166// A raven flying left, its beak at (x, y); the wings beat with `flap`
167const raven = (x, y, flap) => [
168  ...dots([[x, y]], 's'),
169  ...line(x + 1, y, x + 5, y, 'V'),
170  ...line(x + 2, y + 1, x + 4, y + 1, 'V'),
171  ...dots([[x + 6, y]], 'v'),
172  ...dots(flap ? [[x + 3, y + 2], [x + 4, y + 2]] : [[x + 3, y - 1], [x + 4, y - 1], [x + 4, y - 2]], 'v'),
173]
174
175const ATTACK = {
176  // Overhead chop
177  axe: { ready: axeReady, swing: axeSwing, strike: axeStrike, rest: axeRest },
178
179  // A stone hammer comes down flat on the cut
180  hammer: {
181    ready: hammerReady,
182    swing: () => [...dots([[12, 11]], 'K'), ...line(13, 11, 17, 11, 'H'), ...rect(18, 10, 20, 13, 'M'), ...rect(18, 9, 20, 9, 'm')],
183    strike: ({ cut }) => [
184      ...dots([[12, 10]], 'K'),
185      ...line(13, 9, cut - 2, 9, 'H'),
186      ...rect(cut - 1, 9, cut + 2, 11, 'M'),
187      ...rect(cut - 1, 8, cut + 2, 8, 'm'),
188    ],
189    rest: hammerRest,
190  },
191
192  // A sword slices straight down through the block
193  sword: {
194    ready: () => [...dots([[12, 10]], 'K'), ...dots([[12, 9], [14, 9]], 's'), ...line(13, 9, 13, 1, 'X'), ...dots([[13, 0]], 'x')],
195    swing: () => [...dots([[12, 10]], 'K'), ...line(13, 9, 19, 3, 'X'), ...dots([[20, 2]], 'x')],
196    strike: ({ cut }) => [...dots([[12, 10]], 'K'), ...line(13, 9, cut, 11, 'X'), ...line(cut, 12, cut, 15, 'x')],
197    rest: () => [...dots([[12, 11]], 'K'), ...line(13, 12, 16, 15, 'X')],
198  },
199
200  // A spear is thrust down into the cut
201  spear: {
202    ready: () => [...dots([[12, 10]], 'K'), ...line(13, 11, 13, 3, 'H'), ...dots([[13, 0], [13, 1], [12, 2], [13, 2], [14, 2]], 'X')],
203    swing: () => [...dots([[12, 10]], 'K'), ...line(13, 10, 18, 10, 'H'), ...dots([[19, 9], [19, 10], [19, 11], [20, 10]], 'X')],
204    strike: ({ cut }) => [...dots([[12, 10]], 'K'), ...line(13, 9, cut - 1, 10, 'H'), ...dots([[cut - 1, 9], [cut, 10]], 'X'), ...dots([[cut, 11]], 'x')],
205    rest: () => [...dots([[12, 11]], 'K'), ...line(14, 15, 14, 5, 'H'), ...dots([[14, 2], [14, 3], [13, 4], [14, 4], [15, 4]], 'X')],
206  },
207
208  // A torch is put to the cut
209  torch: {
210    ready: () => [
211      ...dots([[12, 10]], 'K'),
212      ...dots([[13, 9], [13, 8], [14, 7], [14, 6]], 'H'),
213      ...dots([[13, 5], [14, 5], [15, 5], [14, 4], [15, 4]], 'o'),
214      ...dots([[14, 3], [15, 3], [14, 2]], 'Y'),
215    ],
216    swing: () => [
217      ...dots([[12, 11]], 'K'),
218      ...line(13, 11, 17, 11, 'H'),
219      ...dots([[18, 10], [19, 10], [18, 11], [19, 11], [18, 12]], 'o'),
220      ...dots([[20, 10], [20, 11], [19, 9]], 'Y'),
221    ],
222    strike: ({ cut, k }) => [
223      ...dots([[12, 10]], 'K'),
224      ...line(13, 9, cut - 2, 10, 'H'),
225      ...dots([[cut - 1, 10], [cut, 10], [cut - 1, 11], [cut, 11], [cut + 1, 11]], 'o'),
226      ...dots([[cut, 9], [cut + 1, 10], [cut, 8 - (k % 2)]], 'Y'),
227    ],
228    rest: () => [...dots([[12, 11]], 'K'), ...dots([[13, 12], [14, 13], [15, 14]], 'H'), ...dots([[16, 14], [16, 15]], 'o'), ...dots([[16, 13]], 'Y')],
229    ownSparks: true,
230  },
231
232  // Two axes, one after the other
233  twin: {
234    ready: () => [...axeReady(), ...dots([[0, 9], [0, 8], [0, 7]], 'H'), ...dots([[0, 5], [1, 5], [0, 6], [1, 6]], 'X')],
235    swing: () => [...axeSwing(), ...line(13, 9, 15, 9, 'H'), ...dots([[16, 8], [16, 9], [17, 8], [17, 9]], 'X')],
236    strike: ({ cut }) => [
237      ...axeStrike({ cut }),
238      ...line(13, 10, cut + 1, 10, 'H'),
239      ...dots([[cut + 2, 9], [cut + 3, 9], [cut + 2, 10], [cut + 3, 10]], 'X'),
240      ...dots([[cut + 3, 11]], 'x'),
241    ],
242    rest: () => [...axeRest(), ...dots([[17, 13], [18, 13], [18, 14]], 'X'), ...dots([[18, 15]], 'x')],
243  },
244
245  // The axe is thrown, turns in the air, and stays planted on the cut
246  throw: {
247    ready: axeReady,
248    swing: ({ cut, frame }) => {
249      const t = frame === 3 ? 0.3 : 0.65
250      return [...dots([[12, 10], [13, 9]], 'K'), ...flyingAxe(Math.round(lerp(17, cut, t)), Math.round(lerp(2, 7, t) - 3 * Math.sin(Math.PI * t)), frame === 4)]
251    },
252    strike: ({ cut, k }) => [...dots([[12, 10], [13, 9]], 'K'), ...(k === 0 ? flyingAxe(cut, 8, false) : stuckAxe(cut))],
253    rest: ({ cut }) => [...dots([[12, 11]], 'K'), ...stuckAxe(cut)],
254  },
255
256  // An arrow is loosed in an arc and lands on the cut
257  bow: {
258    ready: () => [...dots([[12, 9]], 'K'), ...bow(14, 6), ...line(11, 8, 17, 8, 'H'), ...dots([[18, 8]], 'X')],
259    swing: ({ cut, frame }) => {
260      const t = frame === 3 ? 0.35 : 0.7
261      const x = Math.round(lerp(17, cut, t))
262      const y = Math.round(lerp(8, 9, t) - 6 * Math.sin(Math.PI * t))
263      return [...dots([[12, 9]], 'K'), ...bow(14, 6), ...line(x - 3, y, x - 1, y, 'H'), ...dots([[x, y]], 'X')]
264    },
265    strike: ({ cut }) => [...dots([[12, 9]], 'K'), ...bow(14, 6), ...stuckArrow(cut)],
266    rest: ({ cut }) => [...dots([[12, 11]], 'K'), ...bow(13, 9), ...stuckArrow(cut)],
267  },
268
269  // The hammer is held to the sky and lightning falls on the cut
270  lightning: {
271    ready: hammerReady,
272    swing: () => [...hammerReady(), ...dots([[12, 1], [20, 0], [20, 3], [16, 5]], 'Y')],
273    strike: ({ cut, k }) => {
274      const bolt = [[2, 0], [1, 1], [1, 2], [0, 3], [1, 4], [2, 5], [1, 6], [0, 7], [-1, 8], [0, 9], [0, 10], [0, 11]]
275      return [...hammerReady(), ...dots(bolt.map(([dx, y]) => [cut + dx, y]), k % 2 ? 'x' : 'Y')]
276    },
277    rest: hammerRest,
278    ownSparks: true,
279  },
280
281  // He points, and one of Odin's ravens dives on the cut and flies off
282  raven: {
283    ready: ({ width }) => [...dots([[12, 10], [13, 9], [14, 8]], 'K'), ...raven(width - 8, 3, false)],
284    swing: ({ cut, frame, width }) => {
285      const t = frame === 3 ? 0.4 : 0.8
286      return [
287        ...dots([[12, 10], [13, 9], [14, 8]], 'K'),
288        ...raven(Math.round(lerp(width - 8, cut, t)), Math.round(lerp(3, 9, t)), frame === 3),
289      ]
290    },
291    strike: ({ cut, k }) => [...dots([[12, 10], [13, 9], [14, 8]], 'K'), ...raven(cut, 9 + (k % 2), k % 2 === 0)],
292    rest: ({ cut, frame }) => {
293      const away = frame - (STRIKE + 4)
294      const flying = away < 10 ? raven(Math.round(cut + away * 2.5), Math.round(9 - away * 1.3), away % 2 === 0) : []
295      return [...dots([[12, 11]], 'K'), ...flying]
296    },
297  },
298}
299
300// --- the five ways the saved part goes ---
301// Each plots what is left of the saved pixels `t` frames after the impact.
302// `e` carries the cut, the canvas width, the two saved shades and `chunk`,
303// which gives a column its shade.
304
305const EFFECT = {
306  // Bursts into shards that fly up and right, and fall
307  shards(plot, t, e) {
308    for (let x = e.cut; x < BLOCK_X + BLOCK; x++) {
309      for (let y = BLOCK_TOP; y < HEIGHT; y += 2) {
310        const n = x * 7 + y
311        const life = 10 + Math.floor(noise(n) * 7)
312        if (t >= life) continue
313        const vx = 0.5 + noise(n + 1) * 1.6
314        const vy = -1.6 + noise(n + 2) * 1.1
315        plot(x + vx * t, y + vy * t + 0.18 * t * t, t < life - 3 ? e.chunk(x) : e.saved[1])
316      }
317    }
318  },
319
320  // A flash, then everything is thrown outward from the middle
321  explosion(plot, t, e) {
322    const cx = (e.cut + BLOCK_X + BLOCK - 1) / 2
323    const cy = BLOCK_TOP + 1.5
324    if (t < 2) {
325      for (let x = e.cut - 1; x <= BLOCK_X + BLOCK; x++) {
326        for (let y = BLOCK_TOP - 1; y < HEIGHT; y++) plot(x, y, t === 0 ? PAL.x : PAL.Y)
327      }
328      return
329    }
330    for (let x = e.cut; x < BLOCK_X + BLOCK; x++) {
331      for (let y = BLOCK_TOP; y < HEIGHT; y++) {
332        const n = x * 11 + y * 3
333        if (noise(n) < 0.35) continue
334        const life = 8 + Math.floor(noise(n + 1) * 6)
335        if (t >= life) continue
336        const angle = Math.atan2(y - cy, x - cx) + (noise(n + 2) - 0.5)
337        const speed = 1 + noise(n + 3) * 1.4
338        const d = (t - 1) * speed
339        plot(x + Math.cos(angle) * d * 1.6, y + Math.sin(angle) * d - 0.6 * d + 0.05 * t * t, t < 5 ? PAL.o : e.chunk(x))
340      }
341    }
342  },
343
344  // Sinks into the ground, column by column
345  melt(plot, t, e) {
346    for (let x = e.cut; x < BLOCK_X + BLOCK; x++) {
347      const height = 4 - Math.floor(Math.max(0, t - noise(x) * 4) / 2.5)
348      for (let y = HEIGHT - Math.max(0, height); y < HEIGHT; y++) plot(x, y, height < 3 ? e.saved[1] : e.chunk(x))
349    }
350  },
351
352  // Crumbles to dust that drifts up
353  dust(plot, t, e) {
354    for (let x = e.cut; x < BLOCK_X + BLOCK; x++) {
355      for (let y = BLOCK_TOP; y < HEIGHT; y++) {
356        const death = noise(x * 5 + y * 13) * 12
357        if (t < death) plot(x, y, e.chunk(x))
358        else if (t < death + 3) plot(x + (noise(x + y) - 0.5) * 2, y - (t - death) * 1.2, e.saved[1])
359      }
360    }
361  },
362
363  // Swept off to the right in one piece, faster and faster
364  swept(plot, t, e) {
365    const dx = Math.round(0.12 * t * t + 0.5 * t)
366    for (let x = e.cut; x < BLOCK_X + BLOCK; x++) {
367      for (let y = BLOCK_TOP; y < HEIGHT; y++) plot(x + dx, y, e.chunk(x))
368    }
369    if (t > 2) {
370      for (const y of [BLOCK_TOP, BLOCK_TOP + 2]) {
371        for (let i = 2; i <= 6; i += 2) plot(e.cut + dx - i, y, e.saved[1])
372      }
373    }
374  },
375}
376
377// --- choosing an animation ---
378
379function weighted(options, weightOf, random) {
380  const total = options.reduce((sum, option) => sum + weightOf(option), 0)
381  let roll = random() * total
382  for (const option of options) {
383    roll -= weightOf(option)
384    if (roll < 0) return option
385  }
386  return options[options.length - 1]
387}
388
389// Any attack but the one just seen
390export function pickAttack(previous, random = Math.random) {
391  const options = ATTACKS.filter((name) => name !== previous)
392  return options[Math.min(options.length - 1, Math.floor(random() * options.length))]
393}
394
395// Any effect but the one just seen. A big saving leans toward the loud ones,
396// a small one toward the quiet ones; every effect stays possible.
397export function pickEffect(previous, savedRatio, random = Math.random) {
398  const loud = ['shards', 'explosion', 'swept']
399  const options = EFFECTS.filter((name) => name !== previous)
400  const weight = (name) => {
401    if (savedRatio >= 0.7) return loud.includes(name) ? 3 : 1
402    if (savedRatio < 0.3) return loud.includes(name) ? 1 : 3
403    return 1
404  }
405  return weighted(options, weight, random)
406}
407
408// "hammer", "hammer:melt" or ":melt" -> the parts that name a known animation
409export function parseAnimation(value) {
410  if (typeof value !== 'string') return {}
411  const [attack, effect] = value.trim().toLowerCase().split(':')
412  return {
413    ...(ATTACKS.includes(attack) ? { attack } : {}),
414    ...(EFFECTS.includes(effect) ? { effect } : {}),
415  }
416}
417
418// Cells of the command's block that stay: at least one when anything was kept
419export function keptCells(input, output) {
420  if (!(input > 0)) return 0
421  const kept = Math.round((BLOCK * Math.max(0, output)) / input)
422  return output > 0 ? Math.max(1, Math.min(BLOCK, kept)) : 0
423}
424
425// The scene as a canvas of pixel colors (null where nothing is drawn).
426// `colors` gives two shades each for used, saved and idle tokens.
427export function vikingCanvas(state, width, colors) {
428  const canvas = Array.from({ length: HEIGHT }, () => Array.from({ length: width }, () => null))
429  const plot = (x, y, color) => {
430    const col = Math.round(x)
431    const row = Math.round(y)
432    if (row >= 0 && row < HEIGHT && col >= 0 && col < width) canvas[row][col] = color
433  }
434  // Tokens come in chunks of two columns, in alternating shades
435  const chunk = (shades) => (x) => shades[Math.floor((x - BLOCK_X) / 2) % 2]
436  const column = (x, shade) => {
437    for (let y = BLOCK_TOP; y < HEIGHT; y++) plot(x, y, shade(x))
438  }
439
440  const anim = state.running ? null : state.viking
441  const frame = anim?.frame ?? VIKING_FRAMES
442  const kept = anim ? keptCells(anim.input, anim.output) : 0
443  const cut = BLOCK_X + kept
444  const attack = ATTACK[state.running ? state.nextAttack : anim?.attack] ?? ATTACK.axe
445  let moment = 'rest'
446  if (state.running || (anim && frame < 3)) moment = 'ready'
447  else if (anim && frame < STRIKE) moment = 'swing'
448  else if (anim && frame < STRIKE + 4) moment = 'strike'
449
450  if (!anim) {
451    for (let x = BLOCK_X; x < BLOCK_X + BLOCK; x++) column(x, chunk(colors.idle))
452  } else {
453    for (let x = BLOCK_X; x < cut; x++) column(x, chunk(colors.used))
454    if (frame < IMPACT) {
455      for (let x = cut; x < BLOCK_X + BLOCK; x++) column(x, chunk(colors.saved))
456    } else if (frame < VIKING_FRAMES) {
457      const effect = EFFECT[anim.effect] ?? EFFECT.shards
458      // What is left of the saved part never covers the kept part or the viking
459      const right = (x, y, color) => Math.round(x) >= cut && plot(x, y, color)
460      effect(right, frame - IMPACT, { cut, width, saved: colors.saved, chunk: chunk(colors.saved) })
461    }
462  }
463
464  BODY.forEach((row, y) => Array.from(row).forEach((letter, x) => letter !== '.' && plot(x + 1, y, PAL[letter])))
465  for (const [x, y, letter] of attack[moment]({ cut, frame, width, k: frame - STRIKE })) plot(x, y, PAL[letter])
466
467  if (moment === 'strike' && frame < IMPACT && !attack.ownSparks) {
468    for (const [dx, y] of [[-2, 6], [2, 6], [-3, 9], [3, 9], [0, 5]]) plot(cut + dx, y, PAL.Y)
469  }
470  return canvas
471}
472
473// The canvas as terminal cells: [char, color, background]
474export function vikingGrid(state, width, colors) {
475  const canvas = vikingCanvas(state, width, colors)
476  const grid = []
477  for (let row = 0; row < ROWS; row++) {
478    const cells = []
479    for (let col = 0; col < width; col++) {
480      const top = canvas[row * 2][col]
481      const bottom = canvas[row * 2 + 1][col]
482      if (top && bottom) cells.push(['▀', top, bottom])
483      else if (top) cells.push(['▀', top, null])
484      else if (bottom) cells.push(['▄', bottom, null])
485      else cells.push([' ', null, null])
486    }
487    grid.push(cells)
488  }
489  return grid
490}
491
492// Rows of { text, color, background } runs: neighbouring cells of one look merged
493export function runs(grid) {
494  return grid.map((row) => {
495    const out = []
496    for (const [char, color, background] of row) {
497      const last = out[out.length - 1]
498      if (last && last.color === color && last.background === background) last.text += char
499      else out.push({ text: char, color, background })
500    }
501    return out
502  })
503}
504
505// One frame further. Returns true while the viking has frames left to play.
506export function advanceViking(state) {
507  if (!state.viking || state.viking.frame >= VIKING_FRAMES) return false
508  state.viking.frame += 1
509  return state.viking.frame < VIKING_FRAMES
510}
511