SLOPSHOPPER

gopher-spinner

A pixel-art gopher that animates beside the spinner while a turn runs

newpanespinnercommandtimer
★ 1v0.1.0MITupdated 2026-10-04ripta/coding_agent_standards/mods/gopher-spinner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gopher-spinner
│ ┃ Gopher ✕ › 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. │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ idle ✻ Worked for 42s · done 4:20 PM │ │ › /gopher │ ⎿ gopher-spinner: Gopher preview opened. Esc closes it. │ │ Thinking ▄▀▀▄▄▄▄▄▄▀▀▄ ▄▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Gopher
▄▀▀▄▄▄▄▄▄▀▀▄ ▄▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ idle
Spinner
Thinking ▄▀▀▄▄▄▄▄▄▀▀▄ ▄▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀
README

Coding Agent Standards

Personal coding standards, best practices, and coding agent configuration for use across projects. This repo acts as a single source of truth for language conventions, development practices, project management SOPs, and AI agent behavior rules.

Structure

coding_agent_standards/
├── languages/          Language-specific standards
├── practices/          Cross-cutting practices
├── .claude/            Project-local Claude Code config (skills, settings) for this repo
├── claude/             Claude Code plugin source (exported to other projects)
│   ├── .claude-plugin/      Plugin manifest
│   ├── agents/              Subagent definitions
│   ├── commands/            Slash commands
│   ├── hooks/               hooks.json, the guard scripts it wires up, and the work-queue progress mod
│   ├── project-management/  Plans, proposals, design, tracking, and commit chunking standards
│   ├── rules/               Agent behavior rules
│   ├── scripts/             Standalone scripts (statusline)
│   ├── settings/            Permission whitelist templates per ecosystem
│   ├── skills/              Skill definitions
│   ├── tests/               Tests for the plugin's hooks module
│   └── types/               State contract for the plugin's hooks module
├── codex/              Codex skills with no Claude Code equivalent (exported as a Codex plugin)
├── mods/               Claude Code mods, each its own opt-in plugin in the same marketplace
├── profiles/           Composable project profiles
├── rumdl/              Shared markdown lint config and runner
├── vale/               Shared prose lint styles and runner
└── bin/                Utility scripts

Languages

Standards for each language/framework covering naming, project structure, error handling, testing, and tooling:

  • 1z (1z.md) -- stack-oriented functional language, naming conventions, testing, quotation-based control flow
  • Go (go.md) -- packages, error wrapping, concurrency, build patterns
  • CGO (cgo.md) -- C FFI bridge layers, type conversions, memory management, gomobile
  • Swift (swift.md) -- SwiftUI, async/await, SPM, Go/C library integration
  • Rust (rust.md) -- modules, workspaces, error handling with thiserror/anyhow
  • Zig (zig.md) -- allocators, error sets, build system, embedded tests
  • CSS (css.md) -- cascade layers, reset, logical properties, OKLCH tokens, fluid type and space, layout patterns
  • Svelte (svelte.md) -- SvelteKit, Svelte 5 runes, Tailwind, protobuf RPC
  • TypeScript (typescript.md) -- strict tsconfig, ESM, Biome for format and lint, pnpm
  • Phaser (phaser.md) -- scenes, pooling, atlases, isometric projection
  • PixiJS (pixijs.md) -- v8 application setup, scene graph, ticker, asset bundles
  • Protocol Buffers (protobuf.md) -- file layout, naming, schema evolution, field numbering, buf-based code generation
  • PostgreSQL (postgres.md) -- data types, database-enforced constraints, relationships, indexes, denormalization

Practices

Cross-cutting concerns that apply regardless of language:

  • Testing -- table-driven tests, golden files, HTTP handler tests, testability patterns
  • Error Handling -- wrapping, sentinel errors, structured responses, validation
  • Code Review -- review priorities, submission checklist
  • Security -- input validation, parameterized queries, secret management
  • Game Simulation -- fixed timestep, sim/render split, determinism, offline replay

Project Management

Standards for planning and tracking work:

  • Glossary -- what each term means: proposal, phase, milestone, spike, ADR
  • Plans -- phase and milestone definitions, promotion rules, document format
  • Proposals -- feature proposal lifecycle (draft through implemented), numbering
  • Design -- Architecture Decision Records (ADRs), lifecycle, immutability rules
  • Tracking -- cross-reference conventions, metadata standards, markdown guidelines
  • Commit Chunking -- splitting a long-running integration branch into PR-sized chunks

Claude Code Configuration

Rules, hooks, settings, scripts, commands, agents, and skills for Claude Code:

  • Rules (rules/) -- decision-making boundaries, work discipline, git workflow, commit/PR style, writing voice
  • Hooks (hooks/) -- hooks.json wires auto-formatting on edit and a test/lint reminder on stop. The block-broad-find, block-redirection, and cg-check guard scripts ship alongside it, plus opt-in hooks for project-management references, golden files, and the Zig cache; see claude/hooks/README.md for which are wired by default. hooks.json also loads work-queue-progress.tsx, a mod that shows a work-queue run's progress above the prompt and in a /queue pane
  • Settings (settings/) -- permission whitelist templates to copy into a project's .claude/settings.local.json, one per language ecosystem
  • Scripts (scripts/) -- standalone scripts not tied to a hook, currently the statusline
  • Skills (skills/) -- specialized agents for code review, testing, linting, Makefile maintenance, releases, proposals, etc.
  • Agents (agents/) -- subagent definitions invoked by name: implementer, which writes one milestone for work-queue, and reviewer, for post-milestone diff review
  • Commands (commands/) -- slash commands for driving a phase milestone (work-on), working through a queue of milestones planned up front (work-queue), summarizing a session as a commit message (summary), and chunking an integration branch into PRs (next-pr)

Commands stay thin. The rules they enforce live in claude/rules/ and claude/project-management/, and a command reads them at ${CLAUDE_PLUGIN_ROOT}/... rather than restating them. Keeping one copy is what stops /summary and /work-on from drifting apart on the same commit-message rules.

Note the distinction between .claude/ and claude/:

  • .claude/ is the standard Claude Code project config directory. Skills and settings here apply when working in this repo (e.g., standards-synthesizer for onboarding new languages).
  • claude/ contains skills, commands, hooks, rules, and settings exported to other projects that reference this repo via --add-dir or @import.

Codex Configuration

codex/ holds skills that only run under Codex, packaged as a Codex plugin. A skill lands here when it depends on something Codex has and Claude Code does not. expand-lore-wiki is the current example. It calls Codex's built-in imagegen skill, and Claude Code has no image generation to port it to.

Codex skills carry an agents/openai.yaml next to SKILL.md. That file holds the display name and short description Codex shows in its own UI, and Claude Code ignores it.

Profiles

Composable profiles that bundle the right standards for a given project type. Each profile imports a baseline plus relevant language and practice standards:

ProfileStack
go-serviceGo backend services
go-svelte-fullstackGo + Svelte + protobuf
go-cgo-libraryGo libraries with C FFI
swift-macos-appSwiftUI macOS apps
swift-cliSwift command-line tools
rust-projectRust projects
1z-project1z standalone projects
1z-interpreter1z interpreter (Zig + 1z)
zig-projectZig projects
svelte-appSvelte/SvelteKit apps
ts-gameBrowser games in TypeScript, engine-agnostic base
ts-phaser-gamets-game plus Phaser
ts-pixi-gamets-game plus PixiJS
oss-contribLightweight profile for contributing to repos you don't own

Usage

Import a profile from your project's CLAUDE.md:

@~/projects/coding_agent_standards/profiles/go-service.md

For repos you don't own, use CLAUDE.local.md (auto-gitignored by Claude Code) to avoid committing personal standards:

@~/projects/coding_agent_standards/profiles/oss-contrib.md

Installing the Claude Code Plugin

The skills, hooks, and agents under claude/ are packaged as a Claude Code plugin named coding-standards. The marketplace manifest lives at the repo root (.claude-plugin/marketplace.json) and points at ./claude as the plugin source.

Only claude/ is copied into the plugin cache. An installed plugin cannot read files outside its own directory, so anything a skill needs at runtime has to live inside it. That is why project-management/ sits under claude/ rather than at the repo root, and why profiles/baseline.md imports it from there.

Install once, inside any Claude Code session. Run the two commands as separate prompts. A slash command takes the whole rest of the input as its argument. Pasting both lines at once makes /plugin marketplace add read the second line as part of the repo name, and it fails with is not a valid GitHub owner/repo shorthand.

First register the marketplace:

/plugin marketplace add ripta/coding_agent_standards

Then install the plugin from it:

/plugin install coding-standards@coding-standards

The coding-standards@coding-standards spelling is not a typo. The plugin and the marketplace share a name. The part before @ is the plugin from claude/.claude-plugin/plugin.json. The part after is the marketplace from .claude-plugin/marketplace.json.

Choose user scope when prompted so the plugin is available in all your projects. A local clone works as the marketplace source too (/plugin marketplace add ~/projects/coding_agent_standards), in which case updates track your clone instead of GitHub.

Plugin skills and commands are namespaced: invoke them as /coding-standards:<name>. If you keep a copy of a skill in ~/.claude/skills/, or a command in ~/.claude/commands/, both versions will appear -- delete the personal copy once the plugin version works for you.

Updating

Installed plugins are cached copies, not live references. After changes land in the repo:

/plugin marketplace update coding-standards

The plugin has no pinned version; Claude Code versions it by git commit SHA, so every update pulls the latest commit.

Active Development

To iterate on a skill without the cache in the way, launch Claude Code with the plugin loaded directly from source:

claude --plugin-dir ~/projects/coding_agent_standards/claude

Edit freely, then run /reload-plugins in the session to pick up changes immediately. Once satisfied, commit and push, and installed copies catch up via /plugin marketplace update.

Installing Mods

mods/ holds mods: plugins built from TypeScript function hooks that can draw UI inside Claude Code. Each is its own plugin in the same marketplace, so none of them ride along with coding-standards. The exception is the work-queue progress mod, which serves /work-queue and so ships inside coding-standards. Install a mods/ plugin by name:

/plugin install gopher-spinner@coding-standards

gopher-spinner draws a pixel-art gopher in place of the spinner line while a turn runs. It hops during tool calls, looks around while thinking, and blinks while replying. It replaces the whole line, so the elapsed time and token count are not shown. It draws in the terminal only. /gopher opens a pane that plays every frame. Redraw the gopher by editing the pixel rows in hooks/frames.ts.

Check a mod with claude plugin validate mods/<name> and claude plugin test mods/<name>. To iterate with hot reload, launch with claude --plugin-dir ~/projects/coding_agent_standards/mods/<name>.

Installing the Codex Plugin

The skills under codex/ are packaged as a Codex plugin, also named coding-standards. Its manifest is codex/.codex-plugin/plugin.json. The marketplace manifest is .agents/plugins/marketplace.json at the repo root, pointing at ./codex.

Codex discovers ~/.agents/plugins/marketplace.json implicitly, but not a repo-local one. Register this repo once:

codex plugin marketplace add ~/projects/coding_agent_standards
codex plugin add coding-standards@coding-standards

Start a new thread afterward. That is the boundary where Codex picks up new skills.

Validate against Codex's ingestion contract before pushing a manifest change:

python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py codex

Two differences from the Claude Code plugin are worth knowing:

  • Codex requires strict semver in version and caches by it. There is no commit-SHA equivalent. Iterating locally means rewriting the version to 0.1.0+codex.<token> and re-running codex plugin add.
  • Codex rejects a hooks field in plugin.json, so claude/hooks/ has no counterpart on this side.

Linting

Two linters ship as shared config plus a runner, one directory each. Both are copy-in rather than import, since neither tool supports config inheritance.

cp ~/projects/coding_agent_standards/rumdl/rumdl.toml.template .rumdl.toml
~/projects/coding_agent_standards/rumdl/lint-markdown

cp ~/projects/coding_agent_standards/vale/vale.ini.template .vale.ini
~/projects/coding_agent_standards/vale/lint-prose

rumdl/ checks markdown structure, including the 120-column prose width. vale/ checks prose shape in markdown and in code comments. They do not overlap. Each runner defaults to diff scoping against the base ref, so a repo with a backlog can adopt them without a burn-down first. See rumdl/README.md and vale/README.md.

Setup Validation

The bin/check-setup script validates that a project has standards properly configured. It can be wired up as a Claude Code SessionStart hook to run automatically when you open a project.

Non-standard Paths

If this repo doesn't live at ~/projects/coding_agent_standards, create a symlink:

ln -s /actual/path/to/coding_agent_standards ~/coding-standards

License

MIT

Source 3 files
hooks/register.tsx 136 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { COLUMNS, FRAMES, ROWS } from './frames'
5import type { FrameName } from './frames'
6
7const tick = atom({ plugin: 'gopher-spinner', key: 'tick' } as const, 0)
8
9const TICK_MS = 200
10// The timer stops itself once the spinner has not drawn for this long.
11const STALE_MS = 1000
12// Below this width the engine's own one-line spinner is drawn.
13const MIN_COLUMNS = COLUMNS + 24
14
15const THINKING: readonly FrameName[] = [
16  'idle', 'idle', 'lookLeft', 'lookLeft', 'idle', 'idle', 'lookRight', 'lookRight',
17]
18const BLINK_EVERY = 15
19
20export const PANE = 'gopher-preview'
21const PREVIEW: readonly FrameName[] = ['idle', 'blink', 'lookLeft', 'lookRight', 'hop']
22const TICKS_PER_PREVIEW_FRAME = 4
23
24type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
25
26export function frameFor(mode: Mode, n: number): FrameName {
27  if (mode === 'tool-use' || mode === 'tool-input') {
28    return n % 2 === 0 ? 'idle' : 'hop'
29  }
30  if (mode === 'thinking') {
31    return THINKING[n % THINKING.length] ?? 'idle'
32  }
33  return n % BLINK_EVERY === BLINK_EVERY - 1 ? 'blink' : 'idle'
34}
35
36export function previewFrameFor(n: number): FrameName {
37  return PREVIEW[Math.floor(n / TICKS_PER_PREVIEW_FRAME) % PREVIEW.length] ?? 'idle'
38}
39
40let timer: Timer | undefined
41let lastDrawnAt = 0
42
43function ensureTicking($: EngineInterface) {
44  if (timer) return
45  timer = $.clock.every(TICK_MS, async () => {
46    if ((await $.clock.now()) - lastDrawnAt > STALE_MS) {
47      timer?.cancel()
48      timer = undefined
49      return
50    }
51    await update($, tick, n => (n ?? 0) + 1)
52  })
53}
54
55export const register: Register = on => {
56  on('session.start', async ($, e, next) => {
57    await $.command.register({
58      name: 'gopher',
59      description: 'Preview the gopher spinner frames in a pane',
60    })
61    return next(e)
62  })
63
64  on('command.run', { command: 'gopher' }, async $ => {
65    await $.ui.open({
66      id: PANE,
67      title: 'Gopher',
68      focus: true,
69      closeOnEscape: true,
70      // Inline above the prompt, one gopher tall. Docked beside a fullscreen
71      // transcript the pane is full height, so keep it one gopher wide.
72      rows: ROWS + 3,
73      columns: COLUMNS + 4,
74    })
75    return { text: 'Gopher preview opened. Esc closes it.' }
76  })
77
78  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
79    lastDrawnAt = await $.clock.now()
80    ensureTicking($)
81
82    if (e.surface !== 'terminal') {
83      const { Text } = $.ui.resolve(e)
84      return <Text dimColor>The gopher draws in the terminal only.</Text>
85    }
86
87    const n = await read($, tick)
88    const frame = previewFrameFor(n)
89    const { Box, Text, Raster } = $.ui.resolve(e)
90    // The animated gopher plus each still, side by side, two columns apart.
91    const hasRoomForStrip = e.props.bodyColumns >= (PREVIEW.length + 1) * (COLUMNS + 2)
92
93    return (
94      <Box flexDirection="row" gap={2}>
95        <Box flexDirection="column" alignItems="center">
96          <Raster key="preview" columns={COLUMNS} rows={ROWS} cells={FRAMES[frame]} />
97          <Text color="#56b4e0">{frame}</Text>
98        </Box>
99        {hasRoomForStrip &&
100          PREVIEW.map(name => (
101            <Box flexDirection="column" alignItems="center">
102              <Raster key={`strip-${name}`} columns={COLUMNS} rows={ROWS} cells={FRAMES[name]} />
103              <Text dimColor={name !== frame}>{name}</Text>
104            </Box>
105          ))}
106      </Box>
107    )
108  })
109
110  on('turn.start', async ($, e, next) => {
111    lastDrawnAt = await $.clock.now()
112    ensureTicking($)
113    return next(e)
114  })
115
116  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
117    if (e.surface !== 'terminal') return next(e)
118    if (e.viewport && e.viewport.columns < MIN_COLUMNS) return next(e)
119
120    lastDrawnAt = await $.clock.now()
121    ensureTicking($)
122
123    const n = await read($, tick)
124    const frame = frameFor(e.props.mode, n)
125    const label = `${e.props.message ?? e.props.word}${e.props.suffix}`
126    const { Box, Text, Raster } = $.ui.resolve(e)
127
128    return (
129      <Box flexDirection="row" alignItems="center" gap={1}>
130        <Raster key="gopher" columns={COLUMNS} rows={ROWS} cells={FRAMES[frame]} />
131        <Text color="#56b4e0">{label}</Text>
132      </Box>
133    )
134  })
135}
136
hooks/frames.ts 137 lines
1// Pixel-art gopher, two pixels per terminal cell via the upper half block.
2
3const PALETTE: Record<string, number | null> = {
4  '.': null,
5  O: 0x2a6f96, // outline
6  B: 0x56b4e0, // body
7  D: 0x3a8fc0, // body shade
8  L: 0x9ad8f0, // belly
9  S: 0xf1bf9c, // skin
10  W: 0xffffff, // eye white, teeth
11  K: 0x151515, // pupil
12  N: 0x3a3c40, // nose
13  F: 0xe8eef4, // fur collar
14  G: 0xa9b9c8, // fur shade
15  g: 0x6f8496, // fur outline, so the collar shows on light backgrounds
16}
17
18const BASE = [
19  '...OO......OO...',
20  '..OSSOOOOOOSSO..',
21  '..OSBBBBBBBBSO..',
22  '.OBBWWBBBBWWBBO.',
23  '.OBWKKWBBWKKWBO.',
24  '.OBWKKWBBWKKWBO.',
25  '.OBBWWBBBBWWBBO.',
26  '.OBBBBSNNSBBBBO.',
27  '.OBBBBSSSSBBBBO.',
28  '.OBBBBBWWBBBBBO.',
29  'ggGBBBBBBBBBBGgg',
30  '.gFFFGBBBBGFFFg.',
31  '.OBSSgFFFFgSSBO.',
32  '.ODSSLLLLLLSSDO.',
33  '.ODBLLLLLLLLBDO.',
34  '.ODBBLLLLLLBBDO.',
35  '..OSSDDDDDDSSO..',
36  '..SSS......SSS..',
37]
38
39function edit(changes: Record<number, string>): string[] {
40  return BASE.map((row, i) => changes[i] ?? row)
41}
42
43const BLANK = '.'.repeat(BASE[0]?.length ?? 0)
44
45// Two pixels of headroom above every frame, so the hop has room to rise.
46function grounded(rows: readonly string[]): string[] {
47  return [BLANK, BLANK, ...rows]
48}
49
50const SPRITES = {
51  idle: BASE,
52  blink: edit({
53    3: '.OBBBBBBBBBBBBO.',
54    4: '.OBBBBBBBBBBBBO.',
55    5: '.OBKKKKBBKKKKBO.',
56    6: '.OBBBBBBBBBBBBO.',
57  }),
58  lookLeft: edit({
59    4: '.OBKKWWBBKKWWBO.',
60    5: '.OBKKWWBBKKWWBO.',
61  }),
62  lookRight: edit({
63    4: '.OBWWKKBBWWKKBO.',
64    5: '.OBWWKKBBWWKKBO.',
65  }),
66}
67
68export type FrameName = keyof typeof SPRITES | 'hop'
69
70const FRAME_ROWS: Record<FrameName, string[]> = {
71  idle: grounded(SPRITES.idle),
72  blink: grounded(SPRITES.blink),
73  lookLeft: grounded(SPRITES.lookLeft),
74  lookRight: grounded(SPRITES.lookRight),
75  // One pixel up, feet off the ground.
76  hop: [BLANK, ...BASE, BLANK],
77}
78
79export const COLUMNS = BLANK.length
80export const ROWS = (BASE.length + 2) / 2
81
82const DEFAULT_COLOR = 0x01000000
83const UPPER_HALF = 0x2580
84const LOWER_HALF = 0x2584
85const SPACE = 0x20
86
87const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
88
89function toBase64(bytes: Uint8Array): string {
90  let out = ''
91  for (let i = 0; i < bytes.length; i += 3) {
92    const a = bytes[i] ?? 0
93    const b = bytes[i + 1]
94    const c = bytes[i + 2]
95    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
96    out += B64[(n >> 18) & 63]
97    out += B64[(n >> 12) & 63]
98    out += b === undefined ? '=' : B64[(n >> 6) & 63]
99    out += c === undefined ? '=' : B64[n & 63]
100  }
101  return out
102}
103
104function color(rows: readonly string[], y: number, x: number): number | null {
105  return PALETTE[rows[y]?.[x] ?? '.'] ?? null
106}
107
108function encode(rows: readonly string[]): string {
109  const bytes = new Uint8Array(COLUMNS * ROWS * 12)
110  const view = new DataView(bytes.buffer)
111  let offset = 0
112  const put = (codePoint: number, fg: number, bg: number) => {
113    view.setUint32(offset, codePoint, true)
114    view.setUint32(offset + 4, fg, true)
115    view.setUint32(offset + 8, bg, true)
116    offset += 12
117  }
118  for (let y = 0; y < rows.length; y += 2) {
119    for (let x = 0; x < COLUMNS; x++) {
120      const top = color(rows, y, x)
121      const bottom = color(rows, y + 1, x)
122      if (top === null && bottom === null) {
123        put(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
124      } else if (top === null) {
125        put(LOWER_HALF, bottom ?? DEFAULT_COLOR, DEFAULT_COLOR)
126      } else {
127        put(UPPER_HALF, top, bottom ?? DEFAULT_COLOR)
128      }
129    }
130  }
131  return toBase64(bytes)
132}
133
134export const FRAMES = Object.fromEntries(
135  Object.entries(FRAME_ROWS).map(([name, rows]) => [name, encode(rows)]),
136) as Record<FrameName, string>
137
types/index.d.ts 8 lines
1export type Tick = number
2
3declare module 'claude-code' {
4  interface PluginState {
5    'gopher-spinner': { tick: Tick }
6  }
7}
8