Claude Desktop の Code タブで、入力欄の上に好きなキャラと吹き出しを出す

Claude Desktop の Code タブで、入力欄の上に好きなキャラと吹き出しを出す Claude Code の mod です。Claude の返答の最後のひとことを吹き出しに出し、作業中や返答の中身に合わせて表情を切り替えます。
Anthropic の公式ツールではありません。

キャラの名前、色、ひとこと、表情を決める言葉、絵は、すべて characters/<キャラ>/ にまとまっています。mod 本体を触らずに、自分のキャラに差し替えられます。
| フォルダ | キャラ | 絵 |
|---|---|---|
characters/simple-pet/ | ぷに。最初から組み込まれている | 手描きの SVG。MIT |
characters/kurato-ai/ | 倉戸あい。AI で絵を作るとどうなるかの見本 | Magnific で生成。ライセンスの対象外 |

simple-pet が組み込まれている) node tools/build-character.mjs characters/simple-pet
~/.claude/settings.json の env に、mod のフォルダを書く {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:/path/to/claude-desktop-pet/mod/desktop-pet",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH は、mod のファイルを直したときに読み込み直すための設定です。
session の途中で mod を読み込んでも、Desktop には表示されないことがあります。表示されないときは、まず session を開き直してください。
吹き出しを活かすには、Claude に「返答の最後に短いひとことを置く」話し方をしてもらう必要があります。~/.claude/CLAUDE.md に書くテンプレートを examples/CLAUDE.md-snippet.md に置いています。
Claude Desktop 自身の通知の代わりに、キャラの表情つきの通知を出します。
| その session を表示しているか | Claude のウィンドウ | 通知 |
|---|---|---|
| 表示していない(別の session を見ている) | 前面でも後ろでも | 出す |
| 表示している | 後ろ | 出す |
| 表示している | 前面 | 出さない |
使うときの注意です。
powershell.exe)で動かします。Windows 以外では何もしませんcharacter.json の notify.enabled を false にしますmod/desktop-pet/notify/notify.log(mod 側)と ps1-trace.log(スクリプト側)に記録が残っていますcharacters/simple-pet/ をコピーして、characters/<自分のキャラ>/ を作るcharacter.json を書き換える(項目は docs/character-format.md)svg/ に表情ごとの絵を置く。少なくとも normal.svg と、character.json で名前を出した表情の絵が要る。通知を使うなら、icons/ に表情ごとの PNG(256px ほどの正方形。顔のまわりを切り出すと見やすい)も置く node tools/optimize-svg.mjs in.svg characters/<自分のキャラ>/svg/happy.svg
node tools/build-character.mjs characters/<自分のキャラ>
キャラの話し方の決め方、AI で絵を作る手順、つまずきやすいところは docs/making-of.md にまとめています。
claude plugin validate mod/desktop-pet
claude plugin test mod/desktop-pet
テストは組み込んだキャラの character.json から期待する値を取るので、どのキャラを組み込んでも同じテストで確かめられます。
| パス | 中身 |
|---|---|
mod/desktop-pet/ | mod 本体。hooks/register.tsx が描画とイベント処理、hooks/character.ts が組み込んだキャラ(生成したファイル) |
mod/desktop-pet/notify/ | Windows の通知を出す notify.ps1 と、組み込んだキャラの表情アイコン |
characters/<キャラ>/ | character.json、表情ごとの svg/ と icons/、preview.png |
tools/build-character.mjs | キャラのフォルダから hooks/character.ts を作り、アイコンを notify/icons/ にコピーする。足りない絵や大きすぎる絵があれば止まる |
tools/optimize-svg.mjs | SVG を Claude Code の上限に収まるよう縮める |
docs/making-of.md | キャラ作りから mod の完成までの手順と、つまずいたところ |
docs/character-format.md | character.json の書き方 |
examples/CLAUDE.md-snippet.md | キャラの話し方を CLAUDE.md に書くテンプレート |
MIT です。ただし characters/kurato-ai/ の絵(svg/、icons/、preview.png)は Magnific で生成したもので、ライセンスの対象外です。Claude のロゴはリポジトリに含めず、通知を出す PC の Claude Desktop から読み込んで使います。
hooks/register.tsx 186 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mood } from '../types'
5import { character } from './character'
6
7const line = atom({ plugin: 'desktop-pet', key: 'line' } as const, null)
8const activity = atom({ plugin: 'desktop-pet', key: 'activity' } as const, null)
9const mood = atom({ plugin: 'desktop-pet', key: 'mood' } as const, 'normal')
10
11const greeting = character.userCall === '' ? character.greeting : `${character.userCall}、${character.greeting}`
12const activityRules = character.activities.map(({ tool, line }) => ({ pattern: new RegExp(tool), line }))
13
14// 返答の最後の段落を、吹き出し用のひとことにする。表、箇条書き、コード、出典は飛ばす。
15export const closingLine = (answer: string): string | null => {
16 const paragraphs = answer
17 .split(/\n\s*\n/)
18 .map(p => p.trim())
19 .filter(p => p !== '')
20 .filter(p => !/^(```|\||- |\* |\d+\. |#|Sources:)/.test(p))
21
22 const last = paragraphs.at(-1)
23 // 確認の質問は本文で読んでほしいので、吹き出しには出さない
24 if (last === undefined || /[??]$/.test(last)) {
25 return null
26 }
27
28 const plain = last
29 .replace(/^>\s?/gm, '')
30 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
31 .replace(/[*_`]/g, '')
32 .replace(/\s*\n\s*/g, ' ')
33
34 return plain.length > character.maxLine ? `${plain.slice(0, character.maxLine - 1)}…` : plain
35}
36
37// ひとことに含まれる言葉から表情を決める。character.json の moods の上にあるものほど優先する。
38export const moodFor = (said: string): Mood =>
39 character.moods.find(({ words }) => words.some(word => said.includes(word)))?.mood ?? 'normal'
40
41// 作業中に吹き出しへ出すひとこと。ツール名から決める。
42export const activityFor = (tool: string): string =>
43 activityRules.find(({ pattern }) => pattern.test(tool))?.line ?? character.thinkingLine
44
45// Desktop は、表示している session にだけ画面をつなぐ(別の session に切り替えると離れる)
46async function isShownOnDesktop($: EngineInterface): Promise<boolean> {
47 try {
48 return (await $.session.surfaces()).includes('desktop')
49 } catch {
50 return false
51 }
52}
53
54// 通知を出そうとした結果を notify/notify.log に残す(最新の 50 行)。記録に失敗しても止めない
55async function logNotify($: EngineInterface, text: string) {
56 try {
57 const path = `${$.plugin.root}/notify/notify.log`
58 const old = (await $.fs.exists(path)) ? (await $.fs.read(path)).split('\n').filter(l => l !== '') : []
59 const stamp = new Date(await $.clock.now()).toISOString()
60 await $.fs.write(path, [...old, `${stamp} ${text}`].slice(-50).join('\n') + '\n')
61 } catch {
62 // 記録できなくても、通知や作業には関係しない
63 }
64}
65
66function notifyArgv($: EngineInterface, args: string[]): string[] {
67 return ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', `${$.plugin.root}/notify/notify.ps1`, ...args]
68}
69
70// Windows の通知を、キャラのそのときの表情のアイコンで出す。Windows 以外では powershell.exe がなく、何もしない
71async function notify($: EngineInterface, body: string, face: Mood) {
72 if (!character.notify.enabled) {
73 return
74 }
75 let sessionId = '?'
76 try {
77 sessionId = await $.session.id()
78 // この session を表示していなければ、Claude のウィンドウが前面でも通知する。
79 // 表示していれば、Claude のウィンドウが前面かどうかの判断を notify.ps1 に任せる
80 const shown = await isShownOnDesktop($)
81 const force = shown ? [] : ['-Force']
82 const result = await $.process.run(
83 notifyArgv($, ['-Body', body, '-Mood', face, '-SessionId', sessionId, '-FallbackTitle', character.name, ...force]),
84 { timeoutMs: 15000 },
85 )
86 const stderr = result.stderr.trim().replace(/\s+/g, ' ').slice(0, 300)
87 await logNotify($, `${sessionId.slice(0, 8)} shown=${shown} exit=${result.exitCode}${stderr === '' ? '' : ` stderr=${stderr}`}`)
88 } catch (error) {
89 await logNotify($, `${sessionId.slice(0, 8)} failed: ${String(error).slice(0, 300)}`)
90 }
91}
92
93// 送り主「Claude」を先に登録しておく。初めての通知の直前に登録すると、Windows が名前とアイコンを覚え損ねる
94async function registerSender($: EngineInterface) {
95 if (!character.notify.enabled) {
96 return
97 }
98 try {
99 await $.process.run(notifyArgv($, ['-RegisterOnly']), { timeoutMs: 15000 })
100 } catch {
101 // Windows 以外や、登録できなかったときは何もしない
102 }
103}
104
105export const register: Register = on => {
106 on('session.start', async ($, e, next) => {
107 void registerSender($)
108
109 return next(e)
110 })
111
112 on('classic.Notification', async ($, e, next) => {
113 if (e.notification_type.includes('permission')) {
114 const call = character.userCall === '' ? '' : `${character.userCall}、`
115 void notify($, `${call}${character.notify.permissionLine}`, 'worried' in character.svgs ? 'worried' : 'normal')
116 }
117
118 return next(e)
119 })
120
121 on('prompt.submit', async ($, e, next) => {
122 await update($, activity, () => character.thinkingLine)
123
124 return next(e)
125 })
126
127 on('tool.call', async ($, e, next) => {
128 await update($, activity, () => activityFor(e.tool))
129
130 return next(e)
131 })
132
133 on('turn.complete', async ($, e, next) => {
134 if (e.agentId === undefined) {
135 await update($, activity, () => null)
136 if (e.reason === 'answer') {
137 const said = closingLine(e.answer)
138 if (said !== null) {
139 await update($, line, () => said)
140 await update($, mood, () => moodFor(said))
141 }
142 void notify($, said ?? character.notify.doneLine, said === null ? 'normal' : moodFor(said))
143 } else if (e.reason === 'error') {
144 await update($, line, () => character.errorLine)
145 await update($, mood, () => character.errorMood)
146 void notify($, character.errorLine, character.errorMood)
147 }
148 }
149
150 return next(e)
151 })
152
153 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
154 if (e.props.hasSurvey) {
155 return next(e)
156 }
157
158 const doing = e.props.isWorking ? await read($, activity) : null
159 const said = doing ?? (await read($, line)) ?? greeting
160 const face = doing !== null ? character.workingMood : await read($, mood)
161
162 if (e.surface !== 'desktop') {
163 const { Box, Text } = $.ui.resolve(e)
164
165 return (
166 <Box>
167 <Text color={character.color} bold>{character.label} </Text>
168 <Text dimColor={doing !== null} wrap="truncate-end">{said}</Text>
169 </Box>
170 )
171 }
172
173 const { Box, Svg, Text } = $.ui.resolve(e)
174 const svg = character.svgs[face] ?? character.svgs['normal'] ?? ''
175
176 return (
177 <Box flexDirection="row" alignItems="center" gap={1}>
178 <Svg source={svg} alt={`${character.name}(${face})`} width={character.size} height={character.size} />
179 <Box borderStyle="round" borderColor={character.color} paddingX={1} flexShrink={1}>
180 <Text dimColor={doing !== null} wrap="wrap">{said}</Text>
181 </Box>
182 </Box>
183 )
184 })
185}
186hooks/character.ts 89 lines1// characters/simple-pet から tools/build-character.mjs で生成。手で編集しない
2import type { Character } from '../types'
3
4export const character: Character = {
5 "name": "ぷに",
6 "label": "ぷに",
7 "userCall": "",
8 "color": "#5fb8a3",
9 "size": 72,
10 "maxLine": 60,
11 "greeting": "今日もいっしょにがんばろうね。",
12 "errorLine": "あわわ、エラーで止まっちゃった…",
13 "thinkingLine": "かんがえ中…",
14 "notify": {
15 "enabled": true,
16 "doneLine": "おわったよ。",
17 "permissionLine": "たしかめてほしいことがあるよ。"
18 },
19 "activities": [
20 {
21 "tool": "^(Edit|Write|NotebookEdit)$",
22 "line": "かきかき中…"
23 },
24 {
25 "tool": "^(Read|Grep|Glob)$",
26 "line": "よみよみ中…"
27 },
28 {
29 "tool": "^(Bash|PowerShell)$",
30 "line": "コマンド実行中…"
31 },
32 {
33 "tool": "^(WebSearch|WebFetch)$",
34 "line": "しらべもの中…"
35 },
36 {
37 "tool": "^(Agent|Workflow)$",
38 "line": "なかまと手分け中…"
39 },
40 {
41 "tool": "^mcp__",
42 "line": "そとのサービスとお話し中…"
43 }
44 ],
45 "workingMood": "serious",
46 "errorMood": "troubled",
47 "moods": [
48 {
49 "mood": "troubled",
50 "words": [
51 "すみません",
52 "ごめん",
53 "失敗",
54 "エラー",
55 "だめでした",
56 "あわわ"
57 ]
58 },
59 {
60 "mood": "worried",
61 "words": [
62 "休んで",
63 "無理しないで",
64 "心配",
65 "大丈夫",
66 "疲れ"
67 ]
68 },
69 {
70 "mood": "happy",
71 "words": [
72 "やった",
73 "よかった",
74 "できました",
75 "できた",
76 "通りました",
77 "ありがとう"
78 ]
79 }
80 ],
81 "svgs": {
82 "happy": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><g stroke=\"#2f3a40\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 52L8 30\" fill=\"none\" stroke-width=\"6\"/><path d=\"M80 52L92 30\" fill=\"none\" stroke-width=\"6\"/><ellipse cx=\"38\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><ellipse cx=\"62\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><path d=\"M50 22C74 22 86 44 86 64C86 80 72 88 50 88C28 88 14 80 14 64C14 44 26 22 50 22Z\" fill=\"#8fd6c4\"/><path d=\"M30 36C34 30 40 27 45 27\" fill=\"none\" stroke=\"#ffffff\" stroke-width=\"4\"/><path d=\"M33 55Q38 49 43 55\" fill=\"none\"/><path d=\"M57 55Q62 49 67 55\" fill=\"none\"/><ellipse cx=\"30\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><ellipse cx=\"70\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><path d=\"M43 62Q50 74 57 62Z\" fill=\"#e0605e\"/><path d=\"M14 9L16.1 13.9L21 16L16.1 18.1L14 23L11.9 18.1L7 16L11.9 13.9Z\" fill=\"#ffd34d\" stroke=\"none\"/><path d=\"M86 8L87.8 12.2L92 14L87.8 15.8L86 20L84.2 15.8L80 14L84.2 12.2Z\" fill=\"#ffd34d\" stroke=\"none\"/><path d=\"M50 3L51.5 6.5L55 8L51.5 9.5L50 13L48.5 9.5L45 8L48.5 6.5Z\" fill=\"#ffd34d\" stroke=\"none\"/></g></svg>",
83 "normal": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><g stroke=\"#2f3a40\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><ellipse cx=\"38\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><ellipse cx=\"62\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><path d=\"M50 22C74 22 86 44 86 64C86 80 72 88 50 88C28 88 14 80 14 64C14 44 26 22 50 22Z\" fill=\"#8fd6c4\"/><path d=\"M30 36C34 30 40 27 45 27\" fill=\"none\" stroke=\"#ffffff\" stroke-width=\"4\"/><circle cx=\"38\" cy=\"54\" r=\"3.5\" fill=\"#2f3a40\" stroke=\"none\"/><circle cx=\"62\" cy=\"54\" r=\"3.5\" fill=\"#2f3a40\" stroke=\"none\"/><ellipse cx=\"30\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><ellipse cx=\"70\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><path d=\"M45 64Q50 68 55 64\" fill=\"none\"/></g></svg>",
84 "serious": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><g stroke=\"#2f3a40\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><ellipse cx=\"38\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><ellipse cx=\"62\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><path d=\"M50 22C74 22 86 44 86 64C86 80 72 88 50 88C28 88 14 80 14 64C14 44 26 22 50 22Z\" fill=\"#8fd6c4\"/><path d=\"M30 36C34 30 40 27 45 27\" fill=\"none\" stroke=\"#ffffff\" stroke-width=\"4\"/><circle cx=\"38\" cy=\"54\" r=\"9\" fill=\"#ffffff\" fill-opacity=\"0.6\"/><circle cx=\"62\" cy=\"54\" r=\"9\" fill=\"#ffffff\" fill-opacity=\"0.6\"/><path d=\"M47 54L53 54\" fill=\"none\"/><circle cx=\"38\" cy=\"55\" r=\"3\" fill=\"#2f3a40\" stroke=\"none\"/><circle cx=\"62\" cy=\"55\" r=\"3\" fill=\"#2f3a40\" stroke=\"none\"/><path d=\"M31 41L44 44\" fill=\"none\"/><path d=\"M69 41L56 44\" fill=\"none\"/><path d=\"M45 68L55 68\" fill=\"none\"/><path d=\"M57 48L61 46\" fill=\"none\" stroke=\"#ffffff\" stroke-width=\"2\"/></g></svg>",
85 "troubled": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><g stroke=\"#2f3a40\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M78 50L90 34L82 26\" fill=\"none\" stroke-width=\"6\"/><ellipse cx=\"38\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><ellipse cx=\"62\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><path d=\"M50 22C74 22 86 44 86 64C86 80 72 88 50 88C28 88 14 80 14 64C14 44 26 22 50 22Z\" fill=\"#8fd6c4\"/><path d=\"M30 36C34 30 40 27 45 27\" fill=\"none\" stroke=\"#ffffff\" stroke-width=\"4\"/><circle cx=\"38\" cy=\"54\" r=\"3.5\" fill=\"#2f3a40\" stroke=\"none\"/><circle cx=\"62\" cy=\"54\" r=\"3.5\" fill=\"#2f3a40\" stroke=\"none\"/><ellipse cx=\"30\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><ellipse cx=\"70\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><path d=\"M33 46L43 44\" fill=\"none\"/><path d=\"M57 44L67 46\" fill=\"none\"/><path d=\"M42 66Q46 62 50 66Q54 70 58 66\" fill=\"none\"/><path d=\"M24 26Q18 36 24 40Q30 36 24 26Z\" fill=\"#8ec5ff\"/></g></svg>",
86 "worried": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><g stroke=\"#2f3a40\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" transform=\"rotate(-12 50 80)\"><ellipse cx=\"38\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><ellipse cx=\"62\" cy=\"88\" rx=\"8\" ry=\"4\" fill=\"#6fbfab\"/><path d=\"M50 22C74 22 86 44 86 64C86 80 72 88 50 88C28 88 14 80 14 64C14 44 26 22 50 22Z\" fill=\"#8fd6c4\"/><path d=\"M30 36C34 30 40 27 45 27\" fill=\"none\" stroke=\"#ffffff\" stroke-width=\"4\"/><circle cx=\"38\" cy=\"55\" r=\"4.5\" fill=\"#2f3a40\" stroke=\"none\"/><circle cx=\"62\" cy=\"55\" r=\"4.5\" fill=\"#2f3a40\" stroke=\"none\"/><circle cx=\"39.5\" cy=\"53.5\" r=\"1.5\" fill=\"#ffffff\" stroke=\"none\"/><circle cx=\"63.5\" cy=\"53.5\" r=\"1.5\" fill=\"#ffffff\" stroke=\"none\"/><path d=\"M31 47Q36 46 43 42\" fill=\"none\"/><path d=\"M69 47Q64 46 57 42\" fill=\"none\"/><ellipse cx=\"30\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><ellipse cx=\"70\" cy=\"64\" rx=\"5\" ry=\"3\" fill=\"#f4a6a6\" stroke=\"none\"/><path d=\"M46 69Q50 66 54 69\" fill=\"none\"/></g></svg>"
87 }
88}
89types/index.d.ts 29 lines1// 表情の名前。characters/<キャラ>/svg/ のファイル名(拡張子なし)。normal は必須
2export type Mood = string
3
4// characters/<キャラ>/character.json の中身に、表情ごとの SVG を足したもの
5export type Character = {
6 name: string
7 label: string
8 userCall: string
9 color: string
10 size: number
11 maxLine: number
12 greeting: string
13 errorLine: string
14 thinkingLine: string
15 // Windows の通知。返答が終わったとき、権限の確認待ちのときに出す
16 notify: { enabled: boolean; doneLine: string; permissionLine: string }
17 activities: { tool: string; line: string }[]
18 workingMood: Mood
19 errorMood: Mood
20 moods: { mood: Mood; words: string[] }[]
21 svgs: Record<Mood, string>
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'desktop-pet': { line: string | null; activity: string | null; mood: Mood }
27 }
28}
29