SLOPSHOPPER

work-buddy

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

newbandguardmodel
v0.6.1MITupdated 2026-10-16poska-lab/cc-work-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-buddy
› 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 ▣ client module ./buddy.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./buddy.tsx
README

work-buddy

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 行目今の具体的な作業(下のルールの一言)と、ツールの回数すぐ
  • 1・2 行目は、ツールが終わったときに Haiku 5.5(claude-haiku-5-5、effort low)が 1 回の呼び出しでまとめます。前に呼んでから 4 秒たっていなければ呼ばず、失敗したときは前の行のままです。ターンが終わると 1 行目は状態の一言(できた!など)に戻り、2 行目は最後にわかったことを次のターンまで残します。終わったあとに届いた答えは捨てます。
  • 3 行目は、ツールを使わずに考えている間は「考え中 12秒」、返事を書き始めたら「書いてる: …」に変わります。末尾に、そのターンのツールの回数をツールの名前ごとに薄く添えます(MCP のツールはまとめて MCP)。
  • 見た目で区別できるよう、1 行目は太字、2 行目は Claude の色(変わった直後の 2 秒は太字)、3 行目は薄く、集計はさらに薄いグレーで描きます。色はテーマの色名で指定するので、ライトとダークのどちらでも読めます。
  • 長い文は帯の幅に合わせて切ります。

表示の規則

出来事状態一言の例
ターン開始thinking「<依頼の頭>」に取りかかってる
Readreading<file> を読んでる / <file> の 120 行目あたりを読んでる
Edit / Write / NotebookEditwriting<file> を書き換え中 / <file> を書き出し中 / <file> のセルを編集中
Grep / Glob / ToolSearchsearching「<pattern>」を <path> から探してる / <pattern> に合うファイルを探してる
Bashrunning実行中: <description または command> / 裏で実行中: …
WebSearch / WebFetch / MCPbrowsing「<query>」をウェブ検索中 / <host> を読みに行ってる / Notion で notion-search を実行中
Agentdelegating<subagent_type> に頼んでる: <description>
Skill / TodoWrite / AskUserQuestionthinkingスキル <name> を使ってる / 段取りを整理中(3 件) / あなたの答えを待ってる
ツールが失敗 / 拒否oops / denied<tool> がうまくいかなかった… / <tool> は止められた
ターン終了done / stopped / crashedできた! / 中断しました・それは引き受けられなかった / エラーで止まっちゃった…
起動直後idle待機中。なにかあれば呼んでね
  • 集計は、そのターンのツールの回数とツールごとの内訳です。ターンが終わると、かかった時間も添えます。
  • 同じ作業が続いても単調にならないよう、いくつかの言い回しを順に使います。
  • メインの loop だけを対象にし、サブエージェント内のイベントは表示しません。
  • 3 行目は作業状態から作った定型文です。1・2 行目は Haiku が、依頼・Claude の書いた文・作業と結果からまとめたもので、モデルの内部推論ではありません。

Clawd の動き

  • Clawd は、起動画面のブロック文字( ▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝)を1文字2×2画素として読み直した 18×5 画素の絵です。もとと同じく2×2画素を四分割ブロック文字1文字で描くので、大きさはもとと同じ横 9 文字・3 行です。1文字に塗れるのは2色までなので、虹色は文字ごとに1色にし、涙は目と別の文字に置きます。体はテーマの claude 色 #D77757、目は #1F1F1F です。
  • 作業中の状態(thinking から delegating まで)は、状態ごとのテンポで 4 コマをくり返します。実行中や書き換え中は 0.2 秒ごとに速く、考え中や読んでいるときは 0.5〜0.6 秒ごとにゆっくり動きます(目線・腕・跳ねる・左右の揺れ)。
  • 頭の横の記号も動きます(考え中は ? → ?? → ???、実行中は ! → !! → !!!、ウェブは ~ → ~~~)。同じ作業が続くとき(Read が続くなど)は、動きを頭に戻さずそのまま続けます。
  • 作業中は体が虹色に光り、色が流れます。流れ方(横・縦・全体いっせい・斜め)、始まりの色、回る向きは、状態が切り替わるたびに変わります。
  • 表情は状況に合わせて使い分けます。目(点・細めた線)、口、涙で描き分け、腕・動き・頭の横の記号で補います。目と口は頭と胴の内側だけに置き、輪郭を切りません。
状況状態表情とリアクション
終わったdone喜び(目を細めて口を開ける、バンザイ)。虹色になって足踏みしながら 3 回跳ねる、*
ツールが失敗したoops驚き(目を開いて口を開ける)。!? と ?! を入れ替えながら、びくっと震える
ターンがエラーで止まったcrashed泣き(涙)。震える、;
ツールを止められたdeniedむっ。左右をにらむ、#
中断・お断りstoppedしょんぼり。左右を見てから目を伏せる、...
仲間に頼むdelegating左右の手を交互に振る、>>>
待機が 1 分続いたidle ほか疲れ(目を細める)のあと、目を閉じて居眠り、z zz zzz
  • 落ち着いた状態(idle と、リアクションを終えた done / oops / crashed / denied / stopped)は、もとの claude 色で、ふわふわ浮きながらときどきまばたきして左右を見ます。1 分ほどたつと疲れた顔をしてから、目を閉じて z zz zzz と居眠りします。
  • 絵は Client(描画側で動く要素)が自分の時計で動かします。コマが変わったとき(虹色の間は 0.1 秒ごと)だけ描き直し、コマごとにプラグインを呼び出しません。
  • 表示を止めるときは、Plugins で work-buddy を無効にします。
  • 帯(AbovePrompt)を描くのは terminal と desktop だけです。

一言のテンプレートと集計は hooks/mood.ts、Clawd の絵・動き・色は hooks/clawd.ts、絵を動かす Client は hooks/buddy.tsx、イベントの処理と帯の描画は hooks/register.tsx にあります。

設定とプライバシー

  • 1・2 行目をまとめる Haiku 5.5 の呼び出しは、あなたのセッションの認証で行います(API キーは要りません。サブスクならその利用枠を使います)。この mod が自分でネットワークにつなぐことはなく、ファイルも読み書きしません。
  • Haiku に送るのは、このターンの依頼(600 文字まで)、Claude が直近に書いた文(400 文字まで)、直近 6 件の作業の一言(ファイル名やコマンドの説明を含む)、いま終わった作業の結果の頭(600 文字まで)、いま出している 2 行です。ツールが終わったときに、4 秒に 1 回までしか呼びません。
  • 送りたくないときは、設定の「Haiku で今の作業をまとめる」(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

Source 7 files
hooks/register.tsx 201 lines
1import { 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}
201
hooks/mood.ts 145 lines
1import 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}
145
hooks/narrate.ts 86 lines
1import { 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}
86
hooks/buddy.tsx 149 lines
1import 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
149
hooks/text.ts 47 lines
1/** 文字の表示幅。かな・漢字・全角記号は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}
47
hooks/clawd.ts 402 lines
1import 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, ' ')
402
types/index.d.ts 42 lines
1export 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