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

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.

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.
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.
Three places in Claude Code:
/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.Reading the bars:
/context. The hatched part extends the bar by what RTK kept out: "without RTK", the context would be the sum of the two.
In that turn, RTK kept 26.2k tokens out of the context: 79.7k instead of 106k.
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.
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.
| | | | :- | :- | |
|
| | 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. | |
|
| | 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%). |
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.
/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.
/rtk-gain
It does not open on its own: without this step you only see the band. /rtk-gain again, or Esc, closes it.
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.
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 |
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:
RTK_GAIN_LANG (fr, pt_BR, spanish…)language setting ("language": "french" in settings.json)LC_ALL, LC_MESSAGES, then LANG (C.UTF-8 and POSIX don't count)LANG is C.UTF-8Every 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:
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:
tool.call on Bash), picks the viking's attack and shows the command as running.git diff becomes rtk git diff) and RTK records what the command would have returned and what it returned.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.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).
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.
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.
rtk gain --project counts by directory, not by session./rtk-gain then answers with one line of text.git log && git status) shows as one line, with the number of RTK commands: git log +1.Apache-2.0, like RTK. See LICENSE.
hooks/register.js 246 lines1// 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}
246hooks/lib/format.js 45 lines1// 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}
45hooks/lib/i18n.js 378 lines1// 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}
378hooks/lib/model.js 303 lines1// 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}
303hooks/lib/view.js 277 lines1// 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}
277hooks/lib/viking.js 511 lines1// 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