SLOPSHOPPER

dotpet

A pixel-art pet above the prompt input that changes with the session state (idle, working, done, sleeping). /dotpet shows or hides it; the art can be redrawn…

newbandcommandtimer
v0.2.0MITupdated 2026-10-09i-noma-ru/claude-dotpet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dotpet
› 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 › /dotpet ⎿ dotpet: トカゲ: OFF ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-dotpet

A pixel-art pet that sits above the prompt input in Claude Code's terminal UI, plus a browser-based editor for redrawing it. The pet changes with the session state, so you can see at a glance whether Claude is still working without reading the transcript.

日本語の説明は README.ja.md にあります。

When to use

  • When you want to see whether Claude is still working, or has finished, from the picture above the prompt rather than from the text.
  • When you want to draw your own pet in a browser and apply it with one command, instead of editing source files by hand.
  • When you want a ready-made pet: Bun-chan is included as a second art file, and the default sample lizard is meant to be redrawn.

Not for you if you use Claude Code outside the terminal UI, need Windows (untested), or want the in-app messages and the editor UI in a language other than Japanese.

What it looks like

This is Bun-chan (文ちゃん), the author's pet bearded dragon and a member of the family.

IdleWorkingDoneSleeping
Bun-chan idleBun-chan workingBun-chan doneBun-chan sleeping

The pet reflects the session state: idle, working, done, and sleeping. The default art is a plain green sample lizard, meant to be redrawn. Bun-chan is included as a second art file (see Choose the pet).

Requirements

  • Claude Code, and Node.js 18 or later for the editor's apply command.
  • Built on Claude Code's plugin hooks ("mods") API, which is in early access and may change between versions.
  • Developed and tested with Claude Code 2.1.288 and 2.1.289 on macOS.
  • Windows is untested.
  • The in-app messages and the editor UI are in Japanese.

Install

  1. Clone this repository.
  2. Start Claude Code with the folder as a plugin:
   claude --plugin-dir /path/to/claude-dotpet

--plugin-dir loads the plugin for that session only. To load it in every session, add it to the CLAUDE_CODE_PLUGIN_DIRS environment variable: a :-separated list where each entry is either a plugin folder itself or a folder that contains plugin folders (both confirmed in 2.1.292). The variable does not appear in claude --help, so treat it as subject to change.

Commands

CommandEffect
/dotpetToggle on / off
/dotpet on, /dotpet offTurn on / off (persists across sessions)
/dotpet bigFull body, 20 columns × 5 rows of half-block characters (20 × 10 dots)
/dotpet smallFull body, 10 columns × 3 rows of octant characters (20 × 12 dots)
/dotpet imageExperimental. Draws the pet as an image, 12 columns × 3 rows
/dotpet stillExperimental. Single still image, no animation

Notes:

  • small uses octant characters (U+1CD00 and above, "Symbols for Legacy Computing Supplement"). The terminal font must include these glyphs (e.g., Cascadia Code); otherwise, the pet displays as empty boxes.
  • image and still require a terminal that supports the kitty graphics protocol. Both have been confirmed working in kitty and Ghostty (still on 2026-10-08 with Claude Code 2.1.292). In other terminals, a text fallback is shown; use big or small instead.
  • The pet is not drawn outside the terminal UI or when the band above the input is too short. When another plugin draws in the same band, that band is stacked below the pet (the pet yields only when there is no spare row for it; v0.2.0).

Choose the pet

Two art files are included in art/pets/:

FilePetLicense
sample-lizard.jsonGreen sample lizard (the default)MIT
bun-chan.jsonBun-chan, shown in the GIFs aboveCC BY-NC 4.0

Switch art using the apply command from the repository folder:

node editor/apply_art.js --force art/pets/bun-chan.json
node editor/apply_art.js --force art/pets/sample-lizard.json

--force is needed because these files are not editor exports and carry no base marker. The command replaces the current art, so export your own drawing first if you want to keep it (previous files are also saved under backup/).

Redraw the pet

  1. Open editor/dotpet_editor.html in a browser (Chrome was used during development).
  2. Pick a size, state, and frame, then paint.
  3. ← and → move between frames. Hold the key down to preview the animation.
  4. Shift + arrow keys shift the picture by one dot. The target can be one picture, one state, or one size.
  5. Click 「書き出す」 (export). The browser downloads dotpet_art_<timestamp>.json, and the page displays a command.
  6. Run that command in a terminal:
   node "/path/to/claude-dotpet/editor/apply_art.js" ~/Downloads/dotpet_art_<timestamp>.json

The command path is determined by the HTML file's location, so it works from any directory. The download location is assumed to be ~/Downloads; adjust it if your browser saves elsewhere.

  1. If the pet does not update in an active session, run /dotpet twice or restart the session.

The apply command validates the file, backs up current files to backup/, rewrites art/dotpet_art.json and the four generated files (hooks/palette.ts, hooks/sit_art.ts, hooks/octant_art.ts, and editor/dotpet_art.js), and then runs claude plugin validate and claude plugin test. If those checks fail, files are restored from the backup. If the claude command is not found, the checks are skipped and a notice is printed.

OptionEffect
--check <file>Validate only; write nothing
--regenRebuild the generated files from art/dotpet_art.json
--forceContinue past the two safety stops below (details are still printed)

Exit codes: 0 on success, 1 on failure, 2 on invalid arguments.

Safety stops

  • Stale base. Each export records which version of the art it was based on. If the art has since changed and applying the export would undo any of those changes, the command stops and lists the pictures that would be rolled back along with any pictures changed on both sides. An export that already contains every change in the current art is applied without stopping.
  • Unapplied exports. If the export directory contains other dotpet_art*.json files that were never applied, the command stops and names them. Applied files are recorded in apply_ledger.json.

--force overwrites the art with the given file, including any rollbacks listed. There is no automatic merge.

Art format

art/dotpet_art.json contains:

  • palette: nine colors under the fixed keys A S M E P K B C V. Color values can be changed; keys cannot be added.
  • big (20 × 10) and small (20 × 12), each with poses (the pictures, one string per row, one character per dot, space = transparent) and order (which picture each frame shows).

Fixed in this version:

  • Frame counts: idle 8, working 48, done 8, sleeping 8. Animation speed is set in hooks/art.ts.
  • For small, every 2 × 4 block of dots may use at most two colors, and transparent counts as one. A terminal cell can only carry a foreground and a background color. The editor outlines offending blocks in red and refuses to export until they are fixed.

What the plugin reads, writes, runs, and submits

This section lists everything the plugin touches, for review. Nothing leaves the machine.

In the session (the mod): it reads the session state that Claude Code passes to its hooks (idle, working, done) and a timer, and draws the pet above the prompt input. It stores two values through Claude Code's plugin store: whether the pet is shown and which size was chosen. It registers one command, /dotpet. It does not read the conversation, the prompt text, files, or environment variables, does not run any external command, never calls the model, and never submits or edits a prompt.

The editor (editor/dotpet_editor.html): a single offline HTML page you open yourself. It makes no network requests and only downloads a JSON file to your browser's download folder.

The apply script (editor/apply_art.js): a Node.js script you run yourself from a terminal. It reads the JSON you exported, backs up the current art to backup/ inside the plugin folder, rewrites art/dotpet_art.json and the four generated files, and then runs claude plugin validate and claude plugin test on the plugin folder. It writes only inside the plugin folder and makes no network requests.

Tests

node editor/test_apply_art.js
claude plugin validate .
claude plugin test .

License

  • Code and the sample lizard: MIT. See LICENSE.
  • Bun-chan artwork (art/pets/bun-chan.json and the GIFs in assets/): CC BY-NC 4.0. See LICENSE-ART.md. Credit the author when reusing it; commercial use, such as selling merchandise featuring the artwork, is not permitted. Showing Bun-chan in your own terminal is fine, including on a computer you use for work.

The first draft of the Bun-chan pixel art was created with an AI tool; the author then redrew it by hand in the editor.

Source 7 files
hooks/register.tsx 295 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput, Timer } from 'claude-code'
3
4import {
5  advance,
6  BOX,
7  cells,
8  dots,
9  frameAt,
10  picture,
11  TICK_MS,
12  wanted,
13  type Mode,
14  type Pet,
15  type Size,
16} from './art'
17import { octCells } from './octant'
18import { octDots } from './octant_art'
19
20const isOn = atom({ plugin: 'dotpet', key: 'isOn' } as const, true)
21const size = atom({ plugin: 'dotpet', key: 'size' } as const, 'big')
22// オクタントの絵は Text で描くので blit で描き替えられない。絵が変わるたびに数を進めて、帯を描き直させる
23const redraw = atom({ plugin: 'dotpet', key: 'redraw' } as const, 0)
24const RASTER = 'dotpet'
25const STORE_ON = 'isOn'
26const STORE_SIZE = 'size'
27const USAGE = '使い方: /dotpet(切り替え)・/dotpet on・/dotpet off・/dotpet big(全身)・/dotpet small(オクタントで小さい全身)・/dotpet image(画像で小さく・試験中)・/dotpet still(画像 1 枚・動かない)'
28const LABEL: Record<Size, string> = { big: '全身', small: 'オクタント', image: '画像', still: '静止画' }
29
30// 絵の状態。再読み込みで待機に戻ってよいので $.state には置かない(ON/OFF と大きさだけ $.state と $.store に置く)
31type Stage = { pet: Pet; size: Size; band: string | undefined; shown: string; timer: Timer | undefined }
32
33// 静止画で出す 1 枚(待機のおすわり)
34const STILL = picture(dots('idle', 0))
35
36// 画像を出せない端末で、絵の代わりに出る文
37const ALT = 'トカゲ(この端末は画像を出せません。/dotpet big で文字の絵に戻ります)'
38
39function drawn(stage: Stage): string[] {
40  const at = frameAt(stage.pet.mode, stage.pet.tick)
41
42  // 小はオクタント用に描かれた別の絵(20×12 ドット)
43  if (stage.size === 'small') {
44    return [...octDots(stage.pet.mode, at)]
45  }
46
47  return dots(stage.pet.mode, at)
48}
49
50function frame(stage: Stage): string {
51  return cells(drawn(stage))
52}
53
54// $ を受け取る関数はモジュール先頭で宣言する。
55// 絵が変わったときだけ描き替える。帯が畳まれているあいだの拒否は放っておく
56function paint($: EngineInterface, stage: Stage): void {
57  // 静止画は帯を描いたときの 1 枚だけで、描き替えを送らない
58  if (stage.band === undefined || stage.size === 'still') {
59    return
60  }
61
62  if (stage.size === 'small') {
63    const next = drawn(stage).join('\n')
64
65    if (next === stage.shown) {
66      return
67    }
68
69    stage.shown = next
70    void update($, redraw, n => n + 1).catch(() => undefined)
71
72    return
73  }
74
75  if (stage.size === 'image') {
76    const source = picture(drawn(stage))
77
78    if (source.rgba === stage.shown) {
79      return
80    }
81
82    stage.shown = source.rgba
83    void $.ui.blit({ requestId: stage.band, key: RASTER, source }).catch(() => undefined)
84
85    return
86  }
87
88  const next = frame(stage)
89
90  if (next === stage.shown) {
91    return
92  }
93
94  stage.shown = next
95  void $.ui.blit({ requestId: stage.band, key: RASTER, cells: next }).catch(() => undefined)
96}
97
98function setMode($: EngineInterface, stage: Stage, mode: Mode): void {
99  stage.pet = { mode, tick: 0 }
100  paint($, stage)
101}
102
103function tick($: EngineInterface, stage: Stage): void {
104  stage.pet = advance(stage.pet)
105  paint($, stage)
106}
107
108function stop(stage: Stage): void {
109  stage.timer?.cancel()
110  stage.timer = undefined
111  stage.band = undefined
112  stage.shown = ''
113}
114
115export const register: Register = on => {
116  const stage: Stage = { pet: { mode: 'idle', tick: 0 }, size: 'big', band: undefined, shown: '', timer: undefined }
117
118  on('session.start', async ($, e, next) => {
119    await $.command.register({ name: 'dotpet', description: 'トカゲ(入力欄の上のドット絵のペット)を出す・消す・大きさを変える: /dotpet [on|off|big|small|image|still]' })
120
121    if ((await $.store.get(STORE_ON)) === false) {
122      await update($, isOn, () => false)
123    }
124
125    const stored = await $.store.get(STORE_SIZE)
126
127    if (stored === 'small' || stored === 'image' || stored === 'still') {
128      await update($, size, () => stored)
129    }
130
131    // 以前の呼び名 oct で保存されていたら、小として読む
132    if (stored === 'oct') {
133      await update($, size, () => 'small')
134    }
135
136    return next(e)
137  })
138
139  on('command.run', { command: 'dotpet' }, async ($, e) => {
140    const want = wanted(e.args, await read($, isOn))
141
142    if (want === null) {
143      return { text: USAGE }
144    }
145
146    const chosen = want.size
147
148    if (chosen !== undefined) {
149      await update($, size, () => chosen)
150      await $.store.set(STORE_SIZE, chosen)
151    }
152
153    await update($, isOn, () => want.isOn)
154    await $.store.set(STORE_ON, want.isOn)
155
156    if (!want.isOn) {
157      return { text: 'トカゲ: OFF' }
158    }
159
160    return { text: `トカゲ: ON(${LABEL[await read($, size)]}・入力欄の上に出ます)` }
161  })
162
163  // turn.start の入力に agentId は無い(text と turnId だけ)ので、ここでは主担当かどうかを見分けない
164  on('turn.start', ($, e, next) => {
165    setMode($, stage, 'work')
166
167    return next(e)
168  })
169
170  on('turn.complete', async ($, e, next) => {
171    const ran = await next(e)
172
173    if (e.agentId === undefined) {
174      setMode($, stage, 'done')
175    }
176
177    return ran
178  })
179
180  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
181    // Raster は端末だけの部品
182    if (e.surface !== 'terminal') {
183      return next(e)
184    }
185
186    // 下の MOD が帯を描くとき(vime の変換候補・ruling-tracker の未記録の裁定・drop-thumb の画像など)は、
187    // トカゲの下にその帯を積んで両方を出す(2026-10-09・それまでは譲ってトカゲが消えていた)。
188    // dotpet は名前順で先に読み込まれるので、next(e) を呼ばないと下の帯が出ない
189    const below = await next(e)
190    const hasBelow = below.type !== 'engine'
191
192    // 下の帯のぶん 1 行は空けておく。足りなければ従来どおり譲る
193    const pet = await petOf($, e, stage, hasBelow ? 1 : 0)
194
195    if (pet === null) {
196      return below
197    }
198
199    if (!hasBelow) {
200      return pet
201    }
202
203    const { Box } = $.ui.resolve(e)
204
205    return (
206      <Box flexDirection="column">
207        {pet}
208        {below}
209      </Box>
210    )
211  })
212}
213
214// トカゲの絵を 1 つ返す。出せない(OFF・アンケート中・高さ不足)ときは null を返し、時計も止める。
215// reserve = 下の帯のために空けておく行数
216async function petOf($: EngineInterface, e: RenderInput<'AbovePrompt', 'terminal'>, stage: Stage, reserve: number) {
217  stage.size = await read($, size)
218  const room = e.props.maxRows - reserve
219
220  // 静止画は帯を描いたときの 1 枚だけを出し、時計も回さない
221  if (stage.size === 'still') {
222    if (!(await read($, isOn)) || e.props.hasSurvey || room < BOX.still.rows) {
223      stop(stage)
224
225      return null
226    }
227
228    stage.timer?.cancel()
229    stage.timer = undefined
230    stage.band = e.requestId
231
232    const { Image: Still } = $.ui.resolve(e)
233
234    return <Still key={RASTER} source={STILL} columns={BOX.still.columns} rows={BOX.still.rows} alt={ALT} />
235  }
236
237  // オクタントは Raster で出せない文字(BMP の外)なので、色つきの Text を 1 マスずつ並べる
238  if (stage.size === 'small') {
239    if (!(await read($, isOn)) || e.props.hasSurvey || room < BOX.small.rows) {
240      stop(stage)
241
242      return null
243    }
244
245    // 絵が変わると paint がこの数を進めるので、読んでおくと帯が描き直される
246    await read($, redraw)
247    stage.band = e.requestId
248    stage.timer ??= $.clock.every(TICK_MS, () => tick($, stage))
249
250    const rows = drawn(stage)
251    stage.shown = rows.join('\n')
252
253    const { Box, Text } = $.ui.resolve(e)
254
255    return (
256      <Box flexDirection="column">
257        {octCells(rows).map(line => (
258          <Text>
259            {line.map(cell => (
260              <Text color={cell.color} backgroundColor={cell.background}>
261                {cell.char}
262              </Text>
263            ))}
264          </Text>
265        ))}
266      </Box>
267    )
268  }
269
270  const isImage = stage.size === 'image'
271  const box = BOX[stage.size]
272
273  if (!(await read($, isOn)) || e.props.hasSurvey || room < box.rows) {
274    stop(stage)
275
276    return null
277  }
278
279  const { Image, Raster } = $.ui.resolve(e)
280  stage.band = e.requestId
281  // 時計は帯を描いたときに初めて回す(画面の無い実行では回さない)
282  stage.timer ??= $.clock.every(TICK_MS, () => tick($, stage))
283
284  if (isImage) {
285    const source = picture(drawn(stage))
286    stage.shown = source.rgba
287
288    return <Image key={RASTER} source={source} columns={box.columns} rows={box.rows} alt={ALT} />
289  }
290
291  stage.shown = frame(stage)
292
293  return <Raster key={RASTER} columns={box.columns} rows={box.rows} cells={stage.shown} />
294}
295
hooks/art.ts 163 lines
1// 入力欄の上に出すドット絵のペット(大は20×10ドット)。
2// 絵と色の正本は art/dotpet_art.json。生成した sit_art.ts と palette.ts を読む。
3
4import { sitDots } from './sit_art'
5import { PALETTE } from './palette'
6export { PALETTE } from './palette'
7
8export type Mode = 'idle' | 'work' | 'done' | 'sleep'
9
10// 大 = おすわりの全身(20 文字 × 5 行)、小 = オクタント用に描かれた全身をオクタントの文字で出す(10 文字 × 3 行・絵は octant_art.ts、出し方は octant.ts)、
11// 画像 = 全身の絵を画像として出し、端末に縮めてもらう(12 文字 × 3 行。画像を出せる端末だけ・試験中)
12// 静止画 = 画像と同じ箱に、おすわりの絵を 1 枚だけ出す。描き替えを送らない(描き替えで絵が崩れる端末用)
13export type Size = 'big' | 'small' | 'image' | 'still'
14export const BOX: Record<Size, { columns: number; rows: number }> = {
15  big: { columns: 20, rows: 5 },
16  small: { columns: 10, rows: 3 },
17  image: { columns: 12, rows: 3 },
18  still: { columns: 12, rows: 3 },
19}
20const DEFAULT_COLOR = 0x01000000 // 端末の既定色(Raster の決まり)
21const UPPER = 0x2580 // ▀
22const LOWER = 0x2584 // ▄
23const SPACE = 0x20
24
25// 大・画像・静止画は同じ20×10ドット。小は octant_art.ts。
26export function dots(mode: Mode, frame: number): string[] {
27  return [...sitDots(mode, frame)]
28}
29
30export const WORK_CYCLE = 48
31
32// 1 文字 = 上下 2 ドット。Raster の cells([文字, 文字色, 背景色] の u32 を並べた base64)にする
33export function cells(rows: readonly string[]): string {
34  const columns = rows[0]!.length
35  const height = rows.length / 2
36  const words = new Uint32Array(columns * height * 3)
37
38  for (let y = 0; y < height; y++) {
39    for (let x = 0; x < columns; x++) {
40      const top = PALETTE[rows[y * 2]![x]!]
41      const bottom = PALETTE[rows[y * 2 + 1]![x]!]
42      const at = (y * columns + x) * 3
43      words[at] = top !== undefined ? UPPER : bottom !== undefined ? LOWER : SPACE
44      words[at + 1] = top ?? bottom ?? DEFAULT_COLOR
45      words[at + 2] = top !== undefined && bottom !== undefined ? bottom : DEFAULT_COLOR
46    }
47  }
48
49  return (new Uint8Array(words.buffer) as unknown as { toBase64(): string }).toBase64()
50}
51
52// 1 ドットを 4×4 ピクセルにする。端末が縮めるときにドットの角がぼけにくい
53const PIXELS_PER_DOT = 4
54
55export type Picture = { rgba: string; width: number; height: number }
56
57// Image の source(RGBA)にする。色の無いドットは透明
58export function picture(rows: readonly string[]): Picture {
59  const width = rows[0]!.length * PIXELS_PER_DOT
60  const height = rows.length * PIXELS_PER_DOT
61  const bytes = new Uint8Array(width * height * 4)
62
63  for (let y = 0; y < height; y++) {
64    const row = rows[Math.floor(y / PIXELS_PER_DOT)]!
65
66    for (let x = 0; x < width; x++) {
67      const color = PALETTE[row[Math.floor(x / PIXELS_PER_DOT)]!]
68
69      if (color !== undefined) {
70        const at = (y * width + x) * 4
71        bytes[at] = (color >> 16) & 0xff
72        bytes[at + 1] = (color >> 8) & 0xff
73        bytes[at + 2] = color & 0xff
74        bytes[at + 3] = 0xff
75      }
76    }
77  }
78
79  return { rgba: (bytes as unknown as { toBase64(): string }).toBase64(), width, height }
80}
81
82// 時計の 1 刻み = 330 ミリ秒
83// 時計の 1 刻み = 110 ミリ秒。作業中の打鍵を速く見せるための細かさで、ほかの状態は 3 刻み(330 ミリ秒)を 1 歩にする
84export const TICK_MS = 110
85export const STEP = 3
86export const DONE_TICKS = 24 * STEP // 完了の絵は 2 周・約 8 秒
87export const SLEEP_TICKS = 900 * STEP // 待機が約 5 分続いたら眠る
88
89// 待機は 10 歩で 1 周: 最初のコマを 3 歩見せ、あとは 1 歩ずつ(6 コマ目がまばたき)
90export function frameAt(mode: Mode, tick: number): number {
91  // 作業中は 1 コマ 1 刻み(4 コマの 1 往復が 440 ミリ秒。元の HTML の 560 ミリ秒より少し速い)
92  if (mode === 'work') {
93    return tick % WORK_CYCLE
94  }
95
96  const step = Math.floor(tick / STEP)
97
98  if (mode === 'idle') {
99    return Math.max(0, (step % 10) - 2)
100  }
101
102  // 完了は 12 歩、おやすみは 13 歩で 8 コマを 1 周する
103  const frames = mode === 'done'
104    ? [0, 0, 0, 0, 1, 2, 3, 4, 4, 5, 6, 7]
105    : [0, 0, 0, 1, 2, 2, 3, 4, 4, 5, 6, 6, 7]
106
107  return frames[step % frames.length]!
108}
109
110export type Pet = { mode: Mode; tick: number }
111
112// 1 刻み進める。完了は少し見せたら待機へ、待機が長く続いたら眠る
113export function advance(pet: Pet): Pet {
114  const tick = pet.tick + 1
115
116  if (pet.mode === 'done' && tick >= DONE_TICKS) {
117    return { mode: 'idle', tick: 0 }
118  }
119
120  if (pet.mode === 'idle' && tick >= SLEEP_TICKS) {
121    return { mode: 'sleep', tick: 0 }
122  }
123
124  return { mode: pet.mode, tick }
125}
126
127export type Wanted = { isOn: boolean; size?: Size }
128
129// `/dotpet` の引数。空は今の逆へ切り替える。大きさを言えば出したうえでその大きさにする。分からない語は null
130export function wanted(args: string, isOn: boolean): Wanted | null {
131  const word = args.trim().toLowerCase()
132
133  if (word === '') {
134    return { isOn: !isOn }
135  }
136
137  if (word === 'on' || word === 'オン') {
138    return { isOn: true }
139  }
140
141  if (word === 'off' || word === 'オフ') {
142    return { isOn: false }
143  }
144
145  if (word === 'big' || word === '大') {
146    return { isOn: true, size: 'big' }
147  }
148
149  if (word === 'small' || word === '小' || word === 'oct' || word === 'オクタント') {
150    return { isOn: true, size: 'small' }
151  }
152
153  if (word === 'image' || word === '画像') {
154    return { isOn: true, size: 'image' }
155  }
156
157  if (word === 'still' || word === '静止画') {
158    return { isOn: true, size: 'still' }
159  }
160
161  return null
162}
163
hooks/octant.ts 71 lines
1// オクタント(1 文字 = 横 2 × 縦 4 ドット)で全身を 10 文字 × 3 行にする(/dotpet small)。絵は octant_art.ts。
2// 文字は BMP の外(U+1CD00 台)にあり Raster では出せないので、色つきの Text を 1 マスずつ並べる。
3// 端末のフォントがこの文字を持っていないと豆腐になる(Cascadia Code は持っている)。
4
5import { PALETTE } from './art'
6
7// 添字 = ドットのビット(ビット 0〜7 が 左上・右上・2 段目左・2 段目右 … 左下・右下)。
8// Unicode 16 の文字名(BLOCK OCTANT-…)から機械的に作った表。手で直さない
9const GLYPHS = [
10  0x20, 0x1cea8, 0x1ceab, 0x1fb82, 0x1cd00, 0x2598, 0x1cd01, 0x1cd02, 0x1cd03, 0x1cd04, 0x259d, 0x1cd05, 0x1cd06, 0x1cd07, 0x1cd08, 0x2580,
11  0x1cd09, 0x1cd0a, 0x1cd0b, 0x1cd0c, 0x1fbe6, 0x1cd0d, 0x1cd0e, 0x1cd0f, 0x1cd10, 0x1cd11, 0x1cd12, 0x1cd13, 0x1cd14, 0x1cd15, 0x1cd16, 0x1cd17,
12  0x1cd18, 0x1cd19, 0x1cd1a, 0x1cd1b, 0x1cd1c, 0x1cd1d, 0x1cd1e, 0x1cd1f, 0x1fbe7, 0x1cd20, 0x1cd21, 0x1cd22, 0x1cd23, 0x1cd24, 0x1cd25, 0x1cd26,
13  0x1cd27, 0x1cd28, 0x1cd29, 0x1cd2a, 0x1cd2b, 0x1cd2c, 0x1cd2d, 0x1cd2e, 0x1cd2f, 0x1cd30, 0x1cd31, 0x1cd32, 0x1cd33, 0x1cd34, 0x1cd35, 0x1fb85,
14  0x1cea3, 0x1cd36, 0x1cd37, 0x1cd38, 0x1cd39, 0x1cd3a, 0x1cd3b, 0x1cd3c, 0x1cd3d, 0x1cd3e, 0x1cd3f, 0x1cd40, 0x1cd41, 0x1cd42, 0x1cd43, 0x1cd44,
15  0x2596, 0x1cd45, 0x1cd46, 0x1cd47, 0x1cd48, 0x258c, 0x1cd49, 0x1cd4a, 0x1cd4b, 0x1cd4c, 0x259e, 0x1cd4d, 0x1cd4e, 0x1cd4f, 0x1cd50, 0x259b,
16  0x1cd51, 0x1cd52, 0x1cd53, 0x1cd54, 0x1cd55, 0x1cd56, 0x1cd57, 0x1cd58, 0x1cd59, 0x1cd5a, 0x1cd5b, 0x1cd5c, 0x1cd5d, 0x1cd5e, 0x1cd5f, 0x1cd60,
17  0x1cd61, 0x1cd62, 0x1cd63, 0x1cd64, 0x1cd65, 0x1cd66, 0x1cd67, 0x1cd68, 0x1cd69, 0x1cd6a, 0x1cd6b, 0x1cd6c, 0x1cd6d, 0x1cd6e, 0x1cd6f, 0x1cd70,
18  0x1cea0, 0x1cd71, 0x1cd72, 0x1cd73, 0x1cd74, 0x1cd75, 0x1cd76, 0x1cd77, 0x1cd78, 0x1cd79, 0x1cd7a, 0x1cd7b, 0x1cd7c, 0x1cd7d, 0x1cd7e, 0x1cd7f,
19  0x1cd80, 0x1cd81, 0x1cd82, 0x1cd83, 0x1cd84, 0x1cd85, 0x1cd86, 0x1cd87, 0x1cd88, 0x1cd89, 0x1cd8a, 0x1cd8b, 0x1cd8c, 0x1cd8d, 0x1cd8e, 0x1cd8f,
20  0x2597, 0x1cd90, 0x1cd91, 0x1cd92, 0x1cd93, 0x259a, 0x1cd94, 0x1cd95, 0x1cd96, 0x1cd97, 0x2590, 0x1cd98, 0x1cd99, 0x1cd9a, 0x1cd9b, 0x259c,
21  0x1cd9c, 0x1cd9d, 0x1cd9e, 0x1cd9f, 0x1cda0, 0x1cda1, 0x1cda2, 0x1cda3, 0x1cda4, 0x1cda5, 0x1cda6, 0x1cda7, 0x1cda8, 0x1cda9, 0x1cdaa, 0x1cdab,
22  0x2582, 0x1cdac, 0x1cdad, 0x1cdae, 0x1cdaf, 0x1cdb0, 0x1cdb1, 0x1cdb2, 0x1cdb3, 0x1cdb4, 0x1cdb5, 0x1cdb6, 0x1cdb7, 0x1cdb8, 0x1cdb9, 0x1cdba,
23  0x1cdbb, 0x1cdbc, 0x1cdbd, 0x1cdbe, 0x1cdbf, 0x1cdc0, 0x1cdc1, 0x1cdc2, 0x1cdc3, 0x1cdc4, 0x1cdc5, 0x1cdc6, 0x1cdc7, 0x1cdc8, 0x1cdc9, 0x1cdca,
24  0x1cdcb, 0x1cdcc, 0x1cdcd, 0x1cdce, 0x1cdcf, 0x1cdd0, 0x1cdd1, 0x1cdd2, 0x1cdd3, 0x1cdd4, 0x1cdd5, 0x1cdd6, 0x1cdd7, 0x1cdd8, 0x1cdd9, 0x1cdda,
25  0x2584, 0x1cddb, 0x1cddc, 0x1cddd, 0x1cdde, 0x2599, 0x1cddf, 0x1cde0, 0x1cde1, 0x1cde2, 0x259f, 0x1cde3, 0x2586, 0x1cde4, 0x1cde5, 0x2588,
26]
27
28const CELL_ROWS = 4
29
30export type OctCell = { char: string; color?: string; background?: string }
31
32function hex(key: string): string | undefined {
33  return key === ' ' ? undefined : `#${PALETTE[key]!.toString(16).padStart(6, '0')}`
34}
35
36// 1 マス(8 ドット)を、文字 1 つ・文字色・地色にする。端末は 1 マス 2 色までで、透明(端末の既定色)も 1 色に数える。
37// 決め方は元の HTML の encodeOctantCell と同じ: 地色は透明があれば透明、無ければ最初のドットの色。もう 1 色が文字の色。
38// 絵(octant_art.ts)はどのマスも 2 色以内で、3 色目は来ない(テストで全コマ確かめている)
39function encode(pixels: readonly string[]): OctCell {
40  const colors = [...new Set(pixels)]
41  const back = colors.includes(' ') ? ' ' : colors[0]!
42  const front = colors.find(color => color !== back) ?? back
43  const bits = front === back ? 0 : pixels.reduce((sum, dot, k) => (dot === front ? sum | (1 << k) : sum), 0)
44
45  return { char: String.fromCodePoint(GLYPHS[bits]!), color: hex(front), background: hex(back) }
46}
47
48// 20×12 ドットの絵を、10 文字 × 3 行のマスにする
49export function octCells(rows: readonly string[]): OctCell[][] {
50  const width = rows[0]!.length
51  const out: OctCell[][] = []
52
53  for (let y = 0; y < rows.length; y += CELL_ROWS) {
54    const line: OctCell[] = []
55
56    for (let x = 0; x < width; x += 2) {
57      const pixels: string[] = []
58
59      for (let dy = 0; dy < CELL_ROWS; dy++) {
60        pixels.push(rows[y + dy]?.[x] ?? ' ', rows[y + dy]?.[x + 1] ?? ' ')
61      }
62
63      line.push(encode(pixels))
64    }
65
66    out.push(line)
67  }
68
69  return out
70}
71
hooks/octant_art.ts 228 lines
1// art/dotpet_art.json から editor/apply_art.js が作る。手で直さない
2
3const POSES: readonly (readonly string[])[] = [
4  [
5    "                    ",
6    "                    ",
7    "              AAAA  ",
8    "            AAAAAAA ",
9    " A          AAAAEAAA",
10    " AA     AAAAAAAAAAAA",
11    "  AAAAAAAAAAAAAAAAA ",
12    "   AAAAAAAAAAAAAAA  ",
13    "    AA    AA  AA    ",
14    "    AA    AA  AA    ",
15    "                    ",
16    "                    "
17  ],
18  [
19    "                    ",
20    "                    ",
21    "              AAAA  ",
22    "            AAAAAAA ",
23    " A          AAAASAAA",
24    " AA     AAAAAAAAAAAA",
25    "  AAAAAAAAAAAAAAAAA ",
26    "   AAAAAAAAAAAAAAA  ",
27    "    AA    AA  AA    ",
28    "    AA    AA  AA    ",
29    "                    ",
30    "                    "
31  ],
32  [
33    "                    ",
34    "                    ",
35    "              AAAA  ",
36    "            AAAAAAA ",
37    "            AAAAEAAA",
38    "        AAAAAAAAAAAA",
39    "AAAAAAAAAAAAAAAAAAA ",
40    "   AAAAAAAAAAAAAAA  ",
41    "    AA    AA  AA    ",
42    "    AA    AA  AA    ",
43    "                    ",
44    "                    "
45  ],
46  [
47    "                    ",
48    "                    ",
49    "              AAAA  ",
50    "            AAAAAAA ",
51    " A          AAAAEAAA",
52    " AA     AAAAAAAAAAAA",
53    "  AAAAAAAAAAAAAAAAA ",
54    "   AAAAAAAAAAAAAAA  ",
55    "    AA    AA  AA  KK",
56    "    AA    AA  AA  KK",
57    "                    ",
58    "                    "
59  ],
60  [
61    "                    ",
62    "                    ",
63    "              AAAA  ",
64    "            AAAAAAA ",
65    " A          AAAAEAAA",
66    " AA     AAAAAAAAAAAA",
67    "  AAAAAAAAAAAAAAAAA ",
68    "   AAAAAAAAAAAAAAA  ",
69    "    AA    AA  AA  KK",
70    "    AA    AA      KK",
71    "                    ",
72    "                    "
73  ],
74  [
75    "                    ",
76    "                    ",
77    "              AAAA  ",
78    "            AAAAAAA ",
79    " A          AAAAEAAA",
80    " AA     AAAAAAAAAAAA",
81    "  AAAAAAAAAAAAAAAAA ",
82    "   AAAAAAAAAAAAAAA  ",
83    "    AA    AA   AA KK",
84    "    AA    AA      KK",
85    "                    ",
86    "                    "
87  ],
88  [
89    "                    ",
90    "                    ",
91    "              AAAA  ",
92    "            AAAAAAA ",
93    " A          AAAASAAA",
94    " AA     AAAAAAAAAAAA",
95    "  AAAAAAAAAAAAAAAAA ",
96    "   AAAAAAAAAAAAAAA  ",
97    "    AA    AA  AA  KK",
98    "    AA    AA      KK",
99    "                    ",
100    "                    "
101  ],
102  [
103    "                    ",
104    "                    ",
105    "        AA    AAAA  ",
106    "        AA  AAAAAAA ",
107    " A      AA  AAAAEAAA",
108    " AA     AAAAAAAAAAAA",
109    "  AAAAAAAAAAAAAAAAA ",
110    "   AAAAAAAAAAAAAAA  ",
111    "    AA    AA  AA    ",
112    "    AA    AA  AA    ",
113    "                    ",
114    "                    "
115  ],
116  [
117    "                    ",
118    "                    ",
119    "        AA    AAAA  ",
120    "        AA  AAAAAAA ",
121    " A      AA  AAAASAAA",
122    " AA     AAAAAAAAAAAA",
123    "  AAAAAAAAAAAAAAAAA ",
124    "   AAAAAAAAAAAAAAA  ",
125    "    AA    AA  AA    ",
126    "    AA    AA  AA    ",
127    "                    ",
128    "                    "
129  ],
130  [
131    "    VV              ",
132    "                    ",
133    "        AA    AAAA  ",
134    "        AA  AAAAAAA ",
135    " A      AA  AAAAEAAA",
136    " AA     AAAAAAAAAAAA",
137    "  AAAAAAAAAAAAAAAAA ",
138    "   AAAAAAAAAAAAAAA  ",
139    "    AA    AA  AA    ",
140    "    AA    AA  AA    ",
141    "                    ",
142    "                    "
143  ],
144  [
145    "                    ",
146    "                    ",
147    "              AAAA  ",
148    "            AAAAAAA ",
149    "        AA  AAAAEAAA",
150    "        AAAAAAAAAAAA",
151    "AAAAAAAAAAAAAAAAAAA ",
152    "   AAAAAAAAAAAAAAA  ",
153    "    AA    AA  AA    ",
154    "    AA    AA  AA    ",
155    "                    ",
156    "                    "
157  ],
158  [
159    "                    ",
160    "      VV            ",
161    "              AAAA  ",
162    "            AAAAAAA ",
163    "        AA  AAAAEAAA",
164    "        AAAAAAAAAAAA",
165    "AAAAAAAAAAAAAAAAAAA ",
166    "   AAAAAAAAAAAAAAA  ",
167    "    AA    AA  AA    ",
168    "    AA    AA  AA    ",
169    "                    ",
170    "                    "
171  ],
172  [
173    "                    ",
174    "                    ",
175    "              AAAA  ",
176    "            AAAAAAA ",
177    "            AAAASAAA",
178    "        AAAAAAAAAAAA",
179    "AAAAAAAAAAAAAAAAAAA ",
180    "   AAAAAAAAAAAAAAA  ",
181    "    AA    AA  AA    ",
182    "    AA    AA  AA    ",
183    "                    ",
184    "                    "
185  ],
186  [
187    "                    ",
188    "                    ",
189    "          PP  AAAA  ",
190    "            AAAAAAA ",
191    "            AAAASAAA",
192    "        AAAAAAAAAAAA",
193    "AAAAAAAAAAAAAAAAAAA ",
194    "   AAAAAAAAAAAAAAA  ",
195    "    AA    AA  AA    ",
196    "    AA    AA  AA    ",
197    "                    ",
198    "                    "
199  ],
200  [
201    "        PP          ",
202    "                    ",
203    "          PP  AAAA  ",
204    "            AAAAAAA ",
205    "            AAAASAAA",
206    "        AAAAAAAAAAAA",
207    "AAAAAAAAAAAAAAAAAAA ",
208    "   AAAAAAAAAAAAAAA  ",
209    "    AA    AA  AA    ",
210    "    AA    AA  AA    ",
211    "                    ",
212    "                    "
213  ]
214]
215
216const ORDER: Record<'idle' | 'work' | 'done' | 'sleep', readonly number[]> = {
217  idle: [0, 0, 1, 0, 0, 0, 2, 0],
218  work: [3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 6, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 6, 3, 6, 5, 4],
219  done: [7, 8, 9, 8, 10, 10, 11, 10],
220  sleep: [12, 13, 14, 0, 12, 13, 14, 0],
221}
222
223export function octDots(mode: 'idle' | 'work' | 'done' | 'sleep', frame: number): readonly string[] {
224  const frames = ORDER[mode]
225
226  return POSES[frames[frame % frames.length]!]!
227}
228
hooks/sit_art.ts 198 lines
1// art/dotpet_art.json から editor/apply_art.js が作る。手で直さない
2
3const POSES: readonly (readonly string[])[] = [
4  [
5    "                    ",
6    "                    ",
7    "              AAAA  ",
8    "            AAAAAAA ",
9    " A          AAAAEAAA",
10    " AA     AAAAAAAAAAAA",
11    "  AAAAAAAAAAAAAAAAA ",
12    "   AAAAAAAAAAAAAAA  ",
13    "    AA    AA  AA    ",
14    "    AA    AA  AA    "
15  ],
16  [
17    "                    ",
18    "                    ",
19    "              AAAA  ",
20    "            AAAAAAA ",
21    " A          AAAASAAA",
22    " AA     AAAAAAAAAAAA",
23    "  AAAAAAAAAAAAAAAAA ",
24    "   AAAAAAAAAAAAAAA  ",
25    "    AA    AA  AA    ",
26    "    AA    AA  AA    "
27  ],
28  [
29    "                    ",
30    "                    ",
31    "              AAAA  ",
32    "            AAAAAAA ",
33    "            AAAAEAAA",
34    "        AAAAAAAAAAAA",
35    "AAAAAAAAAAAAAAAAAAA ",
36    "   AAAAAAAAAAAAAAA  ",
37    "    AA    AA  AA    ",
38    "    AA    AA  AA    "
39  ],
40  [
41    "                    ",
42    "                    ",
43    "              AAAA  ",
44    "            AAAAAAA ",
45    " A          AAAAEAAA",
46    " AA     AAAAAAAAAAAA",
47    "  AAAAAAAAAAAAAAAAA ",
48    "   AAAAAAAAAAAAAAA  ",
49    "    AA    AA  AA  KK",
50    "    AA    AA  AA  KK"
51  ],
52  [
53    "                    ",
54    "                    ",
55    "              AAAA  ",
56    "            AAAAAAA ",
57    " A          AAAAEAAA",
58    " AA     AAAAAAAAAAAA",
59    "  AAAAAAAAAAAAAAAAA ",
60    "   AAAAAAAAAAAAAAA  ",
61    "    AA    AA  AA  KK",
62    "    AA    AA      KK"
63  ],
64  [
65    "                    ",
66    "                    ",
67    "              AAAA  ",
68    "            AAAAAAA ",
69    " A          AAAAEAAA",
70    " AA     AAAAAAAAAAAA",
71    "  AAAAAAAAAAAAAAAAA ",
72    "   AAAAAAAAAAAAAAA  ",
73    "    AA    AA   AA KK",
74    "    AA    AA      KK"
75  ],
76  [
77    "                    ",
78    "                    ",
79    "              AAAA  ",
80    "            AAAAAAA ",
81    " A          AAAASAAA",
82    " AA     AAAAAAAAAAAA",
83    "  AAAAAAAAAAAAAAAAA ",
84    "   AAAAAAAAAAAAAAA  ",
85    "    AA    AA  AA  KK",
86    "    AA    AA      KK"
87  ],
88  [
89    "                    ",
90    "                    ",
91    "        AA    AAAA  ",
92    "        AA  AAAAAAA ",
93    " A      AA  AAAAEAAA",
94    " AA     AAAAAAAAAAAA",
95    "  AAAAAAAAAAAAAAAAA ",
96    "   AAAAAAAAAAAAAAA  ",
97    "    AA    AA  AA    ",
98    "    AA    AA  AA    "
99  ],
100  [
101    "                    ",
102    "                    ",
103    "        AA    AAAA  ",
104    "        AA  AAAAAAA ",
105    " A      AA  AAAASAAA",
106    " AA     AAAAAAAAAAAA",
107    "  AAAAAAAAAAAAAAAAA ",
108    "   AAAAAAAAAAAAAAA  ",
109    "    AA    AA  AA    ",
110    "    AA    AA  AA    "
111  ],
112  [
113    "    VV              ",
114    "                    ",
115    "        AA    AAAA  ",
116    "        AA  AAAAAAA ",
117    " A      AA  AAAAEAAA",
118    " AA     AAAAAAAAAAAA",
119    "  AAAAAAAAAAAAAAAAA ",
120    "   AAAAAAAAAAAAAAA  ",
121    "    AA    AA  AA    ",
122    "    AA    AA  AA    "
123  ],
124  [
125    "                    ",
126    "                    ",
127    "              AAAA  ",
128    "            AAAAAAA ",
129    "        AA  AAAAEAAA",
130    "        AAAAAAAAAAAA",
131    "AAAAAAAAAAAAAAAAAAA ",
132    "   AAAAAAAAAAAAAAA  ",
133    "    AA    AA  AA    ",
134    "    AA    AA  AA    "
135  ],
136  [
137    "                    ",
138    "      VV            ",
139    "              AAAA  ",
140    "            AAAAAAA ",
141    "        AA  AAAAEAAA",
142    "        AAAAAAAAAAAA",
143    "AAAAAAAAAAAAAAAAAAA ",
144    "   AAAAAAAAAAAAAAA  ",
145    "    AA    AA  AA    ",
146    "    AA    AA  AA    "
147  ],
148  [
149    "                    ",
150    "                    ",
151    "              AAAA  ",
152    "            AAAAAAA ",
153    "            AAAASAAA",
154    "        AAAAAAAAAAAA",
155    "AAAAAAAAAAAAAAAAAAA ",
156    "   AAAAAAAAAAAAAAA  ",
157    "    AA    AA  AA    ",
158    "    AA    AA  AA    "
159  ],
160  [
161    "                    ",
162    "                    ",
163    "          PP  AAAA  ",
164    "            AAAAAAA ",
165    "            AAAASAAA",
166    "        AAAAAAAAAAAA",
167    "AAAAAAAAAAAAAAAAAAA ",
168    "   AAAAAAAAAAAAAAA  ",
169    "    AA    AA  AA    ",
170    "    AA    AA  AA    "
171  ],
172  [
173    "        PP          ",
174    "                    ",
175    "          PP  AAAA  ",
176    "            AAAAAAA ",
177    "            AAAASAAA",
178    "        AAAAAAAAAAAA",
179    "AAAAAAAAAAAAAAAAAAA ",
180    "   AAAAAAAAAAAAAAA  ",
181    "    AA    AA  AA    ",
182    "    AA    AA  AA    "
183  ]
184]
185
186const ORDER: Record<'idle' | 'work' | 'done' | 'sleep', readonly number[]> = {
187  idle: [0, 0, 1, 0, 0, 0, 2, 0],
188  work: [3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 6, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 4, 3, 4, 5, 6, 3, 6, 5, 4],
189  done: [7, 8, 9, 8, 10, 10, 11, 10],
190  sleep: [12, 13, 14, 0, 12, 13, 14, 0],
191}
192
193export function sitDots(mode: 'idle' | 'work' | 'done' | 'sleep', frame: number): readonly string[] {
194  const frames = ORDER[mode]
195
196  return POSES[frames[frame % frames.length]!]!
197}
198
hooks/palette.ts 14 lines
1// art/dotpet_art.json から editor/apply_art.js が作る。手で直さない
2
3export const PALETTE: Record<string, number> = {
4  A: 0x6fbf73,
5  S: 0x3f8f52,
6  M: 0xe8f5c8,
7  E: 0x1f2a1f,
8  P: 0xa6b9bb,
9  K: 0x526e74,
10  B: 0x8a5a2b,
11  C: 0x174b55,
12  V: 0xf2d85c,
13}
14
types/index.d.ts 8 lines
1export type DotpetSize = 'big' | 'small' | 'image' | 'still'
2
3declare module 'claude-code' {
4  interface PluginState {
5    dotpet: { isOn: boolean; size: DotpetSize; redraw: number }
6  }
7}
8