A small ASCII pet that lives in the band above the prompt, fed by /pet

在輸入框正上方養一隻 ASCII 小寵物,用 /pet 呼叫。純裝飾,沒有任何需要照顧的狀態。
只支援 Claude Code. 使用 Claude Code 專屬的 function hooks, 安裝到其他 agent 不會運作.
/\_/\
( o.o )
> ^ <
Mochi
> _
| 指令 | 作用 |
|---|---|
/pet | 顯示/隱藏 |
/pet show · /pet hide | 明確開關 |
顯示與否會存進 plugin 自己的 $.store,跨 session 保留.
/config 裡有兩列:
MochiCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1AbovePrompt 目前只有 terminal 有)claude --plugin-dir ./plugins/pet --debug # 存檔即 hot-reload
claude plugin validate ./plugins/pet
claude plugin test ./plugins/pet
型別來自 /plugin-types 產生的 .claude/types/,更新 Claude Code 後要重跑.
hooks/pet.tsx 116 lines1/**
2 * pet — a small companion drawn in the band above the prompt.
3 *
4 * It has no state to keep but whether it is on screen: the pet blinks on the
5 * heartbeat and looks busy while a turn runs, and that is all it does.
6 *
7 * The art lives only in `sprite()`, so the terminal's `Raster` element can
8 * replace it later without touching anything else.
9 *
10 * Everything that takes `$` is a top-level declaration: the loader inventories
11 * what a hooks module reaches for, and refuses `$` passed into a closure it
12 * cannot see.
13 */
14import type { EngineInterface, Register, Timer } from 'claude-code'
15
16const STORE_KEY = 'visible'
17const TICK_MS = 700
18/** One tick of every eight is a blink, so the pet blinks about every 5 s. */
19const BLINK_EVERY = 8
20const COLOR = 'cyan'
21
22const EARS = ' /\\_/\\'
23const EYES_OPEN = ' ( o.o )'
24const EYES_SHUT = ' ( -.- )'
25const PAWS = ' > ^ <'
26
27let petName = 'Mochi'
28let showOnStart = true
29let visible = true
30let heartbeat: Timer | undefined
31let frame = 0
32
33/** Three lines of art, plus whatever floats beside the face this frame. */
34function sprite(isWorking: boolean): readonly string[] {
35 const blinking = frame % BLINK_EVERY === 0
36 const tag = isWorking ? ' .'.repeat((frame % 3) + 1) : ''
37 return [EARS, `${blinking ? EYES_SHUT : EYES_OPEN}${tag}`, PAWS]
38}
39
40function startHeartbeat($: EngineInterface): void {
41 if (heartbeat) return
42 heartbeat = $.clock.every(TICK_MS, () => {
43 frame += 1
44 $.ui.invalidate('ui.render')
45 })
46}
47
48function stopHeartbeat(): void {
49 heartbeat?.cancel()
50 heartbeat = undefined
51}
52
53async function setVisible($: EngineInterface, next: boolean): Promise<void> {
54 visible = next
55 if (visible) startHeartbeat($)
56 else stopHeartbeat()
57 await $.store.set(STORE_KEY, visible)
58 $.ui.invalidate('ui.render')
59}
60
61export const register: Register = (on, options) => {
62 petName = typeof options.petName === 'string' && options.petName.trim()
63 ? options.petName.trim()
64 : 'Mochi'
65 showOnStart = options.showOnStart !== false
66 visible = showOnStart
67
68 on('session.start', async ($, e, next) => {
69 const stored = await $.store.get(STORE_KEY)
70 visible = typeof stored === 'boolean' ? stored : showOnStart
71
72 await $.command.register({
73 name: 'pet',
74 description: `Show ${petName}, a small pet above the prompt.`,
75 argumentHint: '[show|hide]',
76 })
77
78 if (visible) startHeartbeat($)
79 return next(e)
80 })
81
82 on('command.run', { command: 'pet' }, async ($, e) => {
83 switch (e.args.trim().toLowerCase()) {
84 case '':
85 await setVisible($, !visible)
86 return { text: visible ? `${petName} is out.` : `${petName} went to sleep.` }
87 case 'show':
88 await setVisible($, true)
89 return { text: `${petName} is out.` }
90 case 'hide':
91 await setVisible($, false)
92 return { text: `${petName} went to sleep.` }
93 default:
94 return { text: 'Usage: /pet [show|hide]' }
95 }
96 })
97
98 on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
99 // A survey owns the band while it is up, and the drawing needs four rows.
100 if (!visible || e.props.hasSurvey || e.props.maxRows < 4) return next(e)
101
102 const { Box, Text } = $.ui.resolve(e)
103
104 return (
105 <Box width={e.props.bodyColumns} flexDirection="row" justifyContent="flex-end">
106 <Box flexDirection="column" alignItems="flex-start" marginRight={1}>
107 {sprite(e.props.isWorking).map((line) => (
108 <Text color={COLOR} wrap="truncate-end">{line}</Text>
109 ))}
110 <Text dimColor>{petName}</Text>
111 </Box>
112 </Box>
113 )
114 })
115}
116