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

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.

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 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.
/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.
/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.
| Command | What it does |
|---|---|
/vg | hide the articles, or bring them back; says why they could not be fetched, if they could not |
/vg next | skip straight to the next article |
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.
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.
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.
MIT, see LICENSE.
hooks/register.tsx 248 lines1import { 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}
248hooks/vg.ts 204 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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 `&lt;` reads as `<` 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 ``
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}
204hooks/logo.ts 114 lines1// 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}
114types/index.d.ts 8 lines1export type IsHidden = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'vg': { isHidden: IsHidden }
6 }
7}
8