The Hacker News front page in the spinner: an animated HN tile beside the top 30 stories, rotating, with links

A Claude Code mod that puts the Hacker News front page in the spinner. While Claude works, a small orange HN tile takes the spinner's place, beside one of the top 30 stories as HN lays it out: rank and title on the first line, points, author, age and comments under it.

A new story every six seconds, rank 1 to 30 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 story changes, the letters roll up out of the tile and back in from below, like a ticker; halfway through a story a band sweeps across it.

The title links to the article (to the HN thread for an Ask HN), and the comments link to the HN thread. They are terminal hyperlinks (OSC 8): in iTerm2, Ghostty, kitty or WezTerm, cmd-click or ctrl-click one to open it. A terminal without them draws the URL after the text instead, and the line is cut to fit.
/plugin install hackernews --marketplace whirlwin/claude-code-hackernews
Answer y to add the marketplace, then pick a scope.
/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-hackernews
claude plugin update hackernews@claude-code-hackernews
Then run /reload-plugins in an open session, or restart Claude Code.
| Command | What it does |
|---|---|
/hackernews | hide the front page, or bring it back; says why it could not be fetched, if it could not |
/hackernews next | skip straight to the next story |
Only one mod can draw its own spinner: with another installed that does, which one shows depends on the order the engine runs their hooks in, which it does not promise for installed plugins. /hackernews hides this one for the session; claude plugin disable hackernews@claude-code-hackernews keeps it off.
The spotify mod puts the song after the spinner's word, and the story takes the place of that word, so the song does not show while a story does.
The stories come from the official Hacker News API: topstories.json for the front page's order, then one request per story for its title, points, author, age and comment count, 31 requests in parallel through the engine's $.http.fetch. The mod fetches while a turn runs, at most every five minutes; between turns only /hackernews 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.
The ranks are the front page's own: a story that is dead, deleted or cannot be read is left out, and the rest keep their ranks. Everything the API returns is treated as untrusted, since a title is drawn inside a terminal hyperlink: control and bidirectional override characters are removed from titles and author names, and only http and https URLs become links.
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 story. In the desktop app only the word changes, to the story'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 hackernews rather than after the repository because Claude Code reserves plugin names starting with claude-. It is not affiliated with Y Combinator or Hacker News.
MIT, see LICENSE.
hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { REFRESH_MS, byline, comments, fetchTop, label } from './hn'
5import type { Story } from './hn'
6import { FRAMES_PER_STORY, LOGO_COLUMNS, LOGO_ROWS, ORANGE, TICK_MS, frame } from './logo'
7
8const isHidden = atom({ plugin: 'hackernews', key: 'isHidden' } as const, false)
9
10const RASTER = 'hn'
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 = `#${ORANGE.toString(16).padStart(6, '0')}`
15// How often a running turn checks whether the front page 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 a story for five
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// /hackernews waits for a fetch.
24const FETCH_TIMEOUT_MS = 8 * 1000
25
26// The front page as last fetched. An object rather than variables of
27// register's own: the engine lets `$` be handed only to a function declared
28// at the top of the file, and refresh needs both.
29type Front = {
30 stories: Story[]
31 // Why the last fetch failed, for /hackernews 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 starting
35 // another round of requests 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, front: Front): Promise<void> {
45 front.fetching ??= (async () => {
46 let timer: Timer | undefined
47 try {
48 front.attemptedAt = await $.clock.now()
49 const timeout = new Promise<never>((_, reject) => {
50 timer = $.clock.after(FETCH_TIMEOUT_MS, () => {
51 reject(new Error(`Hacker News did not answer within ${FETCH_TIMEOUT_MS / 1000} seconds`))
52 })
53 })
54 front.stories = await Promise.race([fetchTop(url => $.http.fetch(url)), timeout])
55 front.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 front.problem = error instanceof Error ? error.message : String(error)
61 } finally {
62 timer?.cancel()
63 }
64 })().finally(() => {
65 front.fetching = null
66 })
67 return front.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 a story 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 front: Front = { stories: [], problem: null, attemptedAt: null, fetching: null }
83
84 function storyAt(t: number): Story | null {
85 return front.stories[Math.floor(t / FRAMES_PER_STORY) % front.stories.length] ?? null
86 }
87
88 on('session.start', async ($, e, next) => {
89 await $.command.register({
90 name: 'hackernews',
91 description: 'Show or hide the Hacker News front page in the spinner; "next" skips to the next story',
92 argumentHint: '[next]',
93 })
94
95 // Only while something shows a story, so the rotation picks up at the next
96 // turn where it left off rather than having moved on unseen. A word drawn
97 // 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 story and the timer are text, so a new story 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 || front.fetching !== null) return
120 void (async () => {
121 const wait = front.stories.length === 0 && front.problem !== null ? RETRY_MS : REFRESH_MS
122 if (front.attemptedAt !== null && (await $.clock.now()) - front.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 = front.stories.length === 0
127 await refresh($, front)
128 if (hadNone && front.stories.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: 'hackernews' }, async ($, e) => {
157 const isNext = e.args.trim() === 'next'
158 const hadNone = front.stories.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 Hacker News.' }
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($, front)
170 if (front.stories.length > 0) tick = 0
171 }
172 const story = storyAt(tick)
173 if (isNext) {
174 if (story !== null) return { text: `Next up: ${label(story)}` }
175 if (front.problem !== null) return { text: `The front page could not be fetched: ${front.problem}` }
176 return { text: 'The front page is still loading.' }
177 }
178 const shows = 'The spinner shows the Hacker News front page'
179 if (front.problem === null) return { text: `${shows}.` }
180 if (story === null) return { text: `${shows}, but it could not be fetched: ${front.problem}` }
181 return { text: `${shows} as last fetched, but it could not be fetched again: ${front.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 story = storyAt(tick)
191 if (e.surface !== 'terminal') {
192 if (story === null) return next(e)
193 isWordShown = true
194 return next({ ...e, props: { ...e.props, word: label(story) } })
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 (story === null || e.props.message !== null) {
206 // The engine's own state (compacting, say) outranks the story, and
207 // before the first list there is no story: 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, as HN indents it.
221 const rank = `${story.rank}.`
222 lines = [
223 <Text wrap="truncate-end">
224 <Text color={RANK_COLOR}>{rank}</Text>{' '}
225 <Link href={story.href}>{story.title}</Link>
226 {story.domain === null ? null : <Text dimColor> ({story.domain})</Text>}
227 </Text>,
228 <Text dimColor wrap="truncate-end">
229 {' '.repeat(rank.length + 1)}
230 {byline(story, now)}
231 {story.comments === null ? null : [' | ', <Link href={story.threadHref}>{comments(story.comments)}</Link>]}
232 {time}
233 </Text>,
234 ]
235 }
236
237 // One line centers on the three-row tile; two cannot, and centering them
238 // flipped between the top rows and the bottom ones, so they sit at the top.
239 return (
240 <Box flexDirection="row">
241 <Raster key={RASTER} columns={LOGO_COLUMNS} rows={LOGO_ROWS} cells={frame(tick)} />
242 <Box flexDirection="column" justifyContent={Array.isArray(lines) ? 'flex-start' : 'center'} marginLeft={1}>
243 {lines}
244 </Box>
245 </Box>
246 )
247 })
248}
249hooks/hn.ts 206 lines1// The Hacker News front page from the official Firebase API. Every response
2// is untrusted: it is parsed field by field and drawn on a terminal.
3
4export const TOP_STORIES_URL = 'https://hacker-news.firebaseio.com/v0/topstories.json'
5// As many stories as news.ycombinator.com shows on its first page.
6export const TOP_COUNT = 30
7// The front page moves slowly, and each refresh costs one request per story.
8export const REFRESH_MS = 5 * 60 * 1000
9
10// The engine's Link takes an href of at most 2048 characters once
11// percent-encoded, so past this the story links to its thread instead.
12const MAX_HREF = 2048
13
14// C0 and C1 controls, which could end the OSC 8 hyperlink a title is drawn
15// in, and bidi overrides and isolates, which could reorder the line around it.
16const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
17
18export type Story = {
19 id: number
20 // The position in topstories.json, so a skipped item leaves a gap rather
21 // than renumbering the stories below it.
22 rank: number
23 title: string
24 href: string
25 domain: string | null
26 // Unix seconds.
27 time: number
28 // All three null for a job post, which HN shows with only its age; `by` is
29 // null as well when the item names no author.
30 points: number | null
31 by: string | null
32 comments: number | null
33 threadHref: string
34}
35
36// The subset of $.http.fetch's result this module reads, so it stays testable
37// without the engine.
38export type Fetch = (url: string) => Promise<{ ok: boolean; status: number; text: string }>
39
40export function itemUrl(id: number): string {
41 return `https://hacker-news.firebaseio.com/v0/item/${id}.json`
42}
43
44export function threadUrl(id: number): string {
45 return `https://news.ycombinator.com/item?id=${id}`
46}
47
48function isPositiveInt(value: unknown): value is number {
49 return Number.isSafeInteger(value) && (value as number) > 0
50}
51
52function count(value: unknown): number {
53 return Number.isSafeInteger(value) && (value as number) >= 0 ? (value as number) : 0
54}
55
56// Whitespace first, so a tab, newline or NEL (controls too, and NEL is not in
57// \s) stays a word break instead of gluing two words; again after, for the
58// spaces a removed control leaves side by side.
59function clean(text: string): string {
60 return text.replace(/[\s\u0085]+/g, ' ').replace(UNSAFE, '').replace(/\s+/g, ' ').trim()
61}
62
63// The serialized URL rather than the string HN sent: serializing
64// percent-encodes controls and bidi characters, which in the raw string
65// would break out of the hyperlink just as they would in a title.
66function article(url: unknown): { href: string; domain: string } | null {
67 if (typeof url !== 'string') return null
68 let parsed: URL
69 try {
70 parsed = new URL(url)
71 } catch {
72 return null
73 }
74 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
75 if (parsed.href.length > MAX_HREF) return null
76 return { href: parsed.href, domain: parsed.hostname.replace(/^www\./, '') || parsed.hostname }
77}
78
79/**
80 * The first TOP_COUNT story ids of topstories.json, or null when the body is
81 * not a list of ids.
82 */
83export function parseTopIds(text: string): number[] | null {
84 let value: unknown
85 try {
86 value = JSON.parse(text)
87 } catch {
88 return null
89 }
90 if (!Array.isArray(value)) return null
91 const ids: unknown[] = value.slice(0, TOP_COUNT)
92 return ids.every(isPositiveInt) ? ids : null
93}
94
95/**
96 * The story an item answered, at `rank` on the front page, or null when it
97 * is gone, is not a front page item, or does not hold one.
98 */
99export function parseItem(text: string, rank: number): Story | null {
100 let value: unknown
101 try {
102 value = JSON.parse(text)
103 } catch {
104 return null
105 }
106 if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
107 const item = value as Record<string, unknown>
108 if (item.dead === true || item.deleted === true) return null
109 const { id, type, time } = item
110 if (type !== 'story' && type !== 'job' && type !== 'poll') return null
111 if (!isPositiveInt(id) || !isPositiveInt(time)) return null
112 const title = typeof item.title === 'string' ? clean(item.title) : ''
113 if (title === '') return null
114 const link = article(item.url)
115 const threadHref = threadUrl(id)
116 const isJob = type === 'job'
117 // Jobs carry a score and an author in the API, but HN shows neither. The
118 // author is drawn on the same line as the title, so it is cleaned as one.
119 const by = !isJob && typeof item.by === 'string' ? clean(item.by) : ''
120 return {
121 id,
122 rank,
123 title,
124 href: link?.href ?? threadHref,
125 domain: link?.domain ?? null,
126 points: isJob ? null : count(item.score),
127 by: by === '' ? null : by,
128 time,
129 comments: isJob ? null : count(item.descendants),
130 threadHref,
131 }
132}
133
134/**
135 * The front page in rank order, leaving out the items that could not be read.
136 * Throws, in words fit to show the person, when the list itself cannot be
137 * read or none of its stories can. A request for the list that fails or is
138 * refused rejects with the host's own reason.
139 */
140export async function fetchTop(fetch: Fetch): Promise<Story[]> {
141 const top = await fetch(TOP_STORIES_URL)
142 if (!top.ok) throw new Error(`topstories.json answered ${top.status}`)
143 const ids = parseTopIds(top.text)
144 if (ids === null) throw new Error('topstories.json did not hold a list of story ids')
145 if (ids.length === 0) throw new Error('topstories.json listed no stories')
146 const stories = await Promise.all(
147 ids.map(async (id, i) => {
148 try {
149 const answer = await fetch(itemUrl(id))
150 if (!answer.ok) return null
151 const story = parseItem(answer.text, i + 1)
152 // An item answering for another id would link to the wrong thread.
153 return story?.id === id ? story : null
154 } catch {
155 return null
156 }
157 }),
158 )
159 const read = stories.filter((story): story is Story => story !== null)
160 if (read.length === 0) throw new Error(`none of the top ${ids.length} stories could be read`)
161 return read
162}
163
164export function points(n: number): string {
165 return n === 1 ? '1 point' : `${n} points`
166}
167
168/**
169 * The comment count as HN words it, `discuss` while there are none.
170 */
171export function comments(n: number): string {
172 if (n === 0) return 'discuss'
173 return n === 1 ? '1 comment' : `${n} comments`
174}
175
176/**
177 * How long ago `timeSec` was, as HN words it. A story from the future (the
178 * clock here behind HN's) reads as just posted.
179 */
180export function ago(nowMs: number, timeSec: number): string {
181 const minutes = Math.max(1, Math.floor((nowMs / 1000 - timeSec) / 60))
182 if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`
183 const hours = Math.floor(minutes / 60)
184 if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`
185 const days = Math.floor(hours / 24)
186 return days === 1 ? '1 day ago' : `${days} days ago`
187}
188
189/**
190 * `233 points by whirlwin 1 hour ago`, or just the age for a job. The
191 * comments are left out: they are drawn on their own, as a link.
192 */
193export function byline(story: Story, nowMs: number): string {
194 const age = ago(nowMs, story.time)
195 if (story.points === null) return age
196 const by = story.by === null ? '' : ` by ${story.by}`
197 return `${points(story.points)}${by} ${age}`
198}
199
200/**
201 * `7. Mistral Large 4`, the spinner's word where it cannot draw a link.
202 */
203export function label(story: Story): string {
204 return `${story.rank}. ${story.title}`
205}
206hooks/logo.ts 113 lines1// The spinner's HN 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// A story stays up for 6 s at TICK_MS.
8export const FRAMES_PER_STORY = 40
9export const ORANGE = 0xff6600
10export const WHITE = 0xffffff
11
12const WIDTH = LOGO_COLUMNS * 2
13const HEIGHT = LOGO_ROWS * 2
14
15// The tile at rest, '#' white and '.' orange, orange to every edge so no pixel
16// falls back to the terminal's own background.
17const TILE = [
18 '............',
19 '.#..#.#...#.',
20 '.####.##..#.',
21 '.#..#.#.#.#.',
22 '.#..#.#..##.',
23 '............',
24]
25
26// The letters' offset from rest over a story's first frames: up and out (-5
27// clears the tile), then back in from below, like a ticker flipping.
28const ROLL = [-1, -2, -3, -4, -5, 4, 3, 2, 1]
29// Midway through a story a shine crosses the tile, one cell column a frame.
30const SHINE_AT = 20
31
32type Canvas = Uint32Array
33
34function set(c: Canvas, x: number, y: number, color: number): void {
35 if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) c[y * WIDTH + x] = color
36}
37
38function get(c: Canvas, x: number, y: number): number {
39 return c[y * WIDTH + x] ?? ORANGE
40}
41
42// A band exactly one cell column wide flips each cell under it whole, to its
43// complement glyph, and crosses the tile in LOGO_COLUMNS frames.
44function shine(c: Canvas, f: number): void {
45 const col = f - SHINE_AT
46 if (col < 0 || col >= LOGO_COLUMNS) return
47 for (let y = 0; y < HEIGHT; y++) {
48 for (const x of [col * 2, col * 2 + 1]) set(c, x, y, get(c, x, y) === WHITE ? ORANGE : WHITE)
49 }
50}
51
52// Exported for docs/preview.mjs, which draws the README's strip from it.
53export function pixels(t: number): Uint32Array {
54 const f = t % FRAMES_PER_STORY
55 const c: Canvas = new Uint32Array(WIDTH * HEIGHT).fill(ORANGE)
56 // The border rows are plain orange, so shifting the whole picture moves only
57 // the letters; `set` drops whatever lands outside the tile.
58 const offset = ROLL[f] ?? 0
59 for (let y = 0; y < HEIGHT; y++) {
60 const line = TILE[y] ?? ''
61 for (let x = 0; x < WIDTH; x++) {
62 if (line[x] === '#') set(c, x, y + offset, WHITE)
63 }
64 }
65 shine(c, f)
66 return c
67}
68
69// Indexed by which quadrants hold the foreground: 1 upper left, 2 upper
70// right, 4 lower left, 8 lower right.
71const QUADRANTS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
72
73// Every cell is white on orange, so the glyph alone says which quadrants are
74// white: a plain orange cell is a space, a shined one a full block.
75export function paint(t: number): Uint32Array {
76 const c = pixels(t)
77 const cells = new Uint32Array(LOGO_COLUMNS * LOGO_ROWS * 3)
78 for (let row = 0; row < LOGO_ROWS; row++) {
79 for (let col = 0; col < LOGO_COLUMNS; col++) {
80 const x = col * 2
81 const y = row * 2
82 const quadrants = [get(c, x, y), get(c, x + 1, y), get(c, x, y + 1), get(c, x + 1, y + 1)]
83 const mask = quadrants.reduce((m, p, i) => (p === WHITE ? m | (1 << i) : m), 0)
84 cells.set([QUADRANTS.charCodeAt(mask), WHITE, ORANGE], (row * LOGO_COLUMNS + col) * 3)
85 }
86 }
87 return cells
88}
89
90const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
91
92// Raster cells are little-endian u32s; typed arrays use the host's order,
93// which is little-endian on every platform Claude Code ships for.
94function encode(words: Uint32Array): string {
95 const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
96 let out = ''
97 for (let i = 0; i < bytes.length; i += 3) {
98 const a = bytes[i] ?? 0
99 const b = bytes[i + 1] ?? 0
100 const d = bytes[i + 2] ?? 0
101 const n = (a << 16) | (b << 8) | d
102 out += ALPHABET[(n >> 18) & 63]
103 out += ALPHABET[(n >> 12) & 63]
104 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '='
105 out += i + 2 < bytes.length ? ALPHABET[n & 63] : '='
106 }
107 return out
108}
109
110export function frame(t: number): string {
111 return encode(paint(t))
112}
113types/index.d.ts 8 lines1export type IsHidden = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'hackernews': { isHidden: IsHidden }
6 }
7}
8