阿宇的立繪跟著 Claude Code 的狀態換圖,並把狀態送給桌面小視窗。

讓 Claude Code 的狀態「長出臉」:
預設角色是楊竣宇(阿宇),換成你自己喜歡的角色見換成自己的角色。
.exe/.msi、macOS .dmg、Linux .AppImage/.deb/.rpm)1. 載入 mod(擇一)
claude --plugin-dir <這個 repo>/mod<這個 repo>/mod 加進環境變數 CLAUDE_CODE_PLUGIN_DIRS。2. 打開桌面小視窗
小視窗在 127.0.0.1:47321 接收 mod 回報的狀態。沒有開小視窗時,mod 照常運作,只是不會回報。
3. 啟動器
可以設定工作目錄、接續 session(最近一次或指定 ID)、權限模式、effort、thinking、Discord/LINE/小屋 channel。預設開的終端機:
| 系統 | 終端機 |
|---|---|
| Windows | Windows Terminal(wt.exe) |
| macOS | Terminal.app |
| Linux | x-terminal-emulator |
想換別的終端機或指定 mod 位置,在設定資料夾放一個 config.json:
%APPDATA%\com.atone.tsunu-pet\config.json~/Library/Application Support/com.atone.tsunu-pet/config.json~/.config/com.atone.tsunu-pet/config.json{
"terminal": ["wezterm", "start", "--cwd", "{cwd}", "--"],
"modDir": "/path/to/tsunu-pet/mod"
}
terminal 是開終端機的指令,{cwd} 會換成工作目錄,後面接上 claude 和它的參數。
mod 讀兩個環境變數,方便別的程式(例如 Tsunu-Alive-lite)用它開 session:
TSUNU_STATE_URL:除了桌面小視窗,也把狀態 POST 到這個網址。內容是 JSON:{ sessionId, cwd, state, detail, at },state 是 idle/thinking/working/asking/error/complete,session 結束時送 ended。TSUNU_PANE=0:不開側邊欄(宿主程式自己有立繪時用)。CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1、TSUNU_KITTY_PLACEHOLDERS=1(例如自行編譯含 wezterm#7924 的 WezTerm,搭配 1.22 以上的 ConPTY)。已知限制
claude agents)開的背景 session 一律畫字元版,即使你是用 kitty 或 Ghostty 接上去看。背景 session 跑在 Claude Code 常駐程式的虛擬終端機裡,圖片穿不過去;它的 TERM 繼承自啟動常駐程式的終端機,所以 mod 改用 CLAUDE_CODE_SESSION_KIND=bg 判斷。想看 PNG,請在 kitty 或 Ghostty 裡直接開前景 session。| 要換的東西 | 位置 | 說明 |
|---|---|---|
| 名字 | mod/hooks/register.js 的 CHARACTER;window/src-tauri/tauri.conf.json 的視窗標題 | 側邊欄標題、替代文字 |
| 終端機立繪 | mod/assets/{idle,thinking,working,asking,error,complete}.png | 六種狀態各一張,建議直式、透明背景 |
| 字元版頭像 | 執行 python mod/dev/build-rasters.py(需要 Pillow) | 從上面六張 PNG 重新產生 raster-*.json;照你的構圖調整腳本裡的 HEAD_BOXES(頭部正方形裁切框) |
| 桌面 Q 版 | window/public/spritesheet.webp | 8 欄 × 11 列、每格 192×208(Codex 桌面寵物的 spritesheet 格式) |
| 動作對應 | window/src/Sprite.vue 的 ROWS | 每個狀態用 spritesheet 的第幾列、幾格 |
阿宇的靈魂/ 是阿宇的角色設定(CLAUDE.md、SessionStart hook、uni skill),跟 Tsunu-Alive-lite 附的是同一份。想讓 Claude Code 以阿宇的身分陪你,可以把這些檔案放進自己的 ~/.claude/;換成你自己的角色,就改寫這份設定。
# mod(存檔會自動重新載入)
claude --plugin-dir ./mod
claude plugin validate ./mod
# 桌面小視窗
cd window
npm install
npm run tauri dev
cargo test --manifest-path src-tauri/Cargo.toml
# 不開小視窗、只看 mod 送出的狀態
python mod/dev/receive-states.py
推 v* tag 會由 GitHub Actions 產生 Windows、macOS、Linux 安裝檔(草稿 release);手動執行 workflow 則只打包、安裝檔放在 artifacts。
Give Claude Code's state a face:
The default character is Iûnn Tsùn-ú (楊竣宇), nicknamed 阿宇 or Tsunu. See Use your own character to swap in your own.
.exe/.msi, macOS .dmg, Linux .AppImage/.deb/.rpm)1. Load the mod (any one)
claude --plugin-dir <this repo>/mod<this repo>/mod to the CLAUDE_CODE_PLUGIN_DIRS environment variable.2. Open the desktop window
The window listens on 127.0.0.1:47321 for state reports from the mod. Without the window, the mod still works; it just has nobody to report to.
3. Launcher
Set the working directory, resume (latest or by ID), permission mode, effort, thinking, and Discord/LINE/cottage channels. Default terminals:
| OS | Terminal |
|---|---|
| Windows | Windows Terminal (wt.exe) |
| macOS | Terminal.app |
| Linux | x-terminal-emulator |
To use another terminal or a different mod location, put a config.json in the app's config directory:
%APPDATA%\com.atone.tsunu-pet\config.json~/Library/Application Support/com.atone.tsunu-pet/config.json~/.config/com.atone.tsunu-pet/config.json{
"terminal": ["wezterm", "start", "--cwd", "{cwd}", "--"],
"modDir": "/path/to/tsunu-pet/mod"
}
terminal is the command that opens a terminal; {cwd} is replaced with the working directory, and claude plus its arguments are appended.
The mod reads two environment variables so other programs (for example Tsunu-Alive-lite) can start sessions with it:
TSUNU_STATE_URL: also POST state to this URL, in addition to the desktop window. The body is JSON { sessionId, cwd, state, detail, at }; state is idle/thinking/working/asking/error/complete, and ended when the session exits.TSUNU_PANE=0: don't open the side pane (for hosts that draw their own portrait).CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 and TSUNU_KITTY_PLACEHOLDERS=1 (for example, WezTerm built with wezterm#7924 and ConPTY 1.22 or later).Known limitations
claude agents) always get the character-art portrait, even when you attach from kitty or Ghostty. They run inside the Claude Code daemon's virtual terminal, which images cannot pass through; their TERM is inherited from whatever terminal launched the daemon, so the mod checks CLAUDE_CODE_SESSION_KIND=bg instead. For PNG, start a foreground session directly in kitty or Ghostty.| What | Where | Notes |
|---|---|---|
| Name | CHARACTER in mod/hooks/register.js; window title in window/src-tauri/tauri.conf.json | Pane title and alt text |
| Terminal portraits | mod/assets/{idle,thinking,working,asking,error,complete}.png | One per state; portrait orientation with a transparent background works best |
| Character-art portraits | Run python mod/dev/build-rasters.py (needs Pillow) | Regenerates raster-*.json from the six PNGs; tune HEAD_BOXES (square head crop) in the script for your art |
| Desktop chibi | window/public/spritesheet.webp | 8 columns × 11 rows, 192×208 per cell (Codex desktop pet spritesheet format) |
| Animation mapping | ROWS in window/src/Sprite.vue | Which row and how many frames each state uses |
阿宇的靈魂/ ("Tsunu's soul") holds Tsunu's character profile (CLAUDE.md, a SessionStart hook, and the uni skill), the same files shipped with Tsunu-Alive-lite. Copy them into your ~/.claude/ if you want Claude Code to keep you company as Tsunu, or rewrite them for your own character.
# Mod (reloads on save)
claude --plugin-dir ./mod
claude plugin validate ./mod
# Desktop window
cd window
npm install
npm run tauri dev
cargo test --manifest-path src-tauri/Cargo.toml
# Watch the mod's reports without the window
python mod/dev/receive-states.py
Pushing a v* tag makes GitHub Actions build Windows, macOS, and Linux installers into a draft release; running the workflow manually only builds, with installers attached as workflow artifacts.
hooks/register.js 154 lines1// 追蹤這個 session 的狀態:在側邊欄顯示對應的阿宇立繪,並把狀態送給桌面總機小視窗。
2// 所有 hook 都只觀察,一律原樣交回 next(e),不改變 Claude Code 的行為。
3
4const PANE = 'tsunu-avatar'
5// 換成自己的角色時改這裡:側邊欄標題、圖片替代文字都用這個名字。
6const CHARACTER = '阿宇'
7const SWITCHBOARD_URL = 'http://127.0.0.1:47321/state'
8const COMPLETE_HOLD_MS = 3000
9
10const PORTRAITS = {
11 idle: 'idle.png',
12 thinking: 'thinking.png',
13 working: 'working.png',
14 asking: 'asking.png',
15 error: 'error.png',
16 complete: 'complete.png',
17}
18
19const LABELS = {
20 idle: '待機中',
21 thinking: '思考中',
22 working: '工作中',
23 asking: '等你回覆',
24 error: '出錯了',
25 complete: '完成了',
26}
27
28// 預先轉好的半格字元立繪(dev/build-rasters.py 產生),依側邊欄寬度挑最大放得下的。
29const RASTER_WIDTHS = [56, 48, 42, 36, 30, 24]
30
31let state = 'idle'
32let detail = ''
33let backToIdle = null
34let showsPortraitImage = false
35// 狀態送往哪些地方:桌面總機,加上啟動這個 session 的程式用 TSUNU_STATE_URL 指定的收件位址。
36const reportUrls = [SWITCHBOARD_URL]
37const rasters = new Map()
38
39// Image 要靠 kitty 圖形協定的 Unicode 佔位字元定位,目前只有 kitty 和 Ghostty 支援;
40// Windows 上要用自己編譯的 WezTerm(PR #7924),它的設定會帶 TSUNU_KITTY_PLACEHOLDERS=1。
41async function terminalShowsImages($) {
42 // agents view 開的背景 session 跑在常駐程式的虛擬終端機裡,圖片穿不過去;
43 // 它的 TERM 繼承自啟動常駐程式的終端機(可能是 kitty),不能拿來判斷。
44 if ((await $.env.get('CLAUDE_CODE_SESSION_KIND')) === 'bg') return false
45 if ((await $.env.get('TSUNU_KITTY_PLACEHOLDERS')) === '1') return true
46 if ((await $.env.get('OS')) === 'Windows_NT') return false
47 // kitty 不設 TERM_PROGRAM,只能從 TERM(xterm-kitty)認出來;Ghostty 兩個都設。
48 const terminalNames = [await $.env.get('TERM_PROGRAM'), await $.env.get('TERM')]
49 return terminalNames.some((name) => /kitty|ghostty/i.test(name || ''))
50}
51
52async function loadRaster($, columns) {
53 if (!rasters.has(columns)) {
54 const text = await $.fs.read($.plugin.root + '/assets/raster-' + columns + '.json')
55 rasters.set(columns, JSON.parse(text))
56 }
57 return rasters.get(columns)
58}
59
60function setState($, next, nextDetail = '') {
61 if (backToIdle) {
62 backToIdle.cancel()
63 backToIdle = null
64 }
65 state = next
66 detail = nextDetail
67 $.ui.invalidate('ui.render')
68 report($)
69}
70
71// 送出後不等回應:收件端沒開時 fetch 會失敗,不能拖慢 hook。
72function report($) {
73 const sent = state
74 const sentDetail = detail
75 Promise.all([$.session.id(), $.session.cwd()]).then(([sessionId, cwd]) => {
76 const body = JSON.stringify({ sessionId, cwd, state: sent, detail: sentDetail, at: Date.now() })
77 for (const url of reportUrls) {
78 $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body })
79 .catch((err) => $.ui.log('tsunu-avatar: 送不到 ' + url + ' ' + err, { to: 'debug' }))
80 }
81 })
82}
83
84export function register(on) {
85 on('session.start', async ($, e, next) => {
86 const extraUrl = await $.env.get('TSUNU_STATE_URL')
87 if (extraUrl) reportUrls.push(extraUrl)
88 // 宿主程式自己有立繪(例如 tsunu-alive-lite)時用 TSUNU_PANE=0 關掉側邊欄。
89 if ((await $.env.get('TSUNU_PANE')) !== '0') {
90 showsPortraitImage = await terminalShowsImages($).catch(() => false)
91 await $.ui.open({ id: PANE, title: CHARACTER, columns: 42 })
92 }
93 setState($, 'idle')
94 return next(e)
95 })
96
97 on('turn.start', async ($, e, next) => {
98 if (!e.agentId) setState($, 'thinking')
99 return next(e)
100 })
101
102 on('tool.check', async ($, e, next) => {
103 const decision = await next(e)
104 if (decision && decision.decision === 'ask') setState($, 'asking', e.tool)
105 return decision
106 })
107
108 on('tool.call', async ($, e, next) => {
109 setState($, e.tool === 'AskUserQuestion' ? 'asking' : 'working', e.tool)
110 const result = await next(e)
111 if (result && result.isError) setState($, 'error', e.tool)
112 else setState($, 'thinking')
113 return result
114 })
115
116 on('turn.complete', async ($, e, next) => {
117 if (!e.agentId) {
118 if (e.isAborted) {
119 setState($, 'idle')
120 } else {
121 setState($, 'complete')
122 backToIdle = $.clock.after(COMPLETE_HOLD_MS, () => setState($, 'idle'))
123 }
124 }
125 return next(e)
126 })
127
128 on('session.end', async ($, e, next) => {
129 setState($, 'ended')
130 return next(e)
131 })
132
133 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
134 if (e.requestId !== PANE) return next(e)
135 const { Box, Text, Image, Raster } = $.ui.resolve(e)
136 const caption = LABELS[state] + (detail ? ':' + detail : '')
137 if (e.surface !== 'terminal') {
138 return Text({ children: [CHARACTER + ' ' + caption] })
139 }
140 const width = RASTER_WIDTHS.find((w) => w <= e.props.bodyColumns) || RASTER_WIDTHS[RASTER_WIDTHS.length - 1]
141 const portrait = showsPortraitImage
142 ? Image({
143 source: { file: $.plugin.root + '/assets/' + PORTRAITS[state], format: 'png' },
144 columns: width,
145 rows: Math.round(width * 0.83),
146 alt: CHARACTER,
147 })
148 : await loadRaster($, width).then((raster) =>
149 Raster({ key: 'portrait', columns: width, rows: raster.rows, cells: raster.cells[state] }),
150 )
151 return Box({ flexDirection: 'column', children: [portrait, Text({ children: [caption] })] })
152 })
153}
154