SLOPSHOPPER

hackernews

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

newspinnercommandnetworktimer
v0.1.0MITupdated 2026-10-25whirlwin/claude-code-hackernews
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hackernews
› 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 › /hackernews ⎿ hackernews: The spinner no longer shows Hacker News. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-hackernews

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.

The spinner rotating through the front page while Claude runs a command

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 tile's roll and sweep, frame by frame

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.

Install

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

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

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-hackernews
claude plugin update hackernews@claude-code-hackernews

Then run /reload-plugins in an open session, or restart Claude Code.

Use

CommandWhat it does
/hackernewshide the front page, or bring it back; says why it could not be fetched, if it could not
/hackernews nextskip straight to the next story

With other mods

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.

How it works

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.

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 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.

License

MIT, see LICENSE.

Source 4 files
hooks/register.tsx 249 lines
1import { 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}
249
hooks/hn.ts 206 lines
1// 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}
206
hooks/logo.ts 113 lines
1// 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}
113
types/index.d.ts 8 lines
1export type IsHidden = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'hackernews': { isHidden: IsHidden }
6  }
7}
8