SLOPSHOPPER

yemeni-context-band

Context-window band above the prompt with RTK token savings and a clickable pixel Yemeni companion whose mood follows how much context is left

newbandcommandtoastpromptprocess
v0.4.0MITupdated 2026-10-14iyiob96/Context-Mod-Companion
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · yemeni-context-band
› fix the failing auth test and add an audit log call ╭────────────────────────────────────╮ │ yemeni-context-band │ ⏺ Read(src/auth.ts) │ Setup finished with 3 step(s) left │ ⎿ 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 › /yemeni ⎿ yemeni-context-band: ✓ Hooked RTK into Claude Code ⎿ yemeni-context-band: ✓ Added the Bash-only line to /Users/dev/.claude/CLAUDE.md ⎿ yemeni-context-band: ✓ Installed Matt Pocock's skills ⎿ yemeni-context-band: ⎿ yemeni-context-band: Still to do: hook RTK into Claude Code, add a Bash-only line to your CLAUDE.md, install Matt Pocock's skills. ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ ✓ Hooked RTK into Claude Code │ ✓ Added the Bash-only line to /Users/dev/.claude/CLAUDE.md │ ✓ Installed Matt Pocock's skills │ Still to do: hook RTK into Claude Code, add a Bash-only line to your CLAUDE.md, install Matt Pocock's skills. [ Tr ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ context 97.4k of 200k [49%] ⚡ RTK – │ │ █████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ ✓ Hooked RTK into Claude Code │ ✓ Added the Bash-only line to /Users/dev/.claude/CLAUDE.md │ ✓ Installed Matt Pocock's skills │ Still to do: hook RTK into Claude Code, add a Bash-only line to your CLAUDE.md, install Matt [ Tr │ Pocock's skills. ╰─────────────────────────────────────────────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ context 97.4k of 200k [49%] ⚡ RTK – │ │ █████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Context Mod Companion

A Claude Code mod that puts a context-window band above the prompt box. It shows your RTK token savings, and a pixel Yemeni companion stands beside it. His mood follows how much context you have left. Its one-click setup also installs RTK and Matt Pocock's skills.

The band above the prompt, with the companion standing to its right

Install

Pick one.

One line (recommended)

Paste into a terminal. It installs the plugin, Matt Pocock's skills and RTK, hooks RTK into Claude Code, and adds the Bash-only line to your CLAUDE.md. Run it again any time; it skips what's already done.

Windows (PowerShell):

irm https://raw.githubusercontent.com/iyiob96/Context-Mod-Companion/main/install.ps1 | iex

macOS / Linux:

curl -fsSL https://raw.githubusercontent.com/iyiob96/Context-Mod-Companion/main/install.sh | sh

Then restart Claude Code.

From the Claude desktop app

  1. Paste https://github.com/iyiob96/Context-Mod-Companion into the app's plugin settings and install yemeni-context-band.
  2. Open a new session. The band shows a Finish setup row listing what's missing. Click Finish setup and it does the rest (see what setup does).
  3. Restart Claude Code.

From a Claude Code terminal session

/plugin install yemeni-context-band --marketplace iyiob96/Context-Mod-Companion

Then click Finish setup in the band, or run /yemeni.

What setup does

The band checks four things each time a session starts. If any is missing, it shows a Finish setup row; /yemeni does the same from the prompt.

StepHow
Install RTKWindows: winget install rtk-ai.rtk; macOS: brew install rtk; Linux: RTK's official install script
Hook RTK into Claude Codertk init -g
Bash-only line in your CLAUDE.mdAdds the line below to ~/.claude/CLAUDE.md once. RTK only compresses output from the Bash tool, and on Windows Claude may use PowerShell instead
Matt Pocock's skillsAdds Anthropic's official plugin marketplace if needed, then installs mattpocock-skills

Use the Bash tool for shell commands (git, npm, builds, tests, linters, docker) so RTK can compress their output. Only use PowerShell for Windows-specific tasks such as winget, .ps1 scripts or the registry.

RTK (rtk-ai/rtk, Apache-2.0) and Matt Pocock's skills are separate projects. Setup installs them from their official sources; this repo doesn't bundle them. Click Later to put setup off for the session. The band works without RTK and shows RTK –.

After setup, run /mattpocock-skills:setup-matt-pocock-skills in a project to set up Matt Pocock's skills there.

What you get

PartWhat it shows
ContextTokens used out of your window, the auto-compact point, a % used badge, a bar split by category (system prompt, tools, MCP tools, agents, memory files, skills, messages) and a legend with token counts
⚡ RTKTotal tokens RTK has saved, from rtk gain
CompanionA chibi Yemeni man in traditional dress (turban, khanjar, bandoliers) with no background. Click him and he raises his hand and says "Salam"

The band pops up after the first prompt you send in a session.

All four moods, at rest and waving

Context leftMood
More than 80%Happy: closed-eye grin, rosy cheeks, gentle bob
60–80%Normal: calm, slight smile, blinks
40–60%Worried: raised brows, wavy mouth, sweat drop
40% or lessDevastated: tears, wailing, slumped

The full drawing (bar chart, animated companion, click to wave) is for the Claude Code desktop app. In a terminal the band falls back to text blocks and a half-block pixel companion, and clicking him does nothing.

Turn it off or on

claude plugin disable yemeni-context-band@context-mod-companion
claude plugin enable yemeni-context-band@context-mod-companion

Develop

hooks/register.tsx   the band, the companion's drawing and click, first-run setup
hooks/character.ts   the pixel companion: palette, 24x40 grid per mood and pose
hooks/setup.ts       setup steps, texts and plain helpers
types/index.d.ts     state contract
tests/               claude plugin test suites
install.ps1 / .sh    one-line installers

Check your changes with:

claude plugin validate .
claude plugin test .

The companion was drawn from a reference photo of traditional Yemeni dress, in a chunky pixel-art style.

License

MIT

Source 4 files
hooks/register.tsx 589 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Rtk, Segment, Setup, SetupStep, Usage } from '../types'
5import { characterGrid, EYES, HEIGHT, PALETTE, SKIN, WIDTH } from './character'
6import type { Mood } from './character'
7import {
8  hasBashLine,
9  hasMarketplace,
10  isEnabled,
11  isHooked,
12  MATT_ID,
13  missingSummary,
14  OFFICIAL_MARKETPLACE,
15  OFFICIAL_MARKETPLACE_URL,
16  RTK_INSTALL,
17  RTK_MANUAL,
18  SETUP_COMMAND,
19  STEP_LABEL,
20  STEP_ORDER,
21  withBashLine,
22} from './setup'
23
24const isShown = atom({ plugin: 'yemeni-context-band', key: 'isShown' } as const, false)
25const usageAtom = atom({ plugin: 'yemeni-context-band', key: 'usage' } as const, null)
26const rtkAtom = atom({ plugin: 'yemeni-context-band', key: 'rtk' } as const, null)
27const rtkBaseline = atom({ plugin: 'yemeni-context-band', key: 'rtkBaseline' } as const, null)
28const setupAtom = atom({ plugin: 'yemeni-context-band', key: 'setup' } as const, null as Setup | null)
29
30const ACCENT = '#e0875a'
31const RTK = '#7ccf8a'
32const SCALE = 3 // CSS px per character pixel
33const FREE = '#3a3f4b'
34const BUFFER = '#7b8190'
35const SEGMENT_COLORS: [RegExp, string][] = [
36  [/system prompt/i, '#6c8cff'],
37  [/system tools|^tools/i, '#9aa3b5'],
38  [/mcp/i, '#a479f2'],
39  [/agent/i, '#7ccf8a'],
40  [/memory/i, '#f2a65a'],
41  [/skill/i, '#f28ab8'],
42  [/message/i, '#e86a5c'],
43]
44const FALLBACK = ['#5cc8d6', '#d6c35c', '#8fd65c', '#d65c8f']
45
46function colorOf(s: Segment, i: number): string {
47  if (s.kind === 'free') return FREE
48  if (s.kind === 'buffer') return BUFFER
49  return SEGMENT_COLORS.find(([re]) => re.test(s.name))?.[1] ?? FALLBACK[i % FALLBACK.length]!
50}
51
52function fmt(n: number): string {
53  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
54  if (n >= 1_000) return `${+(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`
55  return `${Math.round(n)}`
56}
57
58function moodOf(leftPct: number): Mood {
59  if (leftPct > 80) return 'happy'
60  if (leftPct > 60) return 'normal'
61  if (leftPct > 40) return 'worried'
62  return 'devastated'
63}
64
65// ---------- data ----------
66
67async function refreshUsage($: EngineInterface): Promise<void> {
68  const u = await $.session.usage({ breakdown: 'summary' })
69  const b = u.context.breakdown
70  const usage: Usage = {
71    tokens: u.context.tokens ?? null,
72    window: u.context.window,
73    percent: u.context.percent ?? null,
74    compactAt: b?.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
75    segments: (b?.categories ?? [])
76      .filter(c => c.kind !== 'deferred' && c.tokens > 0)
77      .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
78    barMax: b?.rawMaxTokens ?? u.context.window,
79  }
80  await update($, usageAtom, () => usage)
81}
82
83async function refreshRtk($: EngineInterface): Promise<void> {
84  const bin = await findRtk($)
85  if (!bin) return
86  const { exitCode, stdout } = await $.process.run([bin, 'gain', '--format', 'json'], { timeoutMs: 10_000 })
87  if (exitCode !== 0) return
88  const s = JSON.parse(stdout).summary
89  if (!s || typeof s.total_saved !== 'number') return
90  let base = await read($, rtkBaseline)
91  if (base === null || base > s.total_saved) {
92    base = s.total_saved as number
93    await update($, rtkBaseline, () => base)
94  }
95  const rtk: Rtk = {
96    total: s.total_saved,
97    session: s.total_saved - base,
98    avgPct: s.avg_savings_pct ?? 0,
99    commands: s.total_commands ?? 0,
100  }
101  await update($, rtkAtom, () => rtk)
102}
103
104const quietly = (p: Promise<unknown>) => p.catch(() => undefined)
105
106// ---------- setup (RTK, its hook, the Bash line, Matt Pocock's skills) ----------
107
108async function runQuiet($: EngineInterface, argv: string[], timeoutMs = 60_000) {
109  try {
110    return await $.process.run(argv, { timeoutMs })
111  } catch {
112    return null
113  }
114}
115
116// RTK may be installed but not yet on this process's PATH (winget adds it
117// for new processes only), so look where its installers put it too.
118let rtkPath: string | null = null
119
120async function findRtk($: EngineInterface): Promise<string | null> {
121  if (rtkPath) return rtkPath
122  const local = await $.env.get('LOCALAPPDATA')
123  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
124  const candidates = ['rtk']
125  if (local) {
126    candidates.push(`${local}/Microsoft/WinGet/Links/rtk.exe`)
127    try {
128      for (const d of await $.fs.list(`${local}/Microsoft/WinGet/Packages`))
129        if (d.name.startsWith('rtk-ai.rtk_')) candidates.push(`${local}/Microsoft/WinGet/Packages/${d.name}/rtk.exe`)
130    } catch {
131      // no winget packages folder
132    }
133  }
134  if (home) candidates.push(`${home}/.local/bin/rtk`, `${home}/.cargo/bin/rtk`)
135  candidates.push('/opt/homebrew/bin/rtk', '/usr/local/bin/rtk')
136  for (const c of candidates) {
137    const r = await runQuiet($, [c, '--version'], 10_000)
138    if (r && r.exitCode === 0) return (rtkPath = c)
139  }
140  return null
141}
142
143async function claudeBin($: EngineInterface): Promise<string> {
144  return (await $.env.get('CLAUDE_CODE_EXECPATH')) ?? 'claude'
145}
146
147async function platform($: EngineInterface): Promise<'windows' | 'mac' | 'linux'> {
148  if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
149  const r = await runQuiet($, ['uname', '-s'], 5_000)
150  return r?.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
151}
152
153async function claudeMdPath($: EngineInterface): Promise<string | null> {
154  const dir = await $.env.get('CLAUDE_CONFIG_DIR')
155  if (dir) return `${dir}/CLAUDE.md`
156  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
157  return home ? `${home}/.claude/CLAUDE.md` : null
158}
159
160async function readTextQuiet($: EngineInterface, path: string): Promise<string | null> {
161  try {
162    return String(await $.fs.read(path))
163  } catch {
164    return null
165  }
166}
167
168async function checkSetup($: EngineInterface): Promise<SetupStep[]> {
169  const missing: SetupStep[] = []
170  const rtk = await findRtk($)
171  if (!rtk) missing.push('rtk', 'hook')
172  else {
173    const shown = await runQuiet($, [rtk, 'init', '-g', '--show'])
174    if (!shown || !isHooked(shown.stdout)) missing.push('hook')
175  }
176  const md = await claudeMdPath($)
177  if (!hasBashLine(md ? await readTextQuiet($, md) : null)) missing.push('bash')
178  const list = await runQuiet($, [await claudeBin($), 'plugin', 'list', '--json'])
179  if (list && list.exitCode === 0 && !isEnabled(list.stdout, MATT_ID)) missing.push('matt')
180  return missing
181}
182
183async function setProgress($: EngineInterface, progress: string) {
184  await update($, setupAtom, s => (s ? { ...s, progress } : s))
185}
186
187async function doStep($: EngineInterface, step: SetupStep): Promise<string> {
188  if (step === 'rtk') {
189    const os = await platform($)
190    const r = await runQuiet($, RTK_INSTALL[os], 600_000)
191    rtkPath = null
192    return (await findRtk($)) ? '✓ Installed RTK' : `✗ Couldn't install RTK (${r ? (r.stderr || r.stdout).trim().slice(-200) : 'installer not found'}). ${RTK_MANUAL}`
193  }
194  if (step === 'hook') {
195    const rtk = await findRtk($)
196    if (!rtk) return '✗ Skipped the RTK hook: RTK is not installed'
197    const r = await runQuiet($, [rtk, 'init', '-g', '--auto-patch', '--no-trust-filters'])
198    return r && r.exitCode === 0 ? '✓ Hooked RTK into Claude Code' : `✗ \`rtk init -g\` failed: ${(r?.stderr || r?.stdout || '').trim().slice(-200)}`
199  }
200  if (step === 'bash') {
201    const path = await claudeMdPath($)
202    if (!path) return '✗ Could not find your home folder for CLAUDE.md'
203    const md = (await readTextQuiet($, path)) ?? ''
204    if (!hasBashLine(md)) await $.fs.write(path, withBashLine(md))
205    return `✓ Added the Bash-only line to ${path}`
206  }
207  const claude = await claudeBin($)
208  const markets = await runQuiet($, [claude, 'plugin', 'marketplace', 'list', '--json'])
209  if (!markets || !hasMarketplace(markets.stdout, OFFICIAL_MARKETPLACE)) {
210    await runQuiet($, [claude, 'plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_URL], 300_000)
211  }
212  const r = await runQuiet($, [claude, 'plugin', 'install', MATT_ID, '--scope', 'user'], 300_000)
213  return r && r.exitCode === 0 ? "✓ Installed Matt Pocock's skills" : `✗ Couldn't install Matt Pocock's skills: ${(r?.stderr || r?.stdout || '').trim().slice(-200)}`
214}
215
216// Runs every missing step in order; the band shows progress, then results.
217async function runSetup($: EngineInterface): Promise<string[]> {
218  const current = await read($, setupAtom)
219  if (current?.phase === 'running') return []
220  const missing = await checkSetup($)
221  await update($, setupAtom, () => ({ missing, phase: 'running' as const, progress: 'checking…', results: [] as string[] }))
222  const results: string[] = []
223  for (const step of STEP_ORDER.filter(s => missing.includes(s))) {
224    await setProgress($, `${STEP_LABEL[step]}…`)
225    results.push(await doStep($, step))
226  }
227  const left = await checkSetup($)
228  await update($, setupAtom, () => ({ missing: left, phase: 'done' as const, progress: '', results }))
229  void quietly(refreshRtk($))
230  $.ui.toast(left.length ? `Setup finished with ${left.length} step(s) left` : 'Setup complete. Restart Claude Code to load everything.')
231  return results
232}
233
234// ---------- drawing ----------
235
236function gridRects(grid: string[]): string {
237  const rects: string[] = []
238  grid.forEach((row, y) => {
239    let x = 0
240    while (x < row.length) {
241      const ch = row[x]!
242      let end = x + 1
243      while (end < row.length && row[end] === ch) end++
244      const fill = PALETTE[ch]
245      if (ch !== '.' && fill) rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fill}"/>`)
246      x = end
247    }
248  })
249  return rects.join('')
250}
251
252// "Salam" in a 5-row pixel font, for the speech bubble.
253const GLYPHS: Record<string, string[]> = {
254  S: ['###', '#..', '###', '..#', '###'],
255  a: ['...', '.##', '#.#', '#.#', '.##'],
256  l: ['#', '#', '#', '#', '#'],
257  m: ['.....', '####.', '#.#.#', '#.#.#', '#.#.#'],
258}
259
260function pixelText(text: string, x0: number, y0: number, fill: string): { svg: string; width: number } {
261  const out: string[] = []
262  let x = x0
263  for (const ch of text) {
264    const g = GLYPHS[ch]
265    if (!g) continue
266    g.forEach((row, y) => [...row].forEach((c, dx) => c === '#' && out.push(`<rect x="${x + dx}" y="${y0 + y}" width="1" height="1" fill="${fill}"/>`)))
267    x += g[0]!.length + 1
268  }
269  return { svg: out.join(''), width: x - x0 - 1 }
270}
271
272// Rows of headroom above him for the bubble.
273const HEADROOM = 11
274const GREET = '2.6s'
275
276function bubbleSvg(): string {
277  const text = pixelText('Salam', 0, 0, '#9b1b1e')
278  const w = text.width + 4 // 1px border + 1px padding each side
279  const x0 = Math.max(0, Math.floor((WIDTH - w) / 2))
280  const ink = '#1a1a1a'
281  const parts = [
282    // body with clipped corners, then border
283    `<rect x="${x0 + 1}" y="1" width="${w - 2}" height="7" fill="#fbf7ee"/>`,
284    `<rect x="${x0 + 1}" y="0" width="${w - 2}" height="1" fill="${ink}"/>`,
285    `<rect x="${x0 + 1}" y="8" width="${w - 2}" height="1" fill="${ink}"/>`,
286    `<rect x="${x0}" y="1" width="1" height="7" fill="${ink}"/>`,
287    `<rect x="${x0 + w - 1}" y="1" width="1" height="7" fill="${ink}"/>`,
288    // tail pointing down at his head
289    `<rect x="${x0 + 8}" y="8" width="3" height="1" fill="#fbf7ee"/>`,
290    `<rect x="${x0 + 7}" y="8" width="1" height="2" fill="${ink}"/>`,
291    `<rect x="${x0 + 8}" y="9" width="2" height="1" fill="#fbf7ee"/>`,
292    `<rect x="${x0 + 10}" y="9" width="1" height="1" fill="${ink}"/>`,
293    `<rect x="${x0 + 8}" y="10" width="2" height="1" fill="${ink}"/>`,
294    `<g transform="translate(${x0 + 2} 2)">${text.svg}</g>`,
295  ]
296  return `<g visibility="hidden">${parts.join('')}<set attributeName="visibility" to="visible" begin="hit.click" dur="${GREET}"/><animateTransform attributeName="transform" type="translate" values="0 2;0 0" dur="0.15s" begin="hit.click" fill="freeze"/></g>`
297}
298
299// Clicking him (desktop draws the Svg in a script-less frame, where SMIL still
300// runs) swaps in the waving pose and pops the bubble for a moment; no state.
301function characterSvg(mood: Mood): string {
302  const blink =
303    mood === 'devastated'
304      ? ''
305      : EYES.map(
306          r =>
307            `<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" fill="${SKIN}" opacity="0"><animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.93;0.96;1" dur="${mood === 'worried' ? 2.2 : 4.5}s" repeatCount="indefinite"/></rect>`,
308        ).join('')
309  const bob =
310    mood === 'happy'
311      ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.5;0 0" dur="1.6s" repeatCount="indefinite"/>`
312      : mood === 'worried'
313        ? `<animateTransform attributeName="transform" type="translate" values="0 0;0.3 0;0 0;-0.3 0;0 0" dur="0.5s" repeatCount="indefinite"/>`
314        : ''
315  const rest = `<g>${gridRects(characterGrid(mood, 'rest'))}${blink}<set attributeName="visibility" to="hidden" begin="hit.click" dur="${GREET}"/></g>`
316  const wave = `<g visibility="hidden">${gridRects(characterGrid(mood, 'wave'))}<set attributeName="visibility" to="visible" begin="hit.click" dur="${GREET}"/></g>`
317  const H = HEIGHT + HEADROOM
318  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${H}" width="${WIDTH * SCALE}" height="${H * SCALE}" shape-rendering="crispEdges">${bubbleSvg()}<g transform="translate(0 ${HEADROOM})"><g>${bob}${rest}${wave}</g></g><rect id="hit" x="0" y="${HEADROOM}" width="${WIDTH}" height="${HEIGHT}" fill="transparent" pointer-events="all" style="cursor:pointer"><title>Say salam</title></rect></svg>`
319}
320
321function barSvg(usage: Usage): string {
322  const W = 1000
323  const H = 14
324  const total = Math.max(usage.barMax, 1)
325  const segs = usage.segments.length
326    ? usage.segments
327    : [
328        { name: 'used', tokens: usage.tokens ?? 0, kind: 'used' as const },
329        { name: 'free', tokens: Math.max(usage.window - (usage.tokens ?? 0), 0), kind: 'free' as const },
330      ]
331  let x = 0
332  const parts = segs.map((s, i) => {
333    const w = (s.tokens / total) * W
334    const rect =
335      s.kind === 'buffer'
336        ? `<rect x="${x}" y="0" width="${w}" height="${H}" fill="url(#hatch)"/>`
337        : `<rect x="${x}" y="0" width="${w}" height="${H}" fill="${colorOf(s, i)}"/>`
338    x += w
339    return rect
340  })
341  const marker =
342    usage.compactAt !== null
343      ? `<rect x="${Math.min((usage.compactAt / total) * W, W - 3)}" y="0" width="3" height="${H}" fill="${ACCENT}"/>`
344      : ''
345  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none"><defs><pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="8" height="8" fill="${FREE}"/><rect width="3" height="8" fill="${BUFFER}"/></pattern><clipPath id="c"><rect width="${W}" height="${H}" rx="4"/></clipPath></defs><g clip-path="url(#c)"><rect width="${W}" height="${H}" fill="${FREE}"/>${parts.join('')}${marker}</g></svg>`
346}
347
348// Terminal: one Raster of half blocks, two pixel rows per cell.
349function characterRaster(mood: Mood): { cells: string; columns: number; rows: number } {
350  const grid = characterGrid(mood)
351  const rows = Math.ceil(HEIGHT / 2)
352  const words = new Uint32Array(WIDTH * rows * 3)
353  const DEFAULT = 0x01000000
354  const hex = (ch: string | undefined) => {
355    const c = ch && ch !== '.' ? PALETTE[ch] : undefined
356    return c ? parseInt(c.slice(1), 16) : DEFAULT
357  }
358  for (let r = 0; r < rows; r++) {
359    for (let x = 0; x < WIDTH; x++) {
360      const i = (r * WIDTH + x) * 3
361      words[i] = 0x2580 // ▀
362      words[i + 1] = hex(grid[r * 2]?.[x])
363      words[i + 2] = hex(grid[r * 2 + 1]?.[x])
364    }
365  }
366  return { cells: toBase64(new Uint8Array(words.buffer)), columns: WIDTH, rows }
367}
368
369function toBase64(bytes: Uint8Array): string {
370  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
371  let out = ''
372  for (let i = 0; i < bytes.length; i += 3) {
373    const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2]
374    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
375    out += A[(n >> 18) & 63]! + A[(n >> 12) & 63]! + (b === undefined ? '=' : A[(n >> 6) & 63]!) + (c === undefined ? '=' : A[n & 63]!)
376  }
377  return out
378}
379
380function textBar(usage: Usage, width: number): { color: string; text: string }[] {
381  const total = Math.max(usage.barMax, 1)
382  const segs = usage.segments.length
383    ? usage.segments
384    : [{ name: 'used', tokens: usage.tokens ?? 0, kind: 'used' as const }]
385  const out: { color: string; text: string }[] = []
386  let used = 0
387  segs.forEach((s, i) => {
388    if (s.kind === 'free') return
389    const n = Math.max(1, Math.round((s.tokens / total) * width))
390    if (used + n > width) return
391    used += n
392    out.push({ color: colorOf(s, i), text: (s.kind === 'buffer' ? '▒' : '█').repeat(n) })
393  })
394  if (used < width) out.push({ color: FREE, text: '░'.repeat(width - used) })
395  return out
396}
397
398// ---------- hooks ----------
399
400export const register: Register = on => {
401  on('session.start', async ($, e, next) => {
402    const result = await next(e)
403    await $.command.register({
404      name: SETUP_COMMAND,
405      description: "Set up RTK, its hook, the Bash-only CLAUDE.md line and Matt Pocock's skills",
406    })
407    void quietly(
408      (async () => {
409        const missing = await checkSetup($)
410        const prev = await read($, setupAtom)
411        if (prev?.phase === 'running') return
412        await update($, setupAtom, () => ({ missing, phase: (missing.length ? 'idle' : 'hidden') as Setup['phase'], progress: '', results: [] }))
413        await refreshRtk($)
414      })(),
415    )
416    return result
417  })
418
419  on('command.run', { command: SETUP_COMMAND }, async $ => {
420    const results = await runSetup($)
421    const left = (await read($, setupAtom))?.missing ?? []
422    return {
423      text: results.length
424        ? [...results, '', left.length ? `Still to do: ${missingSummary(left)}.` : 'All set. Restart Claude Code to load RTK\'s hook and Matt Pocock\'s skills.'].join('\n')
425        : 'Everything is already set up.',
426    }
427  })
428
429  // The band pops up once the first prompt of the session is sent.
430  on('prompt.submit', async ($, e, next) => {
431    await update($, isShown, () => true)
432    void quietly(refreshRtk($))
433    return next(e)
434  }).catch(($, e, next) => next(e)) // never hold a prompt back over the band
435
436  on('session.measure', async ($, e, next) => {
437    if (e.changed.includes('context')) await quietly(refreshUsage($))
438    return next(e)
439  })
440
441  on('turn.complete', async ($, e, next) => {
442    void quietly(refreshRtk($))
443    return next(e)
444  })
445
446  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
447    if (e.props.hasSurvey) return next(e)
448    const setup = await read($, setupAtom)
449    const shown = await read($, isShown)
450    const needsSetup = !!setup && (setup.phase === 'running' || setup.phase === 'done' || (setup.phase === 'idle' && setup.missing.length > 0))
451    if (!shown && !needsSetup) return next(e)
452
453    // The first-run setup row: shown until setup is done or put off.
454    const ui = $.ui.resolve(e)
455    const hide = () => update($, setupAtom, s => (s ? { ...s, phase: 'hidden' as const } : s))
456    const setupRow = !needsSetup || !setup ? null : setup.phase === 'running' ? (
457      <ui.Box key="setup" flexDirection="row" gap={1} borderStyle="round" borderColor={ACCENT} paddingX={1}>
458        <ui.Text color={ACCENT} bold>Setting up</ui.Text>
459        <ui.Text dimColor>{setup.progress}</ui.Text>
460      </ui.Box>
461    ) : setup.phase === 'done' ? (
462      <ui.Box key="setup" flexDirection="column" borderStyle="round" borderColor={setup.missing.length ? '#f2c14e' : RTK} paddingX={1}>
463        {setup.results.map(r => (
464          <ui.Text>{r}</ui.Text>
465        ))}
466        <ui.Box flexDirection="row" gap={1} alignItems="center">
467          <ui.Text dimColor>
468            {setup.missing.length
469              ? `Still to do: ${missingSummary(setup.missing)}.`
470              : "Restart Claude Code to load RTK's hook and Matt Pocock's skills."}
471          </ui.Text>
472          {setup.missing.length ? <ui.Button key="retry" label="Try again" onPress={() => void runSetup($)} /> : null}
473          <ui.Button key="ok" label="OK" onPress={() => void hide()} />
474        </ui.Box>
475      </ui.Box>
476    ) : (
477      <ui.Box key="setup" flexDirection="row" gap={1} alignItems="center" flexWrap="wrap" borderStyle="round" borderColor={ACCENT} paddingX={1}>
478        <ui.Text color={ACCENT} bold>Finish setup:</ui.Text>
479        <ui.Text dimColor>{`${missingSummary(setup.missing)}.`}</ui.Text>
480        <ui.Button key="setup-run" variant="primary" label="Finish setup" onPress={() => void runSetup($)} />
481        <ui.Button key="setup-later" label="Later" onPress={() => void hide()} />
482      </ui.Box>
483    )
484    const withSetup = (band: ReturnType<typeof ui.Box>) =>
485      setupRow ? (
486        <ui.Box flexDirection="column" gap={1}>
487          {setupRow}
488          {band}
489        </ui.Box>
490      ) : (
491        band
492      )
493    if (!shown) return setupRow ?? next(e)
494
495    const usage = await read($, usageAtom)
496    const rtk = await read($, rtkAtom)
497    const pct = usage?.percent ?? 0
498    const left = 100 - pct
499    const mood = moodOf(left)
500    const window = usage?.window ?? 0
501
502    const legend = (usage?.segments ?? [])
503      .map((s, i) => ({ s, color: colorOf(s, i) }))
504      .filter(({ s }) => s.kind !== 'buffer')
505
506    const headline = usage
507      ? `${usage.tokens !== null ? fmt(usage.tokens) : '–'} of ${fmt(window)}${usage.compactAt !== null ? ` · compacts at ${fmt(usage.compactAt)}` : ''}`
508      : 'measuring after the first reply…'
509
510    const rtkTotal = rtk ? fmt(rtk.total) : '–'
511
512    if (e.surface === 'terminal') {
513      const { Box, Text, Raster } = $.ui.resolve(e)
514      const art = characterRaster(mood)
515      const barWidth = Math.max(10, e.props.bodyColumns - WIDTH - 8)
516      return withSetup(
517        <Box flexDirection="row" alignItems="flex-end" gap={2}>
518          <Box flexDirection="column" flexGrow={1} borderStyle="round" borderDimColor paddingX={1}>
519            <Box flexDirection="row" justifyContent="space-between">
520              <Box flexDirection="row" gap={1}>
521                <Text color={ACCENT} bold>◆ context</Text>
522                <Text dimColor>{headline}</Text>
523                <Text bold color={ACCENT}>{`[${Math.round(pct)}%]`}</Text>
524              </Box>
525              <Text color={RTK} bold>{`⚡ RTK ${rtkTotal}`}</Text>
526            </Box>
527            <Box flexDirection="row">
528              {usage ? textBar(usage, barWidth).map(p => <Text color={p.color}>{p.text}</Text>) : <Text dimColor>…</Text>}
529            </Box>
530            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
531              {legend.map(({ s, color }) => (
532                <Box flexDirection="row" gap={1}>
533                  <Text color={color}>■</Text>
534                  <Text dimColor>{`${s.name.toLowerCase()} ${fmt(s.tokens)}`}</Text>
535                </Box>
536              ))}
537            </Box>
538          </Box>
539          <Raster key="yemeni" columns={art.columns} rows={art.rows} cells={art.cells} />
540        </Box>,
541      )
542    }
543
544    const table = $.ui.resolve(e)
545    const { Box, Text } = table
546    const Svg = 'Svg' in table ? table.Svg : undefined
547
548    // Layout A: the band is a framed card; he stands on his own to its right,
549    // feet level with the band's bottom edge, right above the prompt.
550    return withSetup(
551      <Box flexDirection="row" alignItems="flex-end" gap={2}>
552        <Box flexDirection="row" alignItems="center" gap={2} flexGrow={1} flexShrink={1} borderStyle="round" borderDimColor paddingX={1}>
553          <Box flexDirection="column" flexGrow={1} flexShrink={1} gap={1}>
554            <Box flexDirection="row" justifyContent="space-between" alignItems="center">
555              <Text color={ACCENT} bold>◆ context</Text>
556              <Box flexDirection="row" gap={1} alignItems="center">
557                <Text dimColor>{headline}</Text>
558                <Text bold color="#1b1b1b" backgroundColor={left > 40 ? '#9be3a0' : left > 30 ? '#f2c14e' : '#f07a6a'}>
559                  {` ${Math.round(pct)}% `}
560                </Text>
561              </Box>
562            </Box>
563            {Svg && usage ? <Svg source={barSvg(usage)} alt={`Context ${Math.round(pct)}% used`} /> : null}
564            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
565              {legend.map(({ s, color }) => (
566                <Box flexDirection="row" gap={1}>
567                  <Text color={color}>■</Text>
568                  <Text dimColor>{s.name.toLowerCase()}</Text>
569                  <Text bold>{fmt(s.tokens)}</Text>
570                </Box>
571              ))}
572            </Box>
573          </Box>
574          <Text bold color={RTK}>{`⚡ RTK ${rtkTotal}`}</Text>
575        </Box>
576        {Svg ? (
577          <Svg
578            source={characterSvg(mood)}
579            alt={`Pixel Yemeni companion, ${mood}`}
580            width={WIDTH * SCALE}
581            height={(HEIGHT + HEADROOM) * SCALE}
582            isInteractive
583          />
584        ) : null}
585      </Box>,
586    )
587  })
588}
589
hooks/character.ts 341 lines
1// Variant A "chibi": full-body Yemeni man, 24x40, transparent background.
2// Pure data module, no imports.
3
4export type Mood = 'happy' | 'normal' | 'worried' | 'devastated'
5export type Pose = 'rest' | 'wave'
6
7export const WIDTH = 24
8export const HEIGHT = 40
9
10// one char per key -> '#rrggbb'. '.' = transparent (not in PALETTE).
11export const PALETTE: Record<string, string> = {
12  K: '#1a1210', // outline / pupils
13  R: '#9a1f2a', // turban red
14  r: '#651219', // turban shade
15  G: '#f0b03a', // embroidery gold
16  g: '#c46d1c', // embroidery orange
17  H: '#231814', // hair / beard / brows
18  S: '#d29467', // skin
19  s: '#ad6f45', // skin shade
20  n: '#e8786a', // blush
21  W: '#f7f5ef', // white cloth / eye white / teeth
22  w: '#c4c7cf', // white shade
23  L: '#7a3f1f', // leather
24  l: '#4c2412', // dark leather
25  Y: '#f2c94c', // gold studs / brass
26  J: '#3a2418', // jambiya handle
27  A: '#ecd2a8', // jambiya sheath
28  a: '#c09a68', // sheath shade
29  M: '#7c2620', // lips / mouth line
30  m: '#3d0d0d', // mouth interior
31  t: '#e0707a', // tongue
32  D: '#4aa8ee', // tears / sweat
33  d: '#b6e4ff', // tear highlight
34  k: '#34343c', // socks / shoes
35  v: '#6a6a78', // shoe highlight
36}
37
38export const SKIN = PALETTE.S
39
40// Eye rects for the blink overlay (normal head position; devastated head is 1 row lower).
41export const EYES: { x: number; y: number; w: number; h: number }[] = [
42  { x: 7, y: 11, w: 3, h: 2 },
43  { x: 14, y: 11, w: 3, h: 2 },
44]
45
46type Grid = string[][]
47
48function blank(): Grid {
49  const g: Grid = []
50  for (let y = 0; y < HEIGHT; y++) {
51    const row: string[] = []
52    for (let x = 0; x < WIDTH; x++) row.push('.')
53    g.push(row)
54  }
55  return g
56}
57// Drawing coordinates are shifted down 1 row so the outline fits above the turban.
58function px(g: Grid, x: number, y0: number, c: string): void {
59  const y = y0 + 1
60  if (x >= 0 && y >= 0 && x < WIDTH && y < HEIGHT) g[y]![x] = c
61}
62function rect(g: Grid, x0: number, y0: number, x1: number, y1: number, c: string): void {
63  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) px(g, x, y, c)
64}
65function span(g: Grid, y: number, x0: number, x1: number, c: string): void {
66  rect(g, x0, y, x1, y, c)
67}
68function mspan(g: Grid, y: number, x0: number, x1: number, c: string): void {
69  span(g, y, x0, x1, c)
70  span(g, y, WIDTH - 1 - x1, WIDTH - 1 - x0, c)
71}
72function stamp(g: Grid, x0: number, y0: number, rows: string[]): void {
73  for (let y = 0; y < rows.length; y++)
74    for (let x = 0; x < rows[y]!.length; x++) {
75      const c = rows[y]![x]
76      if (c !== '.') px(g, x0 + x, y0 + y, c!)
77    }
78}
79
80function drawLegs(g: Grid): void {
81  // wizar (long white skirt) down to the shins
82  rect(g, 3, 26, 20, 31, 'W')
83  rect(g, 4, 32, 19, 33, 'W')
84  span(g, 33, 4, 19, 'w')
85  for (let y = 27; y <= 32; y++) px(g, 8, y, 'w')
86  for (let y = 29; y <= 32; y++) px(g, 16, y, 'w')
87  // black socks + loafers
88  rect(g, 6, 34, 8, 35, 'k')
89  rect(g, 15, 34, 17, 35, 'k')
90  stamp(g, 4, 36, ['.kkkk', 'kkkkk'])
91  stamp(g, 15, 36, ['kkkk.', 'kkkkk'])
92  px(g, 5, 36, 'v')
93  px(g, 18, 36, 'v')
94}
95
96function drawTorso(g: Grid, slump: boolean, wave: boolean): void {
97  const o = slump ? 1 : 0
98  // puffy raised shoulders (flattened when slumped)
99  if (!slump) {
100    mspan(g, 15, 2, 5, 'W')
101    mspan(g, 16, 1, 6, 'W')
102    mspan(g, 15, 5, 5, 'w')
103  } else {
104    mspan(g, 16, 2, 5, 'W')
105  }
106  mspan(g, 17, 1, 8, 'W')
107  rect(g, 1, 18, 22, 25, 'W')
108  mspan(g, 18 + o, 1, 2, 'w')
109  // arm seams
110  for (let y = 19; y <= 25; y++) {
111    px(g, 3, y, 'w')
112    px(g, 20, y, 'w')
113  }
114  // neck + collar
115  rect(g, 9, 16, 14, 18, 'S')
116  span(g, 17, 9, 14, 's')
117  mspan(g, 18, 8, 9, 'W')
118  span(g, 19, 10, 13, 'w')
119
120  // crossed bandoliers
121  for (let y = 17; y <= 23; y++) {
122    const x0 = 3 + 2 * (y - 17)
123    span(g, y, x0 - 1, x0 + 3, 'l')
124    span(g, y, x0, x0 + 2, 'L')
125    px(g, x0 + 1, y, 'Y')
126  }
127  for (let y = 17; y <= 23; y++) {
128    const x0 = WIDTH - 1 - (3 + 2 * (y - 17)) - 2
129    span(g, y, x0 - 1, x0 + 3, 'l')
130    span(g, y, x0, x0 + 2, 'L')
131    if (y % 2 === 0) px(g, x0 + 1, y, 'Y')
132  }
133
134  // belt with brass cartridges
135  span(g, 23, 1, 22, 'l')
136  span(g, 24, 1, 22, 'L')
137  span(g, 25, 1, 22, 'l')
138  for (let x = 2; x < WIDTH; x += 2) px(g, x, 24, 'Y')
139  for (const x of [2, 4, 6, 17, 19, 21]) {
140    px(g, x, 21, 'g')
141    px(g, x, 22, 'Y')
142  }
143
144  // jambiya
145  stamp(g, 9, 19, [
146    'KKKKKK..',
147    'KYYYYK..',
148    '.KJJK...',
149    '.KJJK...',
150    'KYJJYK..',
151    'KAAAAK..',
152    'KAAAAaK.',
153    '.KAAAAaK',
154    '..KKAAaK',
155    '....KKK.',
156  ])
157
158  if (!slump) {
159    // his right hand gripping the jambiya handle (viewer left)
160    stamp(g, 4, 20, ['.wwwKSSSK', 'wwwwKSSsK', '.KKKKKKK.'])
161    // other arm hanging, hand at the side (raised instead when waving)
162    if (!wave) {
163      rect(g, 21, 26, 22, 27, 'S')
164      px(g, 21, 27, 's')
165    }
166  } else {
167    // both arms hanging limp
168    rect(g, 1, 26, 2, 27, 'S')
169    px(g, 2, 27, 's')
170    if (!wave) {
171      rect(g, 21, 26, 22, 27, 'S')
172      px(g, 21, 27, 's')
173    }
174  }
175}
176
177function drawHead(g: Grid, dy: number): void {
178  const y = (v: number) => v + dy
179  // face
180  rect(g, 5, y(8), 18, y(15), 'S')
181  span(g, y(16), 6, 17, 'S')
182  span(g, y(17), 8, 15, 'S')
183  px(g, 5, y(15), 's')
184  px(g, 18, y(15), 's')
185  px(g, 6, y(16), 's')
186  px(g, 17, y(16), 's')
187  // ears
188  px(g, 4, y(10), 'S')
189  px(g, 4, y(11), 's')
190  px(g, 19, y(10), 'S')
191  px(g, 19, y(11), 's')
192  // nose
193  px(g, 12, y(11), 's')
194  span(g, y(12), 11, 12, 's')
195  // curly hair at the sides
196  stamp(g, 3, y(8), ['HH', 'H.', '..', '..', 'HH', '.H'])
197  stamp(g, 19, y(8), ['H', 'H'])
198  // mustache + beard line + goatee
199  span(g, y(13), 9, 14, 'H')
200  px(g, 9, y(14), 'H')
201  px(g, 14, y(14), 'H')
202  px(g, 9, y(15), 'H')
203  px(g, 14, y(15), 'H')
204  span(g, y(16), 10, 13, 'H')
205  span(g, y(17), 10, 13, 'H')
206  span(g, y(18), 11, 12, 'H')
207
208  // turban
209  span(g, y(0), 8, 15, 'R')
210  span(g, y(1), 6, 17, 'R')
211  rect(g, 4, y(2), 19, y(6), 'R')
212  span(g, y(7), 5, 18, 'r')
213  stamp(g, 7, y(1), [
214    '.gGGggGGg.',
215    'gGgGGGGgGg',
216    'GgGgGGgGgG',
217    '.gGgggGgg.',
218  ])
219  span(g, y(5), 4, 19, 'r')
220  rect(g, 18, y(2), 19, y(4), 'r')
221  for (let x = 5; x <= 18; x += 2) px(g, x, y(6), 'G')
222  // wrap tail draping beside the neck (viewer right)
223  rect(g, 19, y(6), 21, y(15), 'R')
224  rect(g, 18, y(13), 22, y(17), 'R')
225  for (let v = 7; v <= 17; v++) px(g, 21, y(v), 'r')
226  px(g, 20, y(10), 'g')
227  px(g, 19, y(14), 'g')
228  px(g, 21, y(16), 'G')
229}
230
231// His left arm (viewer right) raised with an open palm beside the turban: "salam".
232function drawWave(g: Grid, dy: number): void {
233  stamp(g, 16, 1 + dy, [
234    '..K.K.K.',
235    '.KSKSKSK',
236    '.KSKSKSK',
237    'KKSSSSSK',
238    'KSSSSSsK',
239    '.KKSSSsK',
240    '..KsSSsK',
241    '..KKWWWK',
242    '...KWWWK',
243    '...KWWWK',
244    '...KWWWK',
245    '...KWWWK',
246    '...KwWWK',
247    '...KwWWK',
248    '...KwWWK',
249    '...KwWWK',
250  ])
251}
252
253function drawFace(g: Grid, mood: Mood, dy: number): void {
254  const y = (v: number) => v + dy
255  if (mood === 'happy') {
256    span(g, y(8), 7, 9, 'H')
257    span(g, y(8), 14, 16, 'H')
258    stamp(g, 7, y(10), ['.K.', 'K.K'])
259    stamp(g, 14, y(10), ['.K.', 'K.K'])
260    span(g, y(12), 5, 7, 'n')
261    span(g, y(12), 16, 18, 'n')
262    px(g, 8, y(13), 'H')
263    px(g, 15, y(13), 'H')
264    stamp(g, 9, y(14), ['MWWWWM', '.MmmM.'])
265  } else if (mood === 'normal') {
266    span(g, y(9), 7, 9, 'H')
267    span(g, y(9), 14, 16, 'H')
268    stamp(g, 7, y(10), ['WKK', 'WKK'])
269    stamp(g, 14, y(10), ['KKW', 'KKW'])
270    stamp(g, 10, y(14), ['M..M', '.MM.'])
271  } else if (mood === 'worried') {
272    stamp(g, 7, y(8), ['..H', 'HH.'])
273    stamp(g, 14, y(8), ['H..', '.HH'])
274    stamp(g, 7, y(10), ['WKW', 'WKW'])
275    stamp(g, 14, y(10), ['WKW', 'WKW'])
276    stamp(g, 10, y(14), ['.M.M', 'M.M.'])
277    stamp(g, 17, y(8), ['.d', 'dD', 'DD'])
278  } else {
279    stamp(g, 7, y(8), ['..H', '.H.', 'H..'])
280    stamp(g, 14, y(8), ['H..', '.H.', '..H'])
281    stamp(g, 7, y(10), ['.KK', 'KDD'])
282    stamp(g, 14, y(10), ['KK.', 'DDK'])
283    for (let v = 12; v <= 16; v++) {
284      px(g, 7, y(v), v % 2 ? 'd' : 'D')
285      px(g, 16, y(v), v % 2 ? 'd' : 'D')
286    }
287    stamp(g, 10, y(14), ['MmmM', 'mttm', 'MMMM'])
288  }
289}
290
291// Transparent pixels touching the figure (8-neighbourhood) become outline.
292function outline(g: Grid): void {
293  const out: [number, number][] = []
294  for (let y = 0; y < HEIGHT; y++)
295    for (let x = 0; x < WIDTH; x++) {
296      if (g[y]![x] !== '.') continue
297      let hit = false
298      for (let dy = -1; dy <= 1 && !hit; dy++)
299        for (let dx = -1; dx <= 1 && !hit; dx++) {
300          if (!dx && !dy) continue
301          if (dx && dy) continue // 4-neighbourhood keeps corners crisp
302          const nx = x + dx
303          const ny = y + dy
304          if (nx < 0 || ny < 0 || nx >= WIDTH || ny >= HEIGHT) continue
305          const c = g[ny]![nx]
306          if (c !== '.' && c !== 'K') hit = true
307        }
308      if (hit) out.push([x, y])
309    }
310  for (const [x, y] of out) g[y]![x] = 'K'
311}
312
313export function characterGrid(mood: Mood, pose: Pose = 'rest'): string[] {
314  const g = blank()
315  const slump = mood === 'devastated'
316  const wave = pose === 'wave'
317  const dy = slump ? 1 : 0
318  drawLegs(g)
319  drawTorso(g, slump, wave)
320  drawHead(g, dy)
321  if (wave) drawWave(g, dy)
322  drawFace(g, mood, dy)
323  outline(g)
324  return g.map((row) => row.join(''))
325}
326
327function selfCheck(): void {
328  for (const m of ['happy', 'normal', 'worried', 'devastated'] as Mood[]) {
329    for (const pose of ['rest', 'wave'] as Pose[]) {
330      const rows = characterGrid(m, pose)
331      const id = `${m}/${pose}`
332      if (rows.length !== HEIGHT) throw new Error(`character: ${id} has ${rows.length} rows`)
333      rows.forEach((r, y) => {
334        if (r.length !== WIDTH) throw new Error(`character: ${id} row ${y} has ${r.length} chars`)
335        for (const ch of r) if (ch !== '.' && !(ch in PALETTE)) throw new Error(`character: ${id} row ${y} bad key '${ch}'`)
336      })
337    }
338  }
339}
340selfCheck()
341
hooks/setup.ts 71 lines
1// First-run setup: everything the band needs besides itself. RTK
2// (https://github.com/rtk-ai/rtk) and Matt Pocock's skills are separate
3// projects; this plugin installs them from their official sources and never
4// bundles them. The engine calls live in register.tsx; this file holds the
5// steps, the words and the plain logic.
6
7import type { SetupStep } from '../types'
8
9export const SETUP_COMMAND = 'yemeni'
10
11export const STEP_LABEL: Record<SetupStep, string> = {
12  rtk: 'install RTK',
13  hook: 'hook RTK into Claude Code',
14  bash: 'add a Bash-only line to your CLAUDE.md',
15  matt: "install Matt Pocock's skills",
16}
17
18export const STEP_ORDER: SetupStep[] = ['rtk', 'hook', 'bash', 'matt']
19
20export const MATT_ID = 'mattpocock-skills@claude-plugins-official'
21export const OFFICIAL_MARKETPLACE = 'claude-plugins-official'
22export const OFFICIAL_MARKETPLACE_URL = 'https://github.com/anthropics/claude-plugins-official.git'
23
24export const BASH_LINE =
25  'Use the Bash tool for shell commands (git, npm, builds, tests, linters, docker) so RTK can compress their output. Only use PowerShell for Windows-specific tasks such as winget, .ps1 scripts or the registry.'
26const BASH_MARK = 'so RTK can compress their output'
27
28export const RTK_INSTALL: Record<'windows' | 'mac' | 'linux', string[]> = {
29  windows: ['winget', 'install', '--id', 'rtk-ai.rtk', '-e', '--source', 'winget', '--accept-package-agreements', '--accept-source-agreements', '--silent'],
30  mac: ['brew', 'install', 'rtk'],
31  linux: ['sh', '-c', 'curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh'],
32}
33
34export const RTK_MANUAL = [
35  'Install RTK yourself, then run /yemeni again:',
36  '- Windows: `winget install rtk-ai.rtk`',
37  '- macOS: `brew install rtk`',
38  '- Linux: `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`',
39].join('\n')
40
41export const isHooked = (showOutput: string) =>
42  /\[ok\] Hook/.test(showOutput) && /\[ok\] settings\.json/.test(showOutput)
43
44export const hasBashLine = (md: string | null) => !!md?.includes(BASH_MARK)
45
46export function withBashLine(md: string): string {
47  const sep = md === '' || md.endsWith('\n\n') ? '' : md.endsWith('\n') ? '\n' : '\n\n'
48  return `${md}${sep}${BASH_LINE}\n`
49}
50
51// `claude plugin list --json`: is the plugin there and enabled?
52export function isEnabled(listJson: string, id: string): boolean {
53  try {
54    const list = JSON.parse(listJson) as { id?: string; enabled?: boolean }[]
55    return list.some(p => p.id === id && p.enabled !== false)
56  } catch {
57    return false
58  }
59}
60
61export function hasMarketplace(listJson: string, name: string): boolean {
62  try {
63    return (JSON.parse(listJson) as { name?: string }[]).some(m => m.name === name)
64  } catch {
65    return false
66  }
67}
68
69export const missingSummary = (missing: SetupStep[]) =>
70  STEP_ORDER.filter(s => missing.includes(s)).map(s => STEP_LABEL[s]).join(', ')
71
types/index.d.ts 34 lines
1export type Segment = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
2
3export type Usage = {
4  tokens: number | null
5  window: number
6  percent: number | null
7  compactAt: number | null
8  segments: Segment[]
9  barMax: number
10}
11
12export type Rtk = { total: number; session: number; avgPct: number; commands: number }
13
14export type SetupStep = 'rtk' | 'hook' | 'bash' | 'matt'
15
16export type Setup = {
17  missing: SetupStep[]
18  phase: 'idle' | 'running' | 'done' | 'hidden'
19  progress: string
20  results: string[]
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'yemeni-context-band': {
26      isShown: boolean
27      usage: Usage | null
28      rtk: Rtk | null
29      rtkBaseline: number | null
30      setup: Setup | null
31    }
32  }
33}
34