入力欄の上に Clawd と「今何をしているか」を表示する(非公式)

Claude Code の入力欄の上に、Clawd(Claude Code のマスコット。起動画面のキャラ)と「今何をしているか」を表示する mod(function hooks plugin)です。絵と文字を同じ 3 行に収め、上に 1 行あけます。
非公式の mod です。 Anthropic とは関係がなく、Anthropic が作ったり認めたりしたものではありません。Claude、Claude Code、Clawd(起動画面のキャラクター)は Anthropic のものです。
An unofficial, fan-made mod for Claude Code: Clawd, the Claude Code mascot, sits above your prompt and shows what Claude is doing right now. Not affiliated with or endorsed by Anthropic.
▄▄▄▄▄▄ !!! 「?」「!」の動きを確認している
▄█▘██▝█▄ 「?」は増えるが「!」は全フレーム同じで動いていない
▜▜▀▀▛▛ 実行中: Print each mark ツール 3 回(Bash 2 · Read 1)
| 行 | 中身 | 更新 |
|---|---|---|
| 1 行目 | 全体として何をしているか(Haiku。ターンの始めは依頼の頭) | 目的が変わったときだけ言い換える |
| 2 行目 | 直近で何をして、何がわかったか(Haiku) | ツールが終わるたび(4 秒に 1 回まで) |
| 3 行目 | 今の具体的な作業(下のルールの一言)と、ツールの回数 | すぐ |
claude-haiku-5-5、effort low)が 1 回の呼び出しでまとめます。前に呼んでから 4 秒たっていなければ呼ばず、失敗したときは前の行のままです。ターンが終わると 1 行目は状態の一言(できた!など)に戻り、2 行目は最後にわかったことを次のターンまで残します。終わったあとに届いた答えは捨てます。| 出来事 | 状態 | 一言の例 |
|---|---|---|
| ターン開始 | thinking | 「<依頼の頭>」に取りかかってる |
| Read | reading | <file> を読んでる / <file> の 120 行目あたりを読んでる |
| Edit / Write / NotebookEdit | writing | <file> を書き換え中 / <file> を書き出し中 / <file> のセルを編集中 |
| Grep / Glob / ToolSearch | searching | 「<pattern>」を <path> から探してる / <pattern> に合うファイルを探してる |
| Bash | running | 実行中: <description または command> / 裏で実行中: … |
| WebSearch / WebFetch / MCP | browsing | 「<query>」をウェブ検索中 / <host> を読みに行ってる / Notion で notion-search を実行中 |
| Agent | delegating | <subagent_type> に頼んでる: <description> |
| Skill / TodoWrite / AskUserQuestion | thinking | スキル <name> を使ってる / 段取りを整理中(3 件) / あなたの答えを待ってる |
| ツールが失敗 / 拒否 | oops / denied | <tool> がうまくいかなかった… / <tool> は止められた |
| ターン終了 | done / stopped / crashed | できた! / 中断しました・それは引き受けられなかった / エラーで止まっちゃった… |
| 起動直後 | idle | 待機中。なにかあれば呼んでね |
▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝)を1文字2×2画素として読み直した 18×5 画素の絵です。もとと同じく2×2画素を四分割ブロック文字1文字で描くので、大きさはもとと同じ横 9 文字・3 行です。1文字に塗れるのは2色までなので、虹色は文字ごとに1色にし、涙は目と別の文字に置きます。体はテーマの claude 色 #D77757、目は #1F1F1F です。? → ?? → ???、実行中は ! → !! → !!!、ウェブは ~ → ~~~)。同じ作業が続くとき(Read が続くなど)は、動きを頭に戻さずそのまま続けます。| 状況 | 状態 | 表情とリアクション |
|---|---|---|
| 終わった | done | 喜び(目を細めて口を開ける、バンザイ)。虹色になって足踏みしながら 3 回跳ねる、* |
| ツールが失敗した | oops | 驚き(目を開いて口を開ける)。!? と ?! を入れ替えながら、びくっと震える |
| ターンがエラーで止まった | crashed | 泣き(涙)。震える、; |
| ツールを止められた | denied | むっ。左右をにらむ、# |
| 中断・お断り | stopped | しょんぼり。左右を見てから目を伏せる、... |
| 仲間に頼む | delegating | 左右の手を交互に振る、>>> |
| 待機が 1 分続いた | idle ほか | 疲れ(目を細める)のあと、目を閉じて居眠り、z zz zzz |
z zz zzz と居眠りします。Client(描画側で動く要素)が自分の時計で動かします。コマが変わったとき(虹色の間は 0.1 秒ごと)だけ描き直し、コマごとにプラグインを呼び出しません。work-buddy を無効にします。AbovePrompt)を描くのは terminal と desktop だけです。一言のテンプレートと集計は hooks/mood.ts、Clawd の絵・動き・色は hooks/clawd.ts、絵を動かす Client は hooks/buddy.tsx、イベントの処理と帯の描画は hooks/register.tsx にあります。
narrate)をオフにします。インストールのときに出る設定画面か、/config で切り替えられます。オフのときは、3 行目のルールの一言と集計だけで動きます。Claude Code の terminal で:
/plugin install work-buddy --marketplace poska-lab/cc-work-buddy
Add marketplace? に y で答え、範囲(user など)を選ぶと、その場で動き始めます。shell からなら:
claude plugin install work-buddy --marketplace poska-lab/cc-work-buddy
desktop アプリでは /plugin が使えないため、入力欄の「+」→ Plugins → Add marketplace に poska-lab/cc-work-buddy を入れ、Add plugin で work-buddy を選びます。
クラウドのセッションでは plugin の画面表示が使えません。帯は、手元の terminal または desktop の Code タブのローカルセッションで表示されます。止めるときは Plugins で work-buddy を無効にします。
checkout からそのセッションだけ読み込めます。
claude --plugin-dir .
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 201 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mood, Now, Shown } from '../types'
5import { IDLE, moodAfterTool, moodForTool, moodForTurnEnd, NO_TURN, summary, tallyTool, thinking } from './mood'
6import { buildPrompt, GAP_MS, NARRATOR, parse, SYSTEM } from './narrate'
7
8// 0.2.0 までの 'mood' は seq を持たないため、別の key で持つ($.state は reload 後も残る)。
9const mood = atom({ plugin: 'work-buddy', key: 'shown' } as const, { ...IDLE, seq: 0 } as Shown)
10const turn = atom({ plugin: 'work-buddy', key: 'turn' } as const, NO_TURN)
11const now = atom({ plugin: 'work-buddy', key: 'now' } as const, {} as Now)
12
13/** 状態を切り替える。seq を進めて、描画側に動きのやり直しを伝える。 */
14const enter =
15 (next: Mood) =>
16 (prev: Shown): Shown => ({ ...next, seq: prev.seq + 1 })
17
18/**
19 * 描画側の Client の名前。Client の状態や props の形を変えたら名前も変える。
20 * 同じ名前だと /reload-plugins のあとも古い版の状態と時計が残り、新しい版と食い違って落ちる(0.5.0 で起きた)。
21 */
22export const CLIENT = 'clawd-v5'
23
24/** Client の props は undefined を持てないので、値のない項目を落とす。 */
25const defined = <T extends object>(value: T) =>
26 Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
27
28// Haiku に渡す、このターンの様子。
29let request = ''
30let note = ''
31const actions: string[] = []
32// 今のターン。ターンが終わったあとに届いた Haiku の答えは捨てる。
33let currentTurn: string | undefined
34let inflight = false
35let lastWritingAt = 0
36// 設定の narrate。オフなら Haiku を呼ばず、ルールの一言だけを出す。
37let narrating = true
38
39/**
40 * Haiku に「全体として何をしているか」と「直近で何をして何がわかったか」の2行をまとめてもらう。
41 * 呼び出し中か、前の呼び出しから GAP_MS 経っていなければ呼ばない。
42 */
43async function narrate($: EngineInterface, turnId: string | undefined, result: string) {
44 if (inflight) return
45 inflight = true
46 try {
47 const at = await $.clock.now()
48 const { goal, progress, narratedAt } = await read($, now)
49 if (narratedAt !== undefined && at - narratedAt < GAP_MS) return
50 await update($, now, prev => ({ ...prev, narratedAt: at }))
51 const reply = await $.model.complete({
52 model: NARRATOR,
53 system: SYSTEM,
54 prompt: buildPrompt({ request, note, actions, result, goal, progress }),
55 maxTokens: 500,
56 effort: 'low',
57 timeoutMs: 15_000,
58 })
59 const lines = reply.isAnswered ? parse(reply.text) : undefined
60 if (lines !== undefined && turnId === currentTurn) await update($, now, prev => ({ ...prev, ...lines }))
61 } catch {
62 // 一言が作れなくても、ルールの一言のまま続ける。
63 } finally {
64 inflight = false
65 }
66}
67
68/** ツールの結果を、Haiku に渡す文にする。止められたときはその理由。 */
69const resultText = (ran: { deny?: string; text?: string; result?: unknown }) => {
70 if (ran.deny !== undefined) return `denied: ${ran.deny}`
71 if (typeof ran.text === 'string' && ran.text !== '') return ran.text.slice(0, 2000)
72 try {
73 return JSON.stringify(ran.result ?? '').slice(0, 2000)
74 } catch {
75 return ''
76 }
77}
78
79/** ツールを使わずに考え始めた(モデルの返事を待っている)ことを記録する。 */
80async function startThinking($: EngineInterface) {
81 const at = await $.clock.now()
82 await update($, now, prev => ({ ...prev, thinkingSince: prev.thinkingSince ?? at, writing: undefined }))
83}
84
85/** 書き途中の返事の頭を記録する。 */
86async function showWriting($: EngineInterface, text: string) {
87 await update($, now, prev => ({ ...prev, writing: text.slice(0, 200) }))
88}
89
90export const register: Register = (on, options) => {
91 narrating = options.narrate !== false
92
93 // サブエージェント内のイベントは無視し、メインの作業だけを映す。
94 on('turn.start', async ($, e, next) => {
95 if (e.agentId === undefined) {
96 currentTurn = e.turnId
97 request = e.text
98 note = ''
99 actions.length = 0
100 const startedAt = await $.clock.now()
101 await update($, turn, prev => ({ ...NO_TURN, count: prev.count + 1, startedAt }))
102 // Haiku がまとめるまでは、全体の行に依頼の頭を出す。
103 const start = thinking(e.text)
104 await update($, now, () => ({ goal: start.line, thinkingSince: startedAt }))
105 await update($, mood, enter(start))
106 }
107 return next(e)
108 })
109
110 // モデルの返事が流れる場所なので、受け取ったものは手を加えずにすぐ流す。
111 // 書き途中の文は 0.5 秒おきに、待たずに記録する。
112 on('turn.step', async function* ($, e, next) {
113 if (e.agentId !== undefined) return yield* next(e)
114 startThinking($).catch(() => undefined)
115 const stream = next(e)
116 let text = ''
117 for await (const chunk of stream) {
118 if (chunk.kind === 'text') {
119 text += chunk.text
120 if (Date.now() - lastWritingAt > 500) {
121 lastWritingAt = Date.now()
122 showWriting($, text).catch(() => undefined)
123 }
124 }
125 yield chunk
126 }
127 const result = await stream.result
128 if (result.answer.trim() !== '') note = result.answer
129 return result
130 })
131
132 on('tool.call', async ($, e, next) => {
133 if (e.agentId !== undefined) return next(e)
134
135 const { tool, ...args } = e as { tool: string } & Record<string, unknown>
136 const during: Mood = moodForTool(tool, args, (await read($, turn)).tools)
137 actions.push(during.line)
138 actions.splice(0, Math.max(0, actions.length - 6))
139 await update($, turn, prev => tallyTool(prev, tool))
140 await update($, now, prev => ({ ...prev, thinkingSince: undefined, writing: undefined }))
141 await update($, mood, enter(during))
142
143 const ran = await next(e)
144 // 結果が出てから、何がわかったかを Haiku にまとめてもらう(待たない)。
145 if (narrating) void narrate($, currentTurn, resultText(ran))
146 const after = moodAfterTool(during, {
147 tool,
148 denied: ran.deny !== undefined,
149 isError: ran.isError === true,
150 })
151 if (after !== during) await update($, mood, enter(after))
152
153 return ran
154 }).catch(($, e, next) => next(e))
155
156 on('turn.complete', async ($, e, next) => {
157 if (e.agentId === undefined) {
158 currentTurn = undefined
159 const endedAt = await $.clock.now()
160 await update($, turn, prev => ({ ...prev, endedAt }))
161 // 終わったあとも、最後にわかったことは2行目に残す(次のターンの始めで消える)。
162 await update($, now, prev => ({ progress: prev.progress }))
163 await update($, mood, enter(moodForTurnEnd(e.reason)))
164 }
165 return next(e)
166 })
167
168 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169 // Client(描画側で動く絵)を描けるのは terminal と desktop。
170 if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
171
172 const current = await read($, mood)
173 const { goal, progress, writing, thinkingSince } = await read($, now)
174 const stats = summary(await read($, turn))
175 const elapsedMs = thinkingSince === undefined ? undefined : (await $.clock.now()) - thinkingSince
176 const { Client } = $.ui.resolve(e)
177
178 // 1行目は全体として何をしているか、2行目は直近で何をして何がわかったか、3行目は今の具体的な作業。
179 // ターンの外(待機中・終わったあと)は、1行目に状態の一言だけを出す。
180 return (
181 <Client
182 key={CLIENT}
183 module="./buddy.tsx"
184 props={defined({
185 kind: current.kind,
186 seq: current.seq,
187 width: e.props.bodyColumns,
188 goal: goal ?? current.line,
189 progress: progress ?? '',
190 detail: goal === undefined ? '' : current.line,
191 stats,
192 thinking:
193 thinkingSince === undefined || elapsedMs === undefined
194 ? undefined
195 : defined({ since: thinkingSince, elapsedMs, writing }),
196 })}
197 />
198 )
199 })
200}
201hooks/mood.ts 145 lines1import type { Mood, Turn } from '../types'
2
3export const IDLE: Mood = { kind: 'idle', line: '待機中。なにかあれば呼んでね' }
4
5// 帯の幅で描画側が切るので、ここでは長すぎる文だけを切る。
6const clip = (text: string, max = 120) => {
7 const one = text.replace(/\s+/g, ' ').trim()
8 return one.length > max ? `${one.slice(0, max - 1)}…` : one
9}
10
11const base = (path: string) => path.split('/').filter(Boolean).pop() ?? path
12
13const str = (value: unknown) => (typeof value === 'string' ? value : '')
14
15/** 言い回しを n で順に選ぶ。同じ作業が続いても一言が単調にならないようにする。 */
16const pick = (n: number, lines: readonly string[]) => lines[n % lines.length]!
17
18const host = (url: string) => /^https?:\/\/([^/?#]+)/.exec(url)?.[1] ?? clip(url, 60)
19
20/** mcp__claude_ai_Notion__notion-search → Notion / notion-search */
21const mcp = (tool: string) => {
22 const [, server = '', name = tool] = tool.split('__')
23 return { server: server.replace(/^(claude_ai|plugin)_/, '').replace(/_/g, ' '), name }
24}
25
26/** ツール名と引数から、そのツールを使っている間の気分を作る。n はこのターンで何回目のツールか(0 始まり)。 */
27export const moodForTool = (tool: string, args: Record<string, unknown>, n = 0): Mood => {
28 const file = base(str(args.file_path) || str(args.notebook_path))
29
30 switch (tool) {
31 case 'Read': {
32 const from = typeof args.offset === 'number' ? args.offset : undefined
33 return {
34 kind: 'reading',
35 line:
36 from === undefined
37 ? pick(n, [`${file} を読んでる`, `${file} に目を通してる`, `${file} の中身を確認中`])
38 : `${file} の ${from} 行目あたりを読んでる`,
39 }
40 }
41 case 'Edit':
42 return {
43 kind: 'writing',
44 line: args.replace_all === true ? `${file} をまとめて置き換え中` : pick(n, [`${file} を書き換え中`, `${file} を直してる`]),
45 }
46 case 'Write':
47 return { kind: 'writing', line: `${file} を書き出し中` }
48 case 'NotebookEdit':
49 return { kind: 'writing', line: `${file} のセルを編集中` }
50 case 'Grep': {
51 const where = base(str(args.path)) || str(args.glob)
52 return { kind: 'searching', line: `「${clip(str(args.pattern), 60)}」を${where ? ` ${where} から` : ''}探してる` }
53 }
54 case 'Glob':
55 return { kind: 'searching', line: `${clip(str(args.pattern), 60)} に合うファイルを探してる` }
56 case 'Bash': {
57 const what = clip(str(args.description) || str(args.command))
58 return { kind: 'running', line: args.run_in_background === true ? `裏で実行中: ${what}` : `実行中: ${what}` }
59 }
60 case 'WebSearch':
61 return { kind: 'browsing', line: `「${clip(str(args.query), 60)}」をウェブ検索中` }
62 case 'WebFetch':
63 return { kind: 'browsing', line: `${host(str(args.url))} を読みに行ってる` }
64 case 'Agent':
65 case 'Task': {
66 const who = str(args.subagent_type)
67 return { kind: 'delegating', line: `${who ? `${who} に` : '仲間に'}頼んでる: ${clip(str(args.description), 60)}` }
68 }
69 case 'Skill':
70 return { kind: 'thinking', line: `スキル ${str(args.skill)} を使ってる` }
71 case 'TodoWrite': {
72 const count = Array.isArray(args.todos) ? args.todos.length : 0
73 return { kind: 'thinking', line: count > 0 ? `段取りを整理中(${count} 件)` : '段取りを整理中' }
74 }
75 case 'TaskCreate':
76 case 'TaskUpdate':
77 return { kind: 'thinking', line: '段取りを整理中' }
78 case 'AskUserQuestion':
79 return { kind: 'thinking', line: 'あなたの答えを待ってる' }
80 case 'ToolSearch':
81 return { kind: 'searching', line: '使える道具を探してる' }
82 default: {
83 if (!tool.startsWith('mcp__')) return { kind: 'thinking', line: `${tool} を使ってる` }
84 const { server, name } = mcp(tool)
85 return { kind: 'browsing', line: `${clip(server, 60)} で ${clip(name, 60)} を実行中` }
86 }
87 }
88}
89
90/** ツールの結果で気分を更新する。失敗と拒否のときだけ変える。 */
91export const moodAfterTool = (
92 current: Mood,
93 outcome: { tool: string; denied: boolean; isError: boolean },
94): Mood => {
95 if (outcome.denied) return { kind: 'denied', line: `${outcome.tool} は止められた。別の手を考える` }
96 if (outcome.isError) return { kind: 'oops', line: `${outcome.tool} がうまくいかなかった…立て直します` }
97 return current
98}
99
100export const moodForTurnEnd = (reason: string): Mood => {
101 switch (reason) {
102 case 'answer':
103 return { kind: 'done', line: 'できた!確認してみて' }
104 case 'aborted':
105 return { kind: 'stopped', line: '中断しました。次の指示を待ってます' }
106 case 'refusal':
107 return { kind: 'stopped', line: 'それは引き受けられなかった' }
108 default:
109 return { kind: 'crashed', line: 'エラーで止まっちゃった…' }
110 }
111}
112
113/** ターンの始まり。頼まれたことの頭を出して、何に取りかかったかがわかるようにする。 */
114export const thinking = (request: string): Mood => {
115 const head = clip(request, 40)
116 return { kind: 'thinking', line: head === '' ? '取りかかってる' : `「${head}」に取りかかってる` }
117}
118
119export const NO_TURN: Turn = { count: 0, startedAt: 0, tools: 0, tally: {} }
120
121/** 集計で使うツールの呼び名。MCP のツールはまとめて MCP と数える。 */
122const toolName = (tool: string) => (tool.startsWith('mcp__') ? 'MCP' : tool)
123
124/** ターンにツールを1回足す。ツールの名前ごとに数える。 */
125export const tallyTool = (turn: Turn, tool: string): Turn => {
126 const name = toolName(tool)
127 return { ...turn, tools: turn.tools + 1, tally: { ...turn.tally, [name]: (turn.tally[name] ?? 0) + 1 } }
128}
129
130const duration = (ms: number) => {
131 const seconds = Math.max(0, Math.round(ms / 1000))
132 return seconds < 60 ? `${seconds}秒` : `${Math.floor(seconds / 60)}分${seconds % 60}秒`
133}
134
135/** 一言の下に出す集計。作業中はツールの回数と内訳、終わったらかかった時間も添える。 */
136export const summary = (turn: Turn) => {
137 if (turn.tools === 0 && turn.endedAt === undefined) return ''
138 // 多く使ったツールから並べる(同じ回数なら先に使ったもの)。
139 const parts = Object.entries(turn.tally)
140 .sort((a, b) => b[1] - a[1])
141 .map(([name, count]) => `${name} ${count}`)
142 const tools = `ツール ${turn.tools} 回${parts.length > 0 ? `(${parts.join(' · ')})` : ''}`
143 return turn.endedAt === undefined ? tools : `${duration(turn.endedAt - turn.startedAt)} · ${tools}`
144}
145hooks/narrate.ts 86 lines1import { oneLine } from './text'
2
3export const NARRATOR = 'claude-haiku-5-5'
4
5/** Haiku を呼ぶ間隔の下限。ツールが続いても、これより短い間隔では呼ばない。 */
6export const GAP_MS = 4000
7
8/** Haiku に渡す、いまの作業の様子。 */
9export type Scene = {
10 /** 利用者がこのターンで頼んだこと。 */
11 request: string
12 /** Claude が直近に書いた文(ツールの前の一言など)。 */
13 note: string
14 /** 直近の作業(ルールで作った一言、古い順)。最後のものがいま終わった作業。 */
15 actions: readonly string[]
16 /** いま終わった作業の結果の頭。 */
17 result: string
18 /** いま出している2行。 */
19 goal?: string
20 progress?: string
21}
22
23/** Haiku がまとめた2行。goal は全体として何をしているか、progress は直近で何をして何がわかったか。 */
24export type Narration = { goal: string; progress: string }
25
26export const SYSTEM = `You narrate, for the user watching the screen, what Claude (the coding agent in Claude Code) is doing. Write two lines in the user's language.
27
28GOAL: the overall aim of the current work, at most 40 characters for Japanese (70 for English). Keep the previous goal word for word unless the aim has really changed.
29NOW: what Claude just did and what it found or concluded from the latest result, concrete and specific, at most 40 characters for Japanese (70 for English). For example: config.ts で読み込み順が逆なのを見つけた
30
31No quotes, no emoji, no preamble, no trailing period. Reply in exactly this form:
32GOAL: ...
33NOW: ...`
34
35const tail = (text: string, max: number) => (text.length > max ? `…${text.slice(-max)}` : text)
36const head = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
37
38export const buildPrompt = ({ request, note, actions, result, goal, progress }: Scene) =>
39 [
40 "The user's request:",
41 '<<<',
42 tail(request.trim(), 600),
43 '>>>',
44 '',
45 `Claude's latest note: ${oneLine(tail(note, 400)) || '(none)'}`,
46 '',
47 'Recent actions (oldest first; the last one just finished):',
48 ...(actions.length > 0 ? actions.map(action => `- ${action}`) : ['- (none yet)']),
49 '',
50 'The start of its result:',
51 '<<<',
52 head(result.trim(), 600) || '(empty)',
53 '>>>',
54 '',
55 `The lines shown now: GOAL: ${goal ?? '(none)'} / NOW: ${progress ?? '(none)'}`,
56 ].join('\n')
57
58const QUOTES = [
59 ['「', '」'],
60 ['『', '』'],
61 ['"', '"'],
62 ["'", "'"],
63 ['`', '`'],
64] as const
65
66/** 1行を整える。行全体を囲む引用符と末尾の句点を落とし、長すぎれば切る。空なら undefined。 */
67export const clean = (text: string, max = 60) => {
68 const line = text.trim()
69 // 行全体を囲んでいる引用符だけを外す(「?」で始まる行の「 は残す)。
70 const wrapped = QUOTES.find(([open, close]) => line.startsWith(open) && line.endsWith(close) && !line.slice(1, -1).includes(open))
71 const bare = (wrapped ? line.slice(1, -1) : line).replace(/[。.]+$/, '').trim()
72 if (bare === '') return undefined
73 return bare.length > max ? `${bare.slice(0, max - 1)}…` : bare
74}
75
76/** Haiku の答えから GOAL と NOW の2行を読む。どちらかが欠けていれば undefined。 */
77export const parse = (reply: string): Narration | undefined => {
78 const pick = (label: string) => {
79 const found = new RegExp(`^\\s*${label}\\s*[::]\\s*(.+)$`, 'm').exec(reply)
80 return found ? clean(found[1]!) : undefined
81 }
82 const goal = pick('GOAL')
83 const progress = pick('NOW')
84 return goal !== undefined && progress !== undefined ? { goal, progress } : undefined
85}
86hooks/buddy.tsx 149 lines1import type { ClientModule } from 'claude-code'
2
3import type { MoodKind } from '../types'
4import { draw, frameAt, isRainbow, keepSpaces, RAINBOW_MS, restarts, TICK_MS } from './clawd'
5import type { Cell, Frame } from './clawd'
6import { fit, statusLine, widthOf } from './text'
7
8type Props = {
9 kind: MoodKind
10 seq: number
11 /** 帯の横幅(文字数)。 */
12 width: number
13 /** 1行目: 全体として何をしているか。 */
14 goal: string
15 /** 2行目: 直近で何をして何がわかったか。 */
16 progress: string
17 /** 3行目: 今の具体的な作業(考え中の間は、秒数か書き途中の文に置き換える)。 */
18 detail: string
19 /** 3行目の末尾に薄く添える集計。 */
20 stats: string
21 /** ツールを使わずに考えている間だけある。since はその開始時刻、elapsedMs は送った時点までの経過。 */
22 thinking?: { since: number; elapsedMs: number; writing?: string }
23}
24
25// 描画側の時計。t は刻みの合計、start は今の状態に入った時刻。
26// 考え中の秒数は、props が届いた時点の経過(base)に、そこからの刻み(t - baseAt)を足して数える。
27type Clock = {
28 t: number
29 start: number
30 seq: number
31 kind: MoodKind
32 frame: Frame
33 since?: number
34 base: number
35 baseAt: number
36 second: number
37 /** いま出している2行目と、それが変わった時刻。変わってしばらくは太く描く。 */
38 progress: string
39 progressAt: number
40 lit: boolean
41}
42
43const elapsed = (clock: Clock) => clock.base + clock.t - clock.baseAt
44
45/** 2行目が変わってから太く描いておく長さ。 */
46const LIT_MS = 2000
47
48const isLit = (clock: Clock) => clock.progress !== '' && clock.t - clock.progressAt < LIT_MS
49
50/**
51 * Clawd と、いま何をしているかの3行を、描画側の時計で描く。コマが変わったとき(虹色の間は色を進めるたび、
52 * 考え中は秒が変わるたび)だけ描き直し、プラグインとは往復しない。
53 */
54const Buddy: ClientModule<Props, Clock> = (props, surface) => {
55 let clock = surface.state
56 if (clock === undefined) {
57 const fresh: Clock = {
58 t: 0,
59 start: 0,
60 seq: props.seq,
61 kind: props.kind,
62 frame: frameAt(props.kind, 0),
63 base: 0,
64 baseAt: 0,
65 second: -1,
66 progress: props.progress ?? '',
67 progressAt: -LIT_MS,
68 lit: false,
69 }
70 surface.setState(fresh)
71 surface.every(TICK_MS, () => {
72 fresh.t += TICK_MS
73 const next = frameAt(fresh.kind, fresh.t - fresh.start)
74 const flowing = isRainbow(fresh.kind, next) && fresh.t % RAINBOW_MS === 0
75 const second = fresh.since === undefined ? -1 : Math.floor(elapsed(fresh) / 1000)
76 const lit = isLit(fresh)
77 if (next !== fresh.frame || flowing || second !== fresh.second || lit !== fresh.lit) {
78 fresh.frame = next
79 fresh.second = second
80 fresh.lit = lit
81 surface.setState(fresh)
82 }
83 })
84 clock = fresh
85 }
86
87 // 状態が切り替わったら、その状態のはじめ(リアクション)から動かし直す。
88 // 同じ作業の状態が続くだけなら流れを切らない(ツールが続くたびに1コマ目へ戻って止まって見えるのを防ぐ)。
89 if (props.seq !== clock.seq) {
90 if (restarts(clock.kind, props.kind)) {
91 clock.kind = props.kind
92 clock.start = clock.t
93 clock.frame = frameAt(props.kind, 0)
94 }
95 clock.seq = props.seq
96 }
97 // 考え始めた時刻が変わったら、届いた経過から数え直す。
98 if (props.thinking?.since !== clock.since) {
99 clock.since = props.thinking?.since
100 clock.base = props.thinking?.elapsedMs ?? 0
101 clock.baseAt = clock.t
102 }
103 // 2行目(わかったこと)が変わったら、しばらく太く描いて気づけるようにする。
104 if ((props.progress ?? '') !== clock.progress) {
105 clock.progress = props.progress ?? ''
106 clock.progressAt = clock.t
107 clock.lit = isLit(clock)
108 }
109
110 // 版が切り替わる途中で古い形の props が届いても落ちないよう、文字の項目がなければ空にする。
111 const goal = props.goal ?? ''
112 const stats = props.stats ?? ''
113
114 const { Box, Text } = surface.elements
115 const art = draw(clock.kind, clock.frame, clock.seq, clock.t)
116 const room = Math.max(10, (surface.columns || props.width) - (art[0]?.length ?? 0) - 2)
117 const detail = statusLine(props.detail ?? '', props.thinking && { elapsedMs: elapsed(clock), writing: props.thinking.writing })
118 const tail = stats === '' ? '' : `${detail === '' ? '' : ' '}${stats}`
119 const shown = fit(detail, Math.max(0, room - widthOf(tail)))
120
121 /** 絵の1行に、文字を足して1行にする。 */
122 const line = (cells: Cell[], extra: unknown[]) => (
123 <Text mono>
124 {cells.map(({ ch, fg, bg }) =>
125 fg === undefined ? (
126 keepSpaces(ch)
127 ) : (
128 <Text color={fg} backgroundColor={bg}>
129 {ch}
130 </Text>
131 ),
132 )}
133 {extra}
134 </Text>
135 )
136
137 // 絵の3行それぞれの右に文字を並べて、帯を絵と同じ3行に収める。上に1行あけて、上の出力と詰まらないようにする。
138 // 1行目(全体の目的)は太く、2行目(わかったこと)は Claude の色、3行目(今の作業)は薄く、集計はさらに薄く。
139 return (
140 <Box flexDirection="column" paddingTop={1}>
141 {line(art[0]!, [keepSpaces(' '), <Text bold>{keepSpaces(fit(goal, room))}</Text>])}
142 {line(art[1]!, [keepSpaces(' '), <Text color="claude" bold={isLit(clock)}>{keepSpaces(fit(clock.progress, room))}</Text>])}
143 {line(art[2]!, [keepSpaces(' '), <Text dimColor>{keepSpaces(shown)}</Text>, <Text color="inactive">{keepSpaces(tail)}</Text>])}
144 </Box>
145 )
146}
147
148export default Buddy
149hooks/text.ts 47 lines1/** 文字の表示幅。かな・漢字・全角記号は2マス。 */
2const cellWidth = (ch: string) => {
3 const code = ch.codePointAt(0) ?? 0
4 const wide =
5 (code >= 0x1100 && code <= 0x115f) ||
6 (code >= 0x2e80 && code <= 0xa4cf) ||
7 (code >= 0xac00 && code <= 0xd7a3) ||
8 (code >= 0xf900 && code <= 0xfaff) ||
9 (code >= 0xfe30 && code <= 0xfe4f) ||
10 (code >= 0xff00 && code <= 0xff60) ||
11 (code >= 0xffe0 && code <= 0xffe6)
12 return wide ? 2 : 1
13}
14
15export const widthOf = (text: string) => [...text].reduce((sum, ch) => sum + cellWidth(ch), 0)
16
17/** 改行や連続した空白を1つの空白にまとめる。 */
18export const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
19
20/** 表示幅 cols に収める。はみ出すときだけ末尾を … にする。 */
21export const fit = (text: string, cols: number) => {
22 if (cols <= 0) return ''
23 if (widthOf(text) <= cols) return text
24 let out = ''
25 let used = 0
26 for (const ch of text) {
27 const w = cellWidth(ch)
28 if (used + w > cols - 1) break
29 out += ch
30 used += w
31 }
32 return `${out}…`
33}
34
35/** 経過時間を「12秒」「1分5秒」にする。 */
36export const seconds = (ms: number) => {
37 const total = Math.max(0, Math.floor(ms / 1000))
38 return total < 60 ? `${total}秒` : `${Math.floor(total / 60)}分${total % 60}秒`
39}
40
41/** 2行目に出す文。ツールを使わずに考えている間は、書き途中の返事の頭か、考えている秒数にする。 */
42export const statusLine = (detail: string, thinking: { elapsedMs: number; writing?: string } | undefined) => {
43 if (thinking === undefined) return detail
44 const writing = oneLine(thinking.writing ?? '')
45 return writing !== '' ? `書いてる: ${writing}` : `考え中 ${seconds(thinking.elapsedMs)}`
46}
47hooks/clawd.ts 402 lines1import type { MoodKind } from '../types'
2
3// Clawd(Claude Code のマスコット。起動画面のキャラ)。もとのブロック文字
4// ▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝
5// を1文字2×2画素として読み直した 18×5 画素の絵。もとと同じく、2×2画素を四分割ブロック1文字で描く(横 9 文字)。
6// B 体 . 透明。目・口・腕・脚は別に重ねる。目と口は頭と胴の内側だけに置き、輪郭を切らない。
7const BODY = ['...BBBBBBBBBBBB...', '...BBBBBBBBBBBB...', '...BBBBBBBBBBBB...', '...BBBBBBBBBBBB...']
8
9type Ink = 'body' | 'eye' | 'tear'
10type Px = readonly [x: number, y: number, ink: Ink]
11
12/** Clawd の色(Claude Code のテーマの claude 色)と、目・涙の色。 */
13const INKS: Record<Ink, string> = {
14 body: '#D77757',
15 eye: '#1F1F1F',
16 tear: '#FFFFFF',
17}
18
19// 目は頭の下の段の1画素。look で左右に1画素ずらす。
20const dots = (look: number): Px[] => [
21 [5 + look, 1, 'eye'],
22 [12 + look, 1, 'eye'],
23]
24
25// 閉じた目・細めた目は2画素の線。
26const lines = (look: number): Px[] => [
27 [4 + look, 1, 'eye'],
28 [5 + look, 1, 'eye'],
29 [12 + look, 1, 'eye'],
30 [13 + look, 1, 'eye'],
31]
32
33// 口は胴の上の段の真ん中に2画素。胴の下の段は輪郭なので、口も目も置かない。
34const MOUTH: Px[] = [
35 [8, 2, 'eye'],
36 [9, 2, 'eye'],
37]
38
39/** 表情: ふつう・まばたき・喜び・驚き・疲れ・むっ・しょんぼり・泣き。目の形・口・涙で描き分け、腕・動き・記号で補う。 */
40type Face = 'normal' | 'closed' | 'joy' | 'surprised' | 'tired' | 'annoyed' | 'sad' | 'cry'
41
42const FACES: Record<Face, (look: number) => Px[]> = {
43 normal: dots,
44 closed: lines,
45 // 喜び: 目を細めて、口を開ける(バンザイと虹色で跳ねるのと合わせる)。
46 joy: () => [...lines(0), ...MOUTH],
47 // 驚き: 目を開いたまま、口を開ける(びくっと震えるのと合わせる)。
48 surprised: () => [...dots(0), ...MOUTH],
49 // 疲れ: 目を細める。
50 tired: () => lines(0),
51 // むっ: 目を開いたまま口を結ぶ(左右をにらむのと記号 # で補う)。
52 annoyed: dots,
53 // しょんぼり: 目を伏せる。
54 sad: lines,
55 // 泣き: 目の斜め外側の下に涙(目と同じ文字に入ると3色になって描けないため、1画素外にずらす)。
56 cry: look => [...dots(look), [4 + look, 2, 'tear'], [13 + look, 2, 'tear']],
57}
58
59// 腕は胴の横に2画素。上げると頭の横に斜めに立つ。右腕は左腕を左右反転したもの。
60const LEFT_ARM = {
61 down: [
62 [1, 2],
63 [2, 2],
64 ],
65 up: [
66 [2, 1],
67 [1, 0],
68 ],
69} as const
70
71const ARMS = {
72 down: ['down', 'down'],
73 up: ['up', 'up'],
74 leftUp: ['up', 'down'],
75 rightUp: ['down', 'up'],
76} as const
77
78// 脚は4本。step で内側に寄せ、stand と交互にすると足踏みに見える。
79const LEGS = {
80 stand: [4, 6, 11, 13],
81 step: [5, 7, 10, 12],
82} as const
83
84type Pose = {
85 face?: Face
86 /** 目線。-1 で左、1 で右。 */
87 look?: number
88 arms?: keyof typeof ARMS
89 legs?: keyof typeof LEGS
90 /** 横の位置(0〜2)。コマ間で変えると体が揺れる。 */
91 dx?: number
92 /** 縦の位置。0 で浮き上がる(跳ねる・揺れる)。 */
93 dy?: number
94 /** 頭の右に添える記号。 */
95 mark?: string
96 /** 落ち着いた状態でも、このコマは虹色で塗る(リアクション用)。 */
97 party?: boolean
98}
99
100/** 1コマ。ms はそのコマを見せる長さ。 */
101export type Frame = Pose & { ms: number }
102
103/** intro は状態に入ったときに一度だけ流し、そのあと loop をくり返す。 */
104type Motion = { intro: readonly Frame[]; loop: readonly Frame[] }
105
106/** 描画側の時計の刻み。コマの長さはこの倍数にする。 */
107export const TICK_MS = 50
108
109const at = (ms: number, poses: readonly Pose[]): Frame[] => poses.map(pose => ({ ...pose, ms }))
110
111const repeat = <T>(times: number, items: readonly T[]): T[] => Array.from({ length: times }, () => items).flat()
112
113// 落ち着いている間は、ゆっくりふわふわ浮きながら、ときどきまばたきして左右を見る(1周 8.1 秒)。
114const awake = (mark?: string): Frame[] => [
115 { mark, ms: 1200 },
116 { mark, dy: 0, ms: 1200 },
117 { face: 'closed', mark, ms: 150 },
118 { mark, ms: 900 },
119 { mark, dy: 0, ms: 900 },
120 { look: -1, mark, ms: 600 },
121 { mark, ms: 900 },
122 { look: 1, mark, ms: 600 },
123 { mark, dy: 0, ms: 1500 },
124 { face: 'closed', mark, ms: 150 },
125]
126
127// 起きている7周(約57秒)が過ぎると、疲れた顔をしてから目を閉じて居眠りする。
128const resting = (reaction: readonly Frame[], mark?: string): Motion => ({
129 intro: [...reaction, ...repeat(7, awake(mark)), { face: 'tired', mark: '..', ms: 3000 }],
130 loop: at(900, [
131 { face: 'closed', mark: 'z' },
132 { face: 'closed', mark: 'zz' },
133 { face: 'closed', mark: 'zzz' },
134 ]),
135})
136
137/** 作業中は状態ごとのテンポでくり返し、落ち着いた状態はリアクションのあと待機の動きに移る。 */
138const MOTIONS: Record<MoodKind, Motion> = {
139 thinking: {
140 intro: [],
141 loop: at(500, [
142 { look: -1, mark: '?' },
143 { mark: '??' },
144 { look: 1, mark: '???' },
145 { mark: '??' },
146 ]),
147 },
148 reading: {
149 intro: [],
150 loop: [
151 ...at(600, [
152 { look: -1, mark: '[]' },
153 { mark: '[]' },
154 { look: 1, mark: '[]' },
155 ]),
156 { face: 'closed', mark: '[]', ms: 150 },
157 ],
158 },
159 // 左右を見ながら、足踏みして揺れる。
160 searching: {
161 intro: [],
162 loop: at(300, [
163 { look: -1, dx: 0 },
164 { look: -1, dx: 1, legs: 'step' },
165 { look: 1, dx: 2 },
166 { look: 1, dx: 1, legs: 'step' },
167 ]),
168 },
169 writing: {
170 intro: [],
171 loop: at(200, [
172 { look: 1, arms: 'rightUp', mark: '_/' },
173 { look: 1, mark: '_/' },
174 { look: 1, arms: 'rightUp', mark: '_/' },
175 { mark: '_/' },
176 ]),
177 },
178 // 足を踏み替えながら跳ね、! を増やしたり減らしたりする。
179 running: {
180 intro: [],
181 loop: at(200, [
182 { dy: 0, legs: 'step', mark: '!' },
183 { mark: '!!' },
184 { dy: 0, legs: 'step', arms: 'up', mark: '!!!' },
185 { mark: '!!' },
186 ]),
187 },
188 // 浮きながら左右に揺れる。
189 browsing: {
190 intro: [],
191 loop: at(400, [
192 { dx: 0, mark: '~' },
193 { dx: 1, dy: 0, mark: '~~' },
194 { dx: 2, mark: '~~~' },
195 { dx: 1, dy: 0, mark: '~~' },
196 ]),
197 },
198 // 手を振って呼ぶ。
199 delegating: {
200 intro: [],
201 loop: at(300, [
202 { arms: 'rightUp', mark: '>' },
203 { mark: '>>' },
204 { arms: 'leftUp', mark: '>>>' },
205 { mark: '>>' },
206 ]),
207 },
208 idle: resting([]),
209 // 喜びの顔で、虹色になって足踏みしながら3回跳ねてから、バンザイで少し止まる。
210 done: resting(
211 [
212 ...repeat(
213 3,
214 at(200, [
215 { face: 'joy', arms: 'up', dy: 0, legs: 'step', mark: '*', party: true },
216 { face: 'joy', mark: '+', party: true },
217 ]),
218 ),
219 { face: 'joy', arms: 'up', mark: '*', ms: 1200, party: true },
220 ],
221 '*',
222 ),
223 // ツールが失敗した: 驚いてびくっと震える。
224 oops: resting(
225 [
226 ...repeat(
227 4,
228 at(100, [
229 { face: 'surprised', dx: 0, mark: '!?' },
230 { face: 'surprised', dx: 2, mark: '?!' },
231 ]),
232 ),
233 { face: 'surprised', mark: '!?', ms: 1500 },
234 ],
235 ';',
236 ),
237 // ターンがエラーで止まった: 泣き顔で上下に震える(横にずらすと涙と目が同じ文字に入るため)。
238 crashed: resting(
239 [
240 ...repeat(
241 4,
242 at(100, [
243 { face: 'cry', dy: 0, mark: ';' },
244 { face: 'cry', mark: ';' },
245 ]),
246 ),
247 { face: 'cry', mark: ';', ms: 2000 },
248 ],
249 ';',
250 ),
251 // ツールを止められた: むっとして左右を見る。
252 denied: resting(
253 [
254 { face: 'annoyed', look: -1, mark: '#', ms: 500 },
255 { face: 'annoyed', look: 1, mark: '#', ms: 500 },
256 { face: 'annoyed', mark: '#', ms: 1500 },
257 ],
258 '..',
259 ),
260 // 中断・お断り: 左右を見てから、しょんぼりして目を伏せる。
261 stopped: resting(
262 [
263 { look: -1, mark: '..', ms: 600 },
264 { look: 1, mark: '..', ms: 600 },
265 { face: 'sad', mark: '...', ms: 2300 },
266 ],
267 '..',
268 ),
269}
270
271const total = (frames: readonly Frame[]) => frames.reduce((sum, frame) => sum + frame.ms, 0)
272
273const pick = (frames: readonly Frame[], t: number) => {
274 for (const frame of frames) {
275 if (t < frame.ms) return frame
276 t -= frame.ms
277 }
278 return frames[frames.length - 1]!
279}
280
281/** 状態に入ってから elapsed ミリ秒後に見せるコマ。 */
282export const frameAt = (kind: MoodKind, elapsed: number): Frame => {
283 const { intro, loop } = MOTIONS[kind]
284 const introMs = total(intro)
285 return elapsed < introMs ? pick(intro, elapsed) : pick(loop, (elapsed - introMs) % total(loop))
286}
287
288/** 作業中の状態は、体を虹色で塗る。 */
289const WORKING = new Set<MoodKind>(['thinking', 'reading', 'searching', 'writing', 'running', 'browsing', 'delegating'])
290
291export const isRainbow = (kind: MoodKind, frame: Frame) => frame.party === true || WORKING.has(kind)
292
293/**
294 * 状態が切り替わったとき、動きを頭から流し直すか。同じ作業の状態が続くとき(Read が続くなど)は
295 * 流れを切らずに続ける。リアクションのある状態(done・oops など)は、入るたびに流し直す。
296 */
297export const restarts = (from: MoodKind, to: MoodKind) => from !== to || MOTIONS[to].intro.length > 0
298
299/** 虹色の流れ方(横・縦・全体いっせい・斜め)。状態が切り替わるたびに seq で選び直す。 */
300const FLOWS: readonly ((x: number, y: number) => number)[] = [
301 x => x * 28,
302 (x, y) => y * 60,
303 () => 0,
304 (x, y) => (x + y * 2) * 20,
305]
306
307/** 虹色を進める間隔。 */
308export const RAINBOW_MS = 100
309
310const hsl = (hue: number, l: number) => {
311 const h = ((hue % 360) + 360) % 360
312 const a = 0.9 * Math.min(l, 1 - l)
313 const f = (n: number) => {
314 const k = (n + h / 30) % 12
315 return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
316 }
317 return `#${[f(0), f(8), f(4)].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('')}`
318}
319
320/**
321 * seq 番目の状態で、(x, y) 番目の文字を t ミリ秒の時点で塗る虹色。
322 * 流れ方・始まりの色・回る向きを seq で変え、2秒で色相を一周する。
323 */
324export const rainbow = (seq: number, x: number, y: number, t: number) => {
325 const flow = FLOWS[seq % FLOWS.length]!
326 const direction = Math.floor(seq / FLOWS.length) % 2 === 0 ? 1 : -1
327 return hsl(seq * 137 + flow(x, y) + direction * t * 0.18, 0.62)
328}
329
330// 横は体の揺れ(dx 0〜2)、縦は浮き上がり(dy 0〜1)の分だけ広く取る。
331const SPRITE_W = 20
332const SPRITE_H = 6
333const MARK_W = 4
334
335/** 描く行数。 */
336export const ROWS = SPRITE_H / 2
337
338/** 1文字分。fg は塗った区画の色、bg は残りの区画の色。ch が空白なら何も塗らない。 */
339export type Cell = { ch: string; fg?: string; bg?: string }
340
341// 2×2画素の並び(左上=1・右上=2・左下=4・右下=8)で引く四分割ブロック文字。
342const QUADS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
343
344/**
345 * 2×2画素(左上・右上・左下・右下)を1文字にする。1文字に塗れるのは文字色と背景色の2色までなので、
346 * 3色あるときは少ない色を多い色に寄せる(同じ数なら透明より色を残す)。
347 */
348const quad = (pixels: readonly (string | undefined)[]): Cell => {
349 const counts = new Map<string | undefined, number>()
350 for (const pixel of pixels) counts.set(pixel, (counts.get(pixel) ?? 0) + 1)
351 const [first, second] = [...counts.keys()].sort(
352 (a, b) => counts.get(b)! - counts.get(a)! || Number(a === undefined) - Number(b === undefined),
353 )
354 const fg = first === undefined || second !== undefined ? second : first
355 if (fg === undefined) return { ch: ' ' }
356 const bg = fg === first ? second : first
357 const mask = pixels.reduce((bits, pixel, i) => ((pixel === fg) ? bits | (1 << i) : bits), 0)
358 return bg === undefined ? { ch: QUADS[mask]!, fg } : { ch: QUADS[mask]!, fg, bg }
359}
360
361/** コマを ROWS 行の文字にする。虹色のときは seq と t で体の色を決める。 */
362export const draw = (kind: MoodKind, frame: Frame, seq: number, t: number): Cell[][] => {
363 const { face = 'normal', look = 0, arms = 'down', legs = 'stand', dx = 1, dy = 1, mark = '' } = frame
364 const ink = new Map<number, Ink>()
365 const put = (x: number, y: number, value: Ink) => ink.set((y + dy) * SPRITE_W + x + dx, value)
366
367 BODY.forEach((line, y) =>
368 [...line].forEach((ch, x) => {
369 if (ch !== '.') put(x, y, 'body')
370 }),
371 )
372 for (const x of LEGS[legs]) put(x, 4, 'body')
373 const [left, right] = ARMS[arms]
374 for (const [x, y] of LEFT_ARM[left]) put(x, y, 'body')
375 for (const [x, y] of LEFT_ARM[right]) put(17 - x, y, 'body')
376 for (const [x, y, value] of FACES[face](look)) put(x, y, value)
377
378 const party = isRainbow(kind, frame)
379 const color = (x: number, y: number) => {
380 const value = ink.get(y * SPRITE_W + x)
381 if (value === undefined) return undefined
382 // 虹色は文字ごとに1色にして、1文字2色に収める。
383 return party && value === 'body' ? rainbow(seq, x >> 1, y >> 1, t) : INKS[value]
384 }
385
386 return Array.from({ length: ROWS }, (_, row) => {
387 const cells: Cell[] = Array.from({ length: SPRITE_W / 2 }, (_, col) => {
388 const [x, y] = [col * 2, row * 2]
389 return quad([color(x, y), color(x + 1, y), color(x, y + 1), color(x + 1, y + 1)])
390 })
391 // 記号は頭の高さ(1行目)に置く。
392 const text = (row === 0 ? ` ${mark}` : '').padEnd(MARK_W).slice(0, MARK_W)
393 return [...cells, ...[...text].map(ch => ({ ch }))]
394 })
395}
396
397/**
398 * desktop は通常の空白を詰めて描くため、空白を NBSP に置き換え、
399 * 等幅(mono)表示と合わせて行頭と列をそろえる。
400 */
401export const keepSpaces = (line: string) => line.replace(/ /g, ' ')
402types/index.d.ts 42 lines1export type MoodKind =
2 | 'idle'
3 | 'thinking'
4 | 'reading'
5 | 'searching'
6 | 'writing'
7 | 'running'
8 | 'browsing'
9 | 'delegating'
10 | 'oops'
11 | 'crashed'
12 | 'denied'
13 | 'done'
14 | 'stopped'
15
16export type Mood = { kind: MoodKind; line: string }
17
18/** 帯に出している状態。seq は状態が切り替わるたびに1つ進む。 */
19export type Shown = Mood & { seq: number }
20
21/** 今のターンの集計。count はこれまでのターン数、tally は作業の呼び名ごとの回数。 */
22export type Turn = {
23 count: number
24 startedAt: number
25 endedAt?: number
26 tools: number
27 tally: Record<string, number>
28}
29
30/**
31 * いま何をしているかの説明。goal は全体として何をしているか、progress は直近で何をして何がわかったか
32 * (どちらも Haiku がまとめる。goal はターンの始めだけ依頼の頭)。writing は書き途中の返事の頭、
33 * thinkingSince はツールを使わずに考え始めた時刻、narratedAt は最後に Haiku を呼んだ時刻(どちらもミリ秒)。
34 */
35export type Now = { goal?: string; progress?: string; writing?: string; thinkingSince?: number; narratedAt?: number }
36
37declare module 'claude-code' {
38 interface PluginState {
39 'work-buddy': { shown: Shown; turn: Turn; now: Now }
40 }
41}
42