SLOPSHOPPER

vg

The latest from VG in the spinner: a red VG tile beside the newest articles on vg.no (up to 20), rotating, with links

newspinnercommandnetworktimer
v0.1.0MITupdated 2026-10-25whirlwin/claude-code-vg
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vg
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /vg ⎿ vg: The spinner no longer shows VG. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-vg

A Claude Code mod that puts the newest VG articles in the spinner. While Claude works, a small red VG tile takes the spinner's place, beside one of the newest articles on vg.no (up to 20): rank and title on the first line, and under it the section and age as VG words them, such as Nyheter · for 23 minutter siden.

The spinner rotating through the newest articles while Claude runs a command

A new article every six seconds, newest first and round again. The rotation only moves while it is on screen, so the next turn picks up where the last one stopped. Each time the article changes, the letters roll up out of the tile and back in from below, like a ticker; halfway through an article a band sweeps across it.

The tile's roll and sweep, frame by frame

The title links to the article on vg.no. It is a terminal hyperlink (OSC 8): in iTerm2, Ghostty, kitty or WezTerm, cmd-click or ctrl-click it to open it. A terminal without them draws the URL after the text instead, and the line is cut to fit.

Install

/plugin install vg --marketplace whirlwin/claude-code-vg

Answer y to add the marketplace, then pick a scope.

The three themes, Halloween, Hacker News and VG, are also listed together at themes.whirlwin.io, where this mod installs as vg@whirlwin-themes:

/plugin marketplace add https://themes.whirlwin.io/marketplace.json
/plugin install vg@whirlwin-themes

Install it from one marketplace or the other, not both.

Update

/plugin update installs from the local clone of the marketplace and does not fetch it first, so refresh the clone before updating:

claude plugin marketplace update claude-code-vg
claude plugin update vg@claude-code-vg

Installed from themes.whirlwin.io, it is whirlwin-themes in place of claude-code-vg in both. Then run /reload-plugins in an open session, or restart Claude Code.

Use

CommandWhat it does
/vghide the articles, or bring them back; says why they could not be fetched, if they could not
/vg nextskip straight to the next article

With other mods

Only one mod can draw its own spinner, and the halloween and hackernews mods draw theirs too. With one of them installed, which one shows depends on the order the engine runs their hooks in, which it does not promise for installed plugins. /vg hides this one for the session; claude plugin disable vg@claude-code-vg (or vg@whirlwin-themes) keeps it off.

The spotify mod puts the song after the spinner's word. In the terminal the article takes the place of that word, so the song does not show while an article does; in the desktop app the song follows the article.

How it works

The articles come from VG's public RSS feed, https://www.vg.no/rss/feed/?limit=100, newest first, in one request through the engine's $.http.fetch. Most of its hundred items are goal clips, so the mod keeps only real articles: an item that links under a section, /<section>/i/<id>/<slug>, and has a lead paragraph. That leaves out the goal clips, which have no lead, and the partner items from E24 and MinMote, which link under no section. It keeps up to 20, ranked from 1 in the feed's order, so an item left out leaves no gap.

The mod fetches while a turn runs, at most every ten minutes (the feed's own ttl); between turns only /vg fetches, and only when it has no list yet. A fetch that fails, or gets no answer within eight seconds, keeps the last list on screen; with no list yet, it tries again after 30 seconds, and the spinner shows its own word until one arrives. The first list starts the rotation at rank 1.

Everything the feed holds is treated as untrusted, since a title is drawn inside a terminal hyperlink: entities and CDATA are decoded, control and bidirectional override characters are removed from titles and sections, and an item whose link is not https on www.vg.no is left out.

It reads the RSS feed rather than the front page because VG's terms do not allow scraping vg.no, so it shows the newest articles rather than the order VG's editors give the front page.

The tile is a Raster of quadrant block glyphs, two colors to a cell, repainted with $.ui.blit on a 150 ms clock rather than redrawing the whole tree. Drawing a tree in the spinner's place drops what the engine draws after the word (elapsed time, tokens, effort), so the mod shows its own elapsed time. The token count is not shown. While the engine shows a message of its own in the spinner, the message replaces the article. In the desktop app only the word changes, to the article's rank and title.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .
node docs/preview.mjs docs/preview.png

The preview script needs Node 22.18 or later, to import hooks/logo.ts without a compile step.

The plugin is named vg rather than after the repository because Claude Code reserves plugin names starting with claude-. It is not affiliated with VG or Schibsted.

License

MIT, see LICENSE.

Source 4 files
hooks/register.tsx 248 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { REFRESH_MS, byline, fetchLatest, label } from './vg'
5import type { Article } from './vg'
6import { FRAMES_PER_STORY, LOGO_COLUMNS, LOGO_ROWS, RED, TICK_MS, frame } from './logo'
7
8const isHidden = atom({ plugin: 'vg', key: 'isHidden' } as const, false)
9
10const RASTER = 'vg'
11// Rounded down, so the timer is redrawn a little more often than each second
12// rather than a little less, which skipped a second now and then.
13const TICKS_PER_SECOND = Math.floor(1000 / TICK_MS)
14const RANK_COLOR = `#${RED.toString(16).padStart(6, '0')}`
15// How often a running turn checks whether the feed is due.
16const POLL_MS = 1000
17// With no list to show at all, a failed fetch is tried again well before
18// REFRESH_MS, so a blip does not leave the spinner without an article for ten
19// minutes.
20const RETRY_MS = 30 * 1000
21// $.http.fetch has no timeout of its own, and a request that never answers
22// would hold every later refresh behind it. Under the 10 s hook budget, as
23// /vg waits for a fetch.
24const FETCH_TIMEOUT_MS = 8 * 1000
25
26// The feed as last fetched. An object rather than variables of register's
27// own: the engine lets `$` be handed only to a function declared at the top
28// of the file, and refresh needs both.
29type Feed = {
30  articles: Article[]
31  // Why the last fetch failed, for /vg to say; null when it worked.
32  problem: string | null
33  attemptedAt: number | null
34  // The fetch under way, which a second caller joins rather than sending
35  // another request for the feed beside it.
36  fetching: Promise<void> | null
37}
38
39function elapsed(ms: number): string {
40  const s = Math.floor(ms / 1000)
41  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
42}
43
44function refresh($: EngineInterface, feed: Feed): Promise<void> {
45  feed.fetching ??= (async () => {
46    let timer: Timer | undefined
47    try {
48      feed.attemptedAt = await $.clock.now()
49      const timeout = new Promise<never>((_, reject) => {
50        timer = $.clock.after(FETCH_TIMEOUT_MS, () => {
51          reject(new Error(`VG did not answer within ${FETCH_TIMEOUT_MS / 1000} seconds`))
52        })
53      })
54      feed.articles = await Promise.race([fetchLatest(url => $.http.fetch(url)), timeout])
55      feed.problem = null
56      $.ui.invalidate('ui.render')
57    } catch (error) {
58      // The list stays as it was, and so does the drawing: stale headlines
59      // beat none.
60      feed.problem = error instanceof Error ? error.message : String(error)
61    } finally {
62      timer?.cancel()
63    }
64  })().finally(() => {
65    feed.fetching = null
66  })
67  return feed.fetching
68}
69
70export const register: Register = on => {
71  let tick = 0
72  // The spinner as last drawn, which the timer repaints between draws.
73  let spinner: string | null = null
74  // Whether a surface without a Raster shows an article as the spinner's word.
75  // Nothing there can be blitted, so no deny says it went: the main turn's end
76  // does.
77  let isWordShown = false
78  // When each spinner first drew: drawing a tree replaces the engine's own
79  // elapsed time, so the mod keeps its own.
80  const startedAt = new Map<string, number>()
81  let isTurnRunning = false
82  const feed: Feed = { articles: [], problem: null, attemptedAt: null, fetching: null }
83
84  function articleAt(t: number): Article | null {
85    return feed.articles[Math.floor(t / FRAMES_PER_STORY) % feed.articles.length] ?? null
86  }
87
88  on('session.start', async ($, e, next) => {
89    await $.command.register({
90      name: 'vg',
91      description: 'Show or hide the newest VG articles in the spinner; "next" skips to the next article',
92      argumentHint: '[next]',
93    })
94
95    // Only while something shows an article, so the rotation picks up at the
96    // next turn where it left off rather than having moved on unseen. A word
97    // drawn after the turn ended (a last desktop draw) shows nothing for long.
98    $.clock.every(TICK_MS, () => {
99      if (spinner === null && !(isWordShown && isTurnRunning)) return
100      tick += 1
101      // The article and the timer are text, so a new article or second is a
102      // redraw; a surface showing only the word keeps its own timer.
103      if (tick % FRAMES_PER_STORY === 0 || (spinner !== null && tick % TICKS_PER_SECOND === 0)) {
104        $.ui.invalidate('ui.render')
105        return
106      }
107      if (spinner === null) return
108      const requestId = spinner
109      void $.ui.blit({ requestId, key: RASTER, cells: frame(tick) }).then(result => {
110        if (result.deny !== undefined && spinner === requestId) {
111          spinner = null
112          startedAt.delete(requestId)
113        }
114      })
115    })
116
117    // Only while a turn runs: between turns there is no spinner to show it.
118    $.clock.every(POLL_MS, () => {
119      if (!isTurnRunning || feed.fetching !== null) return
120      void (async () => {
121        const wait = feed.articles.length === 0 && feed.problem !== null ? RETRY_MS : REFRESH_MS
122        if (feed.attemptedAt !== null && (await $.clock.now()) - feed.attemptedAt < wait) return
123        if (await read($, isHidden)) return
124        // The tile has rolled on while there was no list: the first one
125        // starts at rank 1.
126        const hadNone = feed.articles.length === 0
127        await refresh($, feed)
128        if (hadNone && feed.articles.length > 0) tick = 0
129      })()
130    })
131
132    return next(e)
133  })
134
135  on('turn.start', async ($, e, next) => {
136    // A subagent's or teammate's run starts here too, and ends with an
137    // agentId, so only the main loop's start starts the fetches.
138    if (e.agentId === undefined) isTurnRunning = true
139    return next(e)
140  })
141
142  on('turn.complete', async ($, e, next) => {
143    // A subagent's turn ends here too, inside the main turn, so only the main
144    // loop's end stops the fetches. The list is kept: the next turn shows it
145    // at once.
146    if (e.agentId === undefined) {
147      isTurnRunning = false
148      isWordShown = false
149      // A spinner hidden mid-turn was never denied a blit, so its start would
150      // otherwise carry over into the next turn's time.
151      startedAt.clear()
152    }
153    return next(e)
154  })
155
156  on('command.run', { command: 'vg' }, async ($, e) => {
157    const isNext = e.args.trim() === 'next'
158    const hadNone = feed.articles.length === 0
159    if (isNext) {
160      tick = (Math.floor(tick / FRAMES_PER_STORY) + 1) * FRAMES_PER_STORY
161      await update($, isHidden, () => false)
162      $.ui.invalidate('ui.render')
163    } else if (await update($, isHidden, was => !was)) {
164      return { text: 'The spinner no longer shows VG.' }
165    }
166    // Between turns nothing fetches, so with no list yet the answer would
167    // have nothing to say. The first list starts at rank 1, skipped or not.
168    if (hadNone) {
169      await refresh($, feed)
170      if (feed.articles.length > 0) tick = 0
171    }
172    const article = articleAt(tick)
173    if (isNext) {
174      if (article !== null) return { text: `Next up: ${label(article)}` }
175      if (feed.problem !== null) return { text: `The articles could not be fetched: ${feed.problem}` }
176      return { text: 'The VG feed is still loading.' }
177    }
178    const shows = 'The spinner shows the newest VG articles'
179    if (feed.problem === null) return { text: `${shows}.` }
180    if (article === null) return { text: `${shows}, but they could not be fetched: ${feed.problem}` }
181    return { text: `${shows} as last fetched, but they could not be fetched again: ${feed.problem}` }
182  })
183
184  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
185    if (await read($, isHidden)) {
186      spinner = null
187      isWordShown = false
188      return next(e)
189    }
190    const article = articleAt(tick)
191    if (e.surface !== 'terminal') {
192      if (article === null) return next(e)
193      isWordShown = true
194      return next({ ...e, props: { ...e.props, word: label(article) } })
195    }
196
197    const now = await $.clock.now()
198    const start = startedAt.get(e.requestId) ?? now
199    startedAt.set(e.requestId, start)
200    spinner = e.requestId
201    const time = ` (${elapsed(now - start)})`
202    const { Box, Link, Raster, Text } = $.ui.resolve(e)
203
204    let lines
205    if (article === null || e.props.message !== null) {
206      // The engine's own state (compacting, say) outranks the article, and
207      // before the first list there is no article: either way its words show.
208      const text = e.props.message ?? e.props.word
209      // As the engine draws it: its ellipsis left off a text ending in one,
210      // a suffix another mod rewrote drawn as handed.
211      const suffix = text.endsWith('\u2026') && e.props.suffix === '\u2026' ? '' : e.props.suffix
212      lines = (
213        <Text wrap="truncate-end">
214          {text}
215          {suffix}
216          <Text dimColor>{time}</Text>
217        </Text>
218      )
219    } else {
220      // The second line starts under the title, so the rank stands alone at
221      // the left. Every article is on vg.no, so no domain follows the title.
222      const rank = `${article.rank}.`
223      lines = [
224        <Text wrap="truncate-end">
225          <Text color={RANK_COLOR}>{rank}</Text>{' '}
226          <Link href={article.href}>{article.title}</Link>
227        </Text>,
228        <Text dimColor wrap="truncate-end">
229          {' '.repeat(rank.length + 1)}
230          {byline(article, now)}
231          {time}
232        </Text>,
233      ]
234    }
235
236    // One line centers on the three-row tile; two cannot, and centering them
237    // flipped between the top rows and the bottom ones, so they sit at the top.
238    return (
239      <Box flexDirection="row">
240        <Raster key={RASTER} columns={LOGO_COLUMNS} rows={LOGO_ROWS} cells={frame(tick)} />
241        <Box flexDirection="column" justifyContent={Array.isArray(lines) ? 'flex-start' : 'center'} marginLeft={1}>
242          {lines}
243        </Box>
244      </Box>
245    )
246  })
247}
248
hooks/vg.ts 204 lines
1// The newest articles on vg.no, from VG's public RSS feed: VG's terms forbid
2// scraping the front page, so the feed is the only source. Every response is
3// untrusted: it is parsed field by field and drawn on a terminal.
4
5export const FEED_URL = 'https://www.vg.no/rss/feed/?limit=100'
6// The feed is newest first and mostly goal clips, so a hundred items leave
7// about this many articles.
8export const TOP_COUNT = 20
9// The feed's own <ttl>.
10export const REFRESH_MS = 10 * 60 * 1000
11
12// The engine's Link takes an href of at most 2048 characters once
13// percent-encoded, so an article linking past this is left out.
14const MAX_HREF = 2048
15
16// C0 and C1 controls, which could end the OSC 8 hyperlink a title is drawn
17// in, and bidi overrides and isolates, which could reorder the line around it.
18const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
19
20// VG's own articles sit under a section, /sport/i/77aWL9/<slug>. The partner
21// items from E24 and MinMote link to /i/<id>/<slug>, under none.
22const ARTICLE_PATH = /^\/([a-z0-9-]+)\/i\/([A-Za-z0-9]+)\/[^/]+$/
23
24const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
25
26export type Article = {
27  // The short id in the link, the 77aWL9 of /sport/i/77aWL9/.
28  id: string
29  // The position among the articles kept, so the clips between them leave
30  // no gap.
31  rank: number
32  title: string
33  href: string
34  // As VG labels it: Sport, Nyheter, Rampelys.
35  section: string
36  // Unix milliseconds.
37  time: number
38}
39
40// The subset of $.http.fetch's result this module reads, so it stays testable
41// without the engine.
42export type Fetch = (url: string) => Promise<{ ok: boolean; status: number; text: string }>
43
44// CDATA is text that may hold markup. Escaped back into character data up
45// front, a </title> or </item> inside one ends nothing, and every text is
46// then decoded alike.
47function unwrapCdata(xml: string): string {
48  let out = ''
49  let at = 0
50  for (;;) {
51    const open = xml.indexOf('<![CDATA[', at)
52    if (open === -1) break
53    const close = xml.indexOf(']]>', open + 9)
54    // No later section can close either, so the scan ends here.
55    if (close === -1) break
56    const text = xml.slice(open + 9, close)
57    out += xml.slice(at, open) + text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
58    at = close + 3
59  }
60  return out + xml.slice(at)
61}
62
63function* elements(text: string, name: string): Generator<string | null> {
64  const open = new RegExp(`<${name}(?:\\s[^<>]*?)?(/?)>`, 'g')
65  const close = new RegExp(`</${name}\\s*>`, 'g')
66  for (let m = open.exec(text); m !== null; m = open.exec(text)) {
67    if (m[1] === '/') {
68      yield null
69      continue
70    }
71    close.lastIndex = open.lastIndex
72    const end = close.exec(text)
73    if (end === null) return
74    yield text.slice(open.lastIndex, end.index)
75    open.lastIndex = close.lastIndex
76  }
77}
78
79// In one pass, so `&amp;lt;` reads as `&lt;` rather than `<`. A reference to
80// no character (past U+10FFFF, or half a surrogate pair) is dropped, as
81// String.fromCodePoint would throw on the first. Before cleaning, so `&#27;`
82// is stripped as the escape it stands for.
83function decode(text: string): string {
84  return text.replace(
85    /&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|lt|gt|quot|apos));/g,
86    (_, dec: string | undefined, hex: string | undefined, name: string | undefined) => {
87      if (name !== undefined) return ENTITIES[name] ?? ''
88      const code = dec !== undefined ? Number(dec) : parseInt(hex ?? '', 16)
89      if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return ''
90      return String.fromCodePoint(code)
91    },
92  )
93}
94
95// Whitespace first, so a tab, newline or NEL (controls too, and NEL is not in
96// \s) stays a word break instead of gluing two words; again after, for the
97// spaces a removed control leaves side by side.
98function clean(text: string): string {
99  return text.replace(/[\s\u0085]+/g, ' ').replace(UNSAFE, '').replace(/\s+/g, ' ').trim()
100}
101
102// The decoded text of an item's first `name` element, '' when it is written
103// empty or self-closing, null when there is none. Anchored on `<`, so `link`
104// does not find <vg:waplink>.
105function field(item: string, name: string): string | null {
106  for (const text of elements(item, name)) return decode(text ?? '')
107  return null
108}
109
110// The serialized URL rather than the string VG sent: serializing
111// percent-encodes controls and bidi characters, which in the raw string
112// would break out of the hyperlink just as they would in a title.
113function articleLink(text: string | null): { id: string; href: string; section: string } | null {
114  if (text === null) return null
115  let url: URL
116  try {
117    url = new URL(text)
118  } catch {
119    return null
120  }
121  // The host rather than the hostname, so a port of its own is refused too.
122  if (url.protocol !== 'https:' || url.host !== 'www.vg.no') return null
123  // A user name ahead of the host would show as another site's address
124  // wherever the terminal prints the link.
125  if (url.username !== '' || url.password !== '') return null
126  if (url.href.length > MAX_HREF) return null
127  const path = ARTICLE_PATH.exec(url.pathname)
128  if (path === null) return null
129  const [, section = '', id = ''] = path
130  return { id, href: url.href, section: section.charAt(0).toUpperCase() + section.slice(1) }
131}
132
133/**
134 * The first TOP_COUNT articles of the feed in its order, newest first,
135 * leaving out the clips, the partner items and whatever could not be read.
136 */
137export function parseFeed(xml: string): Article[] {
138  const articles: Article[] = []
139  const ids = new Set<string>()
140  for (const item of elements(unwrapCdata(xml), 'item')) {
141    if (item === null) continue
142    if (articles.length === TOP_COUNT) break
143    // An item listed twice would show twice in the rotation.
144    const link = articleLink(field(item, 'link'))
145    if (link === null || ids.has(link.id)) continue
146    const title = clean(field(item, 'title') ?? '')
147    // A goal clip links under a section like an article, but has no lead.
148    const lead = clean(field(item, 'description') ?? '')
149    const time = Date.parse((field(item, 'pubDate') ?? '').trim())
150    if (title === '' || lead === '' || !Number.isFinite(time)) continue
151    ids.add(link.id)
152    articles.push({
153      id: link.id,
154      rank: articles.length + 1,
155      title,
156      href: link.href,
157      section: clean(field(item, 'category') ?? '') || link.section,
158      time,
159    })
160  }
161  return articles
162}
163
164/**
165 * The newest articles on vg.no, from one request for the feed. Throws, in
166 * words fit to show the person, when the feed fails or holds none. A request
167 * that fails or is refused rejects with the host's own reason.
168 */
169export async function fetchLatest(fetch: Fetch): Promise<Article[]> {
170  const feed = await fetch(FEED_URL)
171  if (!feed.ok) throw new Error(`the VG feed answered ${feed.status}`)
172  const articles = parseFeed(feed.text)
173  if (articles.length === 0) throw new Error('the VG feed held no articles')
174  return articles
175}
176
177/**
178 * How long ago `timeMs` was, as VG words it. An article from the future (the
179 * clock here behind VG's) reads as just published.
180 */
181export function ago(nowMs: number, timeMs: number): string {
182  const minutes = Math.max(1, Math.floor((nowMs - timeMs) / 60_000))
183  if (minutes < 60) return minutes === 1 ? 'for 1 minutt siden' : `for ${minutes} minutter siden`
184  const hours = Math.floor(minutes / 60)
185  if (hours < 24) return hours === 1 ? 'for 1 time siden' : `for ${hours} timer siden`
186  const days = Math.floor(hours / 24)
187  return days === 1 ? 'for 1 dag siden' : `for ${days} dager siden`
188}
189
190/**
191 * `Nyheter · for 23 minutter siden`.
192 */
193export function byline(article: Article, nowMs: number): string {
194  return `${article.section} · ${ago(nowMs, article.time)}`
195}
196
197/**
198 * `1. Oversvømmelse: E6 stengt mellom Lillehammer og Biri`, the spinner's
199 * word where it cannot draw a link.
200 */
201export function label(article: Article): string {
202  return `${article.rank}. ${article.title}`
203}
204
hooks/logo.ts 114 lines
1// The spinner's VG tile: a canvas of quadrant pixels, four to a terminal cell,
2// packed into Raster cells.
3
4export const LOGO_COLUMNS = 6
5export const LOGO_ROWS = 3
6export const TICK_MS = 150
7// An article stays up for 6 s at TICK_MS.
8export const FRAMES_PER_STORY = 40
9// Sampled from VG's own logo image, https://vgc.no/gfx/vg-rss.png.
10export const RED = 0xdd1f02
11export const WHITE = 0xffffff
12
13const WIDTH = LOGO_COLUMNS * 2
14const HEIGHT = LOGO_ROWS * 2
15
16// The tile at rest, '#' white and '.' red, red to every edge so no pixel
17// falls back to the terminal's own background.
18const TILE = [
19  '............',
20  '.#...#.####.',
21  '.#...#.#....',
22  '..#.#..#.##.',
23  '...#...####.',
24  '............',
25]
26
27// The letters' offset from rest over an article's first frames: up and out
28// (-5 clears the tile), then back in from below, like a ticker flipping.
29const ROLL = [-1, -2, -3, -4, -5, 4, 3, 2, 1]
30// Midway through an article a shine crosses the tile, one cell column a frame.
31const SHINE_AT = 20
32
33type Canvas = Uint32Array
34
35function set(c: Canvas, x: number, y: number, color: number): void {
36  if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) c[y * WIDTH + x] = color
37}
38
39function get(c: Canvas, x: number, y: number): number {
40  return c[y * WIDTH + x] ?? RED
41}
42
43// A band exactly one cell column wide flips each cell under it whole, to its
44// complement glyph, and crosses the tile in LOGO_COLUMNS frames.
45function shine(c: Canvas, f: number): void {
46  const col = f - SHINE_AT
47  if (col < 0 || col >= LOGO_COLUMNS) return
48  for (let y = 0; y < HEIGHT; y++) {
49    for (const x of [col * 2, col * 2 + 1]) set(c, x, y, get(c, x, y) === WHITE ? RED : WHITE)
50  }
51}
52
53// Exported for docs/preview.mjs, which draws the README's strip from it.
54export function pixels(t: number): Uint32Array {
55  const f = t % FRAMES_PER_STORY
56  const c: Canvas = new Uint32Array(WIDTH * HEIGHT).fill(RED)
57  // The border rows are plain red, so shifting the whole picture moves only
58  // the letters; `set` drops whatever lands outside the tile.
59  const offset = ROLL[f] ?? 0
60  for (let y = 0; y < HEIGHT; y++) {
61    const line = TILE[y] ?? ''
62    for (let x = 0; x < WIDTH; x++) {
63      if (line[x] === '#') set(c, x, y + offset, WHITE)
64    }
65  }
66  shine(c, f)
67  return c
68}
69
70// Indexed by which quadrants hold the foreground: 1 upper left, 2 upper
71// right, 4 lower left, 8 lower right.
72const QUADRANTS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
73
74// Every cell is white on red, so the glyph alone says which quadrants are
75// white: a plain red cell is a space, a shined one a full block.
76export function paint(t: number): Uint32Array {
77  const c = pixels(t)
78  const cells = new Uint32Array(LOGO_COLUMNS * LOGO_ROWS * 3)
79  for (let row = 0; row < LOGO_ROWS; row++) {
80    for (let col = 0; col < LOGO_COLUMNS; col++) {
81      const x = col * 2
82      const y = row * 2
83      const quadrants = [get(c, x, y), get(c, x + 1, y), get(c, x, y + 1), get(c, x + 1, y + 1)]
84      const mask = quadrants.reduce((m, p, i) => (p === WHITE ? m | (1 << i) : m), 0)
85      cells.set([QUADRANTS.charCodeAt(mask), WHITE, RED], (row * LOGO_COLUMNS + col) * 3)
86    }
87  }
88  return cells
89}
90
91const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
92
93// Raster cells are little-endian u32s; typed arrays use the host's order,
94// which is little-endian on every platform Claude Code ships for.
95function encode(words: Uint32Array): string {
96  const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
97  let out = ''
98  for (let i = 0; i < bytes.length; i += 3) {
99    const a = bytes[i] ?? 0
100    const b = bytes[i + 1] ?? 0
101    const d = bytes[i + 2] ?? 0
102    const n = (a << 16) | (b << 8) | d
103    out += ALPHABET[(n >> 18) & 63]
104    out += ALPHABET[(n >> 12) & 63]
105    out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '='
106    out += i + 2 < bytes.length ? ALPHABET[n & 63] : '='
107  }
108  return out
109}
110
111export function frame(t: number): string {
112  return encode(paint(t))
113}
114
types/index.d.ts 8 lines
1export type IsHidden = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'vg': { isHidden: IsHidden }
6  }
7}
8