SLOPSHOPPER

dopa-ide

A dopamine band above the prompt: blinking yellow with progress while Claude works, a rainbow Completed! when it's done, and the run log on hover

newpanebandspinnerrowsguard
v0.3.0no licenseupdated 2026-10-16pupuchan1/dopa-ide
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dopa-ide
│ ┃ 🌈 dopa-ide log ✕ › fix the failing auth test and add an audit log cal╭──────────────╮ │ ┃ ✓ 📖 auth.ts を読む │ dopa-ide │ │ ┃ ✓ 🔍 refresh\( を検索 ⏺ Read(src/auth.ts) │ 🌈 │ │ ┃ ✓ 📝 auth.ts を編集 ⎿ Read 6 lines │ Completed! │ │ ┃ ✓ ✍️ audit.ts を書く ⏺ Update(src/auth.ts) ╰──────────────╯ │ ┃ ✓ ✍️ cache.ts を書く ⎿ Added 2 lines, removed 1 line │ ┃ ✗ 💻 Run the test suite ⏺ Bash(bun test) │ ┃ ✓ 💻 Show working tree status ⎿ 3 pass, 1 fail │ ┃ ✓ 💻 Clean build output and push │ ┃ ✓ 💻 Show environment file ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ ✅ Worked for 42s · done 4:20 PM │ │ › /dopa-log │ ⎿ dopa-ide: 実行履歴のペインを開きました。 │ │ ✻ 🧠 Thinking ✨… | ✨ Completed! ✨ ⏱ 0:00 · 🔧 9 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ✅ 完了! ? for shortcuts ⚠ dopa-ide: ✅ 完了!

Draws

Band
| ✨ Completed! ✨ ⏱ 0:00 · 🔧 9
Pane · 🌈 dopa-ide log
✓ 📖 auth.ts を読む ✓ 🔍 refresh\( を検索 ✓ 📝 auth.ts を編集 ✓ ✍️ audit.ts を書く ✓ ✍️ cache.ts を書く ✗ 💻 Run the test suite ✓ 💻 Show working tree status ✓ 💻 Clean build output and push ✓ 💻 Show environment file
Pane · dopa-fireworks
| ✅ 完了!
Prompt hint
✅ 完了! ? for shortcuts
README

🌈 dopa-ide

Claude Code を「ドパガキ仕様」にする mod です。Claude が働いている間も、終わった瞬間も、とにかく派手になります。

できること

  • 作業中:プロンプトの上に黄色い帯が点滅し、進捗バー、今やっていること、経過時間、ツールの実行回数が出ます
  • コンボ:ツールを 10 / 25 / 50 / 100 回使うと「🔥 COMBO!」が出て、効果音が鳴ります
  • 完了したとき
  • 🎆 花火と紙吹雪が上がり、虹色の「✨ COMPLETED! ✨」が出ます
  • ファンファーレが鳴り、「おつかれさま!完了しました」と読み上げます
  • 最後の回答と、そのターンのツール行が緑に光ります
  • デスクトップアプリでは、サイドバーの未読ドットが点滅し、セッション名の頭に ✅ が付きます(次のプロンプトを送ると外れます)
  • Windows では、画面全体に紙吹雪が降り、枠が虹色に光り、「Claude 返信きたよ!」と出ます(約2秒)
  • 実行ログ:帯にマウスを乗せるとこのターンの実行履歴が出ます。/dopa-log ならペインで全部見られます

ターミナルでもデスクトップアプリ(Code タブ)でも動きます。未読ドットとタイトルの ✅ は、デスクトップアプリ専用です。

音の ON / OFF

ファンファーレ、読み上げ、コンボ音がうるさいときは、プロンプトに次のように入力します。

/dopa-sound off

/dopa-sound on で元に戻ります。/dopa-sound だけなら ON と OFF が切り替わります。設定は次のセッションにも引き継がれます。音を切っても、花火などの見た目の演出はそのまま出ます。

全画面演出の ON / OFF

画面共有中などで全画面の紙吹雪を出したくないときは、/dopa-overlay off と入力します。使い方は /dopa-sound と同じです。全画面演出は Windows だけで出ます。

インストール

ターミナルで Claude Code を起動して、プロンプトに次の1行を入力します。

/plugin install dopa-ide --marketplace pupuchan1/dopa-ide

マーケットプレイスを追加するか聞かれたら y を押し、インストール先のスコープを選びます。ユーザー全体(user)にすると、デスクトップアプリの Code タブでも動きます。

開発

claude plugin validate .
claude plugin test .

手元のフォルダから直接読み込むなら、claude --plugin-dir <このフォルダ> で起動します。

音について

効果音は、mod の中で計算して作っています。音声ファイルは同梱していません。Windows では PowerShell を使って鳴らし、読み上げにはシステムの音声を使います。

Source 2 files
hooks/register.tsx 1146 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Finale, LogEntry, Phase, Run, Step } from '../types'
5
6// ---- state -------------------------------------------------------------
7
8const EMPTY: Run = { phase: 'idle', startedAt: 0, endedAt: 0, tools: 0, log: [], steps: [] }
9
10const run = atom({ plugin: 'dopa-ide', key: 'run' } as const, EMPTY)
11const frame = atom({ plugin: 'dopa-ide', key: 'frame' } as const, 0)
12const isAnimating = atom({ plugin: 'dopa-ide', key: 'isAnimating' } as const, false)
13const finale = atom({ plugin: 'dopa-ide', key: 'finale' } as const, { prompt: '', answer: '', toolIds: [] } as Finale)
14
15const PANE = 'dopa-log'
16const FIREWORKS_PANE = 'dopa-fireworks'
17const BLINK_MS = 500
18const RAINBOW_MS = 90
19const RAINBOW_FOR_MS = 6000
20const HOVER_ROWS = 8
21const FIREWORK_ROWS = 8
22const CELL_ASPECT = 2.2 // a desktop cell's height over its width
23const DOCK_WIDTH = 1680 // the docked drawing's width; its height follows the rows
24const COMBO_AT = [10, 25, 50, 100]
25
26// The one timer that drives blinking or the rainbow; reset on reload.
27let timer: Timer | undefined
28
29function stopTimer() {
30  timer?.cancel()
31  timer = undefined
32}
33
34// ---- pure helpers (no $) -----------------------------------------------
35
36function hsl(h: number, s: number, l: number): string {
37  const a = s * Math.min(l, 1 - l)
38  const f = (n: number) => {
39    const k = (n + h / 30) % 12
40    const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
41    return Math.round(c * 255)
42      .toString(16)
43      .padStart(2, '0')
44  }
45  return `#${f(0)}${f(8)}${f(4)}`
46}
47
48function rainbow(text: string, shift: number): { ch: string; color: string }[] {
49  return [...text].map((ch, i) => ({ ch, color: hsl((i * 32 + shift * 24) % 360, 1, 0.6) }))
50}
51
52function clock(ms: number): string {
53  const s = Math.max(0, Math.round(ms / 1000))
54  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
55}
56
57function base(path: unknown): string {
58  return typeof path === 'string' ? path.split('/').pop() || path : ''
59}
60
61function cut(text: unknown, n: number): string {
62  const s = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
63  return s.length > n ? s.slice(0, n - 1) + '…' : s
64}
65
66function describe(tool: string, input: Record<string, unknown>): { icon: string; label: string } {
67  if (tool.endsWith('update_plan')) return { icon: '📋', label: 'タスクリスト更新' }
68  switch (tool) {
69    case 'Read':
70      return { icon: '📖', label: `${base(input.file_path)} を読む` }
71    case 'Edit':
72    case 'MultiEdit':
73    case 'NotebookEdit':
74      return { icon: '📝', label: `${base(input.file_path ?? input.notebook_path)} を編集` }
75    case 'Write':
76      return { icon: '✍️', label: `${base(input.file_path)} を書く` }
77    case 'Bash':
78      return { icon: '💻', label: cut(input.description || input.command, 48) }
79    case 'Grep':
80    case 'Glob':
81      return { icon: '🔍', label: `${cut(input.pattern, 40)} を検索` }
82    case 'WebSearch':
83      return { icon: '🌐', label: `「${cut(input.query, 36)}」を検索` }
84    case 'WebFetch':
85      return { icon: '🌐', label: cut(input.url, 48) }
86    case 'Agent':
87    case 'Task':
88      return { icon: '🤖', label: `サブエージェント: ${cut(input.description, 36)}` }
89    case 'TaskCreate':
90      return { icon: '📋', label: `タスク追加: ${cut(input.subject, 36)}` }
91    case 'TaskUpdate':
92      return { icon: '📋', label: `タスク更新 → ${String(input.status ?? '')}` }
93    case 'TodoWrite':
94      return { icon: '📋', label: 'ToDoリスト更新' }
95    default:
96      return { icon: '🔧', label: tool }
97  }
98}
99
100function progress(steps: Step[]): { done: number; total: number; bar: string } | null {
101  if (steps.length === 0) return null
102  const done = steps.filter(s => s.status === 'completed').length
103  const width = 12
104  const filled = Math.round((done / steps.length) * width)
105  return { done, total: steps.length, bar: '█'.repeat(filled) + '░'.repeat(width - filled) }
106}
107
108// The desktop app's task list (update_plan): a whole list, or advance / done / start.
109function applyPlan(steps: Step[], input: Record<string, unknown>): Step[] {
110  const items = input.items as { content?: string; status?: Step['status'] }[] | undefined
111  if (Array.isArray(items)) {
112    return items.map((t, i) => ({ id: `plan-${i}`, title: String(t.content ?? ''), status: t.status ?? 'pending' }))
113  }
114  let next = steps.map(s => ({ ...s }))
115  if (input.advance === true) {
116    const at = next.findIndex(s => s.status === 'in_progress')
117    if (at >= 0) next[at].status = 'completed'
118    const pending = next.findIndex(s => s.status === 'pending')
119    if (pending >= 0) next[pending].status = 'in_progress'
120  }
121  if (Array.isArray(input.done)) {
122    for (const k of input.done as number[]) if (next[k - 1]) next[k - 1].status = 'completed'
123  }
124  if (typeof input.start === 'number' && next[input.start - 1]) {
125    next = next.map(s => (s.status === 'in_progress' ? { ...s, status: 'pending' as const } : s))
126    next[input.start - 1].status = 'in_progress'
127  }
128  return next
129}
130
131// A small seeded random, so each completion gets its own show.
132function rng(seed: number): () => number {
133  let s = (seed >>> 0) || 1
134  return () => {
135    s = (s * 1664525 + 1013904223) >>> 0
136    return s / 2 ** 32
137  }
138}
139
140// Desktop: one SVG the sandboxed frame animates by itself with CSS, no ticks.
141// Two waves of bursts with shock rings, confetti falling through, and a
142// glowing COMPLETED! in the middle.
143function fireworksSvg(seed: number, size: { width: number; height: number } = { width: 800, height: 220 }): string {
144  const W = Math.round(size.width)
145  const H = Math.round(size.height)
146  const P = 2.6 // seconds per cycle
147  const fontSize = Math.round(Math.min(46, W / 10.5))
148  const rand = rng(seed)
149  const css: string[] = [
150    `.p{animation:burst ${P}s ease-out infinite;opacity:0}`,
151    '@keyframes burst{0%,28%{transform:translateX(0);opacity:0}30%{opacity:1}70%{opacity:.95}100%{transform:translateX(var(--r));opacity:0}}',
152    `.fall{animation:fall ${P}s ease-in infinite}`,
153    '@keyframes fall{0%,28%{transform:translateY(0)}100%{transform:translateY(26px)}}',
154    `.ring{animation:ring ${P}s ease-out infinite;opacity:0;fill:none;stroke-width:3}`,
155    '@keyframes ring{0%,28%{r:2;opacity:0}30%{opacity:.9}60%{opacity:0}100%{r:var(--r);opacity:0}}',
156    `.flash{animation:flash ${P}s ease-out infinite;opacity:0}`,
157    '@keyframes flash{0%,28%{opacity:0}30%{opacity:.35}45%,100%{opacity:0}}',
158    '.c{animation:conf linear infinite}',
159    `@keyframes conf{0%{transform:translateY(-20px) rotate(0)}100%{transform:translateY(${H + 20}px) rotate(720deg)}}`,
160    `.title{animation:pulse 1.2s ease-in-out infinite;transform-origin:${W / 2}px ${H / 2}px}`,
161    '@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}',
162    '.hue{animation:hue 2s linear infinite}',
163    '@keyframes hue{to{filter:hue-rotate(360deg)}}',
164  ]
165  const parts: string[] = []
166  // As many bursts as the area holds, in two waves, spread over all of it.
167  const bursts = Math.max(6, Math.min(26, Math.round((W * H) / 17600) * 2))
168  const perWave = Math.ceil(bursts / 2)
169  for (let b = 0; b < bursts; b++) {
170    const wave = b % 2
171    const slot = b >> 1
172    const x = Math.round(40 + ((slot + 0.5) / perWave) * (W - 80) + (rand() - 0.5) * 60)
173    const y = Math.round(30 + rand() * Math.max(70, H * 0.72 - 30))
174    const hue = Math.round(rand() * 360)
175    const delay = (wave * P * 0.5 + ((slot * 0.18) % (P * 0.5)) + rand() * 0.15).toFixed(2)
176    const r = Math.round(40 + rand() * 38)
177    const color = hsl(hue, 1, 0.62)
178    const glow = hsl((hue + 50) % 360, 1, 0.78)
179    css.push(
180      `@keyframes rk${b}{0%{transform:translateY(${H - y}px);opacity:1}28%{transform:translateY(0);opacity:1}29%,100%{opacity:0}}`,
181    )
182    const sparks: string[] = []
183    const count = 22
184    for (let i = 0; i < count; i++) {
185      const angle = Math.round((360 / count) * i + rand() * 8)
186      const rr = i % 2 === 0 ? r : Math.round(r * 0.6)
187      sparks.push(
188        `<g transform="rotate(${angle})"><circle class="p" r="${i % 2 === 0 ? 3 : 2}" fill="${i % 3 === 0 ? glow : color}" style="--r:${rr}px;animation-delay:${delay}s"/></g>`,
189      )
190    }
191    parts.push(
192      `<g transform="translate(${x} ${y})">` +
193        `<circle r="2.6" fill="#FFF3B0" style="animation:rk${b} ${P}s ease-out infinite;animation-delay:${delay}s;opacity:0"/>` +
194        `<circle class="flash" r="${r}" fill="${glow}" style="animation-delay:${delay}s"/>` +
195        `<circle class="ring" stroke="${glow}" style="--r:${r + 14}px;animation-delay:${delay}s"/>` +
196        `<g class="fall" style="animation-delay:${delay}s">${sparks.join('')}</g>` +
197        `</g>`,
198    )
199  }
200  const confetti: string[] = []
201  for (let i = 0; i < Math.max(30, Math.round((W * H) / 3800)); i++) {
202    const x = Math.round(rand() * W)
203    const color = hsl(Math.round(rand() * 360), 0.95, 0.6)
204    const dur = (2.2 + rand() * 2.2).toFixed(2)
205    const delay = (-rand() * 4).toFixed(2)
206    confetti.push(
207      `<g transform="translate(${x} 0)"><rect class="c" x="-3" y="-2" width="${4 + Math.round(rand() * 5)}" height="${3 + Math.round(rand() * 3)}" fill="${color}" style="animation-duration:${dur}s;animation-delay:${delay}s"/></g>`,
208    )
209  }
210  return (
211    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
212    `<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="#FF4D6D"/><stop offset=".25" stop-color="#FFD400"/><stop offset=".5" stop-color="#22C55E"/><stop offset=".75" stop-color="#38BDF8"/><stop offset="1" stop-color="#A855F7"/></linearGradient>` +
213    `<filter id="glow" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>` +
214    `<radialGradient id="sky" cx=".5" cy="1" r="1"><stop offset="0" stop-color="#1B1F3A"/><stop offset="1" stop-color="#05060D"/></radialGradient></defs>` +
215    `<style>${css.join('')}</style>` +
216    `<rect width="${W}" height="${H}" fill="url(#sky)" rx="12"/>` +
217    parts.join('') +
218    confetti.join('') +
219    `<g class="title"><text class="hue" x="${W / 2}" y="${Math.round(H / 2 + fontSize * 0.35)}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="${fontSize}" font-weight="900" fill="url(#g)" filter="url(#glow)" letter-spacing="${Math.round(fontSize / 12)}">✨ COMPLETED! ✨</text></g>` +
220    `</svg>`
221  )
222}
223
224type Cell = { ch: string; color: string }
225
226// Terminal: the same idea in characters, one frame per tick.
227function fireworksRows(n: number, columns: number, rows: number, seed: number): Cell[][] {
228  const grid: (Cell | undefined)[][] = Array.from({ length: rows }, () => Array<Cell | undefined>(columns).fill(undefined))
229  const put = (x: number, y: number, cell: Cell) => {
230    if (y >= 0 && y < rows && x >= 0 && x < columns) grid[y][x] = cell
231  }
232  const PERIOD = 22
233  const bursts = Math.max(2, Math.min(5, Math.floor(columns / 18)))
234  for (let b = 0; b < bursts; b++) {
235    const start = b * 5
236    if (n < start) continue
237    const t = (n - start) % PERIOD
238    const cycle = Math.floor((n - start) / PERIOD)
239    const rand = rng(seed + b * 97 + cycle * 7919)
240    const x = Math.round(((b + 0.5) / bursts) * columns + (rand() - 0.5) * 8)
241    const cy = 1 + Math.floor(rand() * 2)
242    const hue = Math.round(rand() * 360)
243    if (t < 5) {
244      const y = Math.round(rows - 1 - (t / 5) * (rows - 1 - cy))
245      put(x, y, { ch: '|', color: '#FFE45C' })
246      put(x, y + 1, { ch: '.', color: '#8A7300' })
247      continue
248    }
249    const age = t - 5
250    if (age > 12) continue
251    const radius = age * 0.55
252    const drop = Math.floor((age * age) / 60)
253    const light = Math.max(0.3, 0.7 - age * 0.03)
254    const ch = age < 4 ? '*' : age < 8 ? '+' : '·'
255    for (let i = 0; i < 12; i++) {
256      const a = (Math.PI * 2 * i) / 12
257      put(Math.round(x + Math.cos(a) * radius * 2), Math.round(cy + Math.sin(a) * radius) + drop, {
258        ch,
259        color: hsl((hue + i * 8) % 360, 1, light),
260      })
261    }
262    if (age < 3) put(x, cy, { ch: '✦', color: '#FFFFFF' })
263  }
264  return grid.map(row => row.map(c => c ?? { ch: ' ', color: '' }))
265}
266
267// Runs of one color, so a row is a handful of Texts and not one per cell.
268function spans(row: Cell[]): Cell[] {
269  const out: Cell[] = []
270  for (const c of row) {
271    const last = out[out.length - 1]
272    if (last && last.color === c.color) last.ch += c.ch
273    else out.push({ ...c })
274  }
275  return out
276}
277
278// Green on and off, about three times a second at the rainbow's frame rate.
279function green(n: number): string {
280  return Math.floor(n / 4) % 2 === 0 ? '#22C55E' : '#14532D'
281}
282
283// The last block of the answer is the row that closes the turn.
284function isAnswerTail(text: string, answer: string): boolean {
285  const t = text.trim()
286  return t.length > 0 && answer.trim().endsWith(t)
287}
288
289// ---- sound: synthesized here, so the mod ships no audio file ------------
290
291const RATE = 16000
292const FANFARE_GAIN = 2 // 0 to 4; how hard the fanfare is driven (1: as made)
293const SAY = 'おつかれさま!完了しました'
294const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
295
296function base64(bytes: Uint8Array): string {
297  let out = ''
298  for (let i = 0; i < bytes.length; i += 3) {
299    const t = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
300    out +=
301      B64[(t >> 18) & 63] +
302      B64[(t >> 12) & 63] +
303      (i + 1 < bytes.length ? B64[(t >> 6) & 63] : '=') +
304      (i + 2 < bytes.length ? B64[t & 63] : '=')
305  }
306  return out
307}
308
309// 16-bit mono PCM in a WAV header, as base64. `drive` above 1 saturates the
310// waveform (tanh) so it sounds louder at the same peak: Windows' player has
311// no volume of its own.
312function wav(samples: Float32Array, drive = 1): string {
313  const bytes = new Uint8Array(44 + samples.length * 2)
314  const v = new DataView(bytes.buffer)
315  const tag = (at: number, s: string) => [...s].forEach((c, i) => v.setUint8(at + i, c.charCodeAt(0)))
316  tag(0, 'RIFF')
317  v.setUint32(4, 36 + samples.length * 2, true)
318  tag(8, 'WAVE')
319  tag(12, 'fmt ')
320  v.setUint32(16, 16, true)
321  v.setUint16(20, 1, true)
322  v.setUint16(22, 1, true)
323  v.setUint32(24, RATE, true)
324  v.setUint32(28, RATE * 2, true)
325  v.setUint16(32, 2, true)
326  v.setUint16(34, 16, true)
327  tag(36, 'data')
328  v.setUint32(40, samples.length * 2, true)
329  let peak = 0
330  for (const x of samples) peak = Math.max(peak, Math.abs(x))
331  const scale = peak > 0 ? 1 / peak : 1
332  const shape = drive > 1 ? (x: number) => Math.tanh(x * drive) / Math.tanh(drive) : (x: number) => x
333  samples.forEach((x, i) => v.setInt16(44 + i * 2, Math.round(shape(x * scale) * 0.92 * 32767), true))
334  return base64(bytes)
335}
336
337function chime(out: Float32Array, at: number, freq: number, gain: number, decay: number) {
338  const start = Math.round(at * RATE)
339  for (let i = start; i < out.length; i++) {
340    const t = (i - start) / RATE
341    const env = Math.exp(-t * decay) * Math.min(1, t * 400)
342    if (env < 0.001) break
343    out[i] += gain * env * (Math.sin(2 * Math.PI * freq * t) + 0.3 * Math.sin(4 * Math.PI * freq * t))
344  }
345}
346
347// Three rockets whistle up and boom one after another, crackles rain down,
348// and a fanfare climbs to a ringing chord.
349function fanfareSamples(): Float32Array {
350  const out = new Float32Array(Math.round(RATE * 3.8))
351  const rand = rng(7)
352  const rockets = [
353    { at: 0, from: 700, to: 2300 },
354    { at: 0.28, from: 900, to: 2700 },
355    { at: 0.56, from: 600, to: 2000 },
356  ]
357  for (const r of rockets) {
358    let phase = 0
359    const start = Math.round(r.at * RATE)
360    for (let i = 0; i < RATE * 0.42; i++) {
361      const t = i / RATE
362      phase += (2 * Math.PI * (r.from + (t / 0.42) * (r.to - r.from))) / RATE
363      out[start + i] += 0.14 * Math.sin(phase) * (t / 0.42) + 0.03 * (rand() * 2 - 1)
364    }
365    // the boom: low noise and a thump under it
366    const boom = start + Math.round(RATE * 0.44)
367    let low = 0
368    for (let i = boom; i < out.length; i++) {
369      const t = (i - boom) / RATE
370      const env = Math.exp(-t * 4.5)
371      if (env < 0.002) break
372      low += (rand() * 2 - 1 - low) * 0.09
373      out[i] += low * env * 2.2 + 0.7 * Math.sin(2 * Math.PI * (70 - t * 30) * t) * Math.exp(-t * 7)
374    }
375  }
376  for (let i = Math.round(RATE * 0.6); i < out.length - 40; i++) {
377    if (rand() < 0.007) {
378      const g = (rand() * 0.4 + 0.12) * Math.exp(-(i / RATE - 0.6) * 0.9)
379      for (let k = 0; k < 40; k++) out[i + k] += g * (rand() * 2 - 1) * Math.exp(-k / 7)
380    }
381  }
382  // fanfare: up the arpeggio twice, then the chord rings out
383  ;[1046.5, 1318.5, 1568, 2093].forEach((f, k) => chime(out, 0.6 + k * 0.08, f, 0.17, 4))
384  ;[1568, 2093, 2637, 3136].forEach((f, k) => chime(out, 1.05 + k * 0.08, f, 0.17, 4))
385  ;[1046.5, 1318.5, 1568, 2093, 2637].forEach(f => chime(out, 1.5, f, 0.14, 1.3))
386  return out
387}
388
389function pipSamples(): Float32Array {
390  const out = new Float32Array(Math.round(RATE * 0.35))
391  chime(out, 0, 1568, 0.3, 14)
392  chime(out, 0.08, 2093, 0.3, 10)
393  return out
394}
395
396let fanfare: string | undefined
397let pip: string | undefined
398
399// Windows: the engine's player plays nothing there, so PowerShell plays the
400// clip from standard input with the system's SoundPlayer, then speaks with
401// the system voice (Japanese where one is installed). No file is written.
402const WIN_PLAYER = [
403  "$ErrorActionPreference = 'Stop'",
404  '$bytes = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())',
405  '$player = New-Object System.Media.SoundPlayer (New-Object System.IO.MemoryStream (,$bytes))',
406  '$player.PlaySync()',
407  'if ($env:DOPA_SAY) {',
408  '  Add-Type -AssemblyName System.Speech',
409  '  $voice = New-Object System.Speech.Synthesis.SpeechSynthesizer',
410  "  try { $voice.SelectVoiceByHints('NotSet', 'NotSet', 0, [Globalization.CultureInfo]'ja-JP') } catch {}",
411  '  $voice.Volume = 100',
412  '  $voice.Speak($env:DOPA_SAY)',
413  '}',
414].join('\n')
415
416// Windows: a borderless, see-through, topmost window over the whole primary
417// screen for about two seconds: confetti falling, a frame cycling through the
418// rainbow, and a line of text in the middle. It never takes the focus, and it
419// makes no sound of its own (the fanfare is the sound).
420const OVERLAY_TEXT = 'Claude 返信きたよ!'
421const WIN_OVERLAY = String.raw`
422$ErrorActionPreference = 'Stop'
423$ProgressPreference = 'SilentlyContinue'
424Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase
425
426$w = New-Object System.Windows.Window
427$w.WindowStyle = 'None'
428$w.AllowsTransparency = $true
429$w.Background = [System.Windows.Media.Brushes]::Transparent
430$w.Topmost = $true
431$w.ShowInTaskbar = $false
432$w.ShowActivated = $false
433$w.Left = 0; $w.Top = 0
434$sw = [System.Windows.SystemParameters]::PrimaryScreenWidth
435$sh = [System.Windows.SystemParameters]::PrimaryScreenHeight
436$w.Width = $sw; $w.Height = $sh
437
438$grid = New-Object System.Windows.Controls.Grid
439$border = New-Object System.Windows.Controls.Border
440$border.BorderThickness = 18
441$grid.Children.Add($border) | Out-Null
442$canvas = New-Object System.Windows.Controls.Canvas
443$grid.Children.Add($canvas) | Out-Null
444
445$label = New-Object System.Windows.Controls.TextBlock
446$label.Text = $env:DOPA_TEXT
447$label.FontSize = 64
448$label.FontWeight = 'Bold'
449$label.Foreground = [System.Windows.Media.Brushes]::White
450$label.HorizontalAlignment = 'Center'
451$label.VerticalAlignment = 'Center'
452$label.Effect = New-Object System.Windows.Media.Effects.DropShadowEffect
453$grid.Children.Add($label) | Out-Null
454$w.Content = $grid
455
456$colors = '#E24B4A','#EF9F27','#1D9E75','#378ADD','#7F77DD','#D4537E'
457$brushes = $colors | ForEach-Object { New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($_)) }
458$rand = New-Object System.Random
459$confetti = @()
460for ($i = 0; $i -lt 120; $i++) {
461  $r = New-Object System.Windows.Shapes.Rectangle
462  $r.Width = 10; $r.Height = 16
463  $r.Fill = $brushes[$i % 6]
464  [System.Windows.Controls.Canvas]::SetLeft($r, $rand.NextDouble() * $sw)
465  [System.Windows.Controls.Canvas]::SetTop($r, -$rand.NextDouble() * $sh * 0.5)
466  $canvas.Children.Add($r) | Out-Null
467  $confetti += [pscustomobject]@{ R = $r; VY = 8 + $rand.NextDouble() * 10; VX = ($rand.NextDouble() - 0.5) * 6 }
468}
469
470$script:tick = 0
471$timer = New-Object System.Windows.Threading.DispatcherTimer
472$timer.Interval = [TimeSpan]::FromMilliseconds(30)
473$timer.Add_Tick({
474  $script:tick++
475  $border.BorderBrush = $brushes[[int]($script:tick / 4) % 6]
476  foreach ($p in $confetti) {
477    [System.Windows.Controls.Canvas]::SetTop($p.R, [System.Windows.Controls.Canvas]::GetTop($p.R) + $p.VY)
478    [System.Windows.Controls.Canvas]::SetLeft($p.R, [System.Windows.Controls.Canvas]::GetLeft($p.R) + $p.VX)
479  }
480  if ($script:tick -gt 50) { $w.Opacity = [Math]::Max(0, 1 - ($script:tick - 50) / 15) }
481  if ($script:tick -gt 65) { $timer.Stop(); $w.Close() }
482})
483$w.Add_ContentRendered({ $timer.Start() })
484$w.ShowDialog() | Out-Null
485`
486
487function utf16le(text: string): Uint8Array {
488  const bytes = new Uint8Array(text.length * 2)
489  for (let i = 0; i < text.length; i++) {
490    bytes[i * 2] = text.charCodeAt(i) & 255
491    bytes[i * 2 + 1] = text.charCodeAt(i) >> 8
492  }
493  return bytes
494}
495
496let isWindows: boolean | undefined
497let isSoundWarned = false
498
499// By its full path: powershell.exe is not always on PATH.
500async function powershellPath($: EngineInterface): Promise<string> {
501  const root = (await $.env.get('SystemRoot')) ?? 'C:\\Windows'
502  return `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
503}
504
505async function playClip($: EngineInterface, clip: string, gain: number, say?: string) {
506  isWindows ??= (await $.env.get('OS')) === 'Windows_NT'
507  if (isWindows) {
508    const ran = await $.process.run(
509      [await powershellPath($), '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', base64(utf16le(WIN_PLAYER))],
510      { stdin: clip, env: { DOPA_SAY: say ?? '' }, timeoutMs: 30000 },
511    )
512    if (ran.exitCode !== 0) throw new Error(cut(ran.stderr || `exit ${ran.exitCode}`, 80))
513    return
514  }
515  await $.audio.play({ base64: clip, mime: 'audio/wav' }, { gain })
516  if (say) await $.audio.speak(say)
517}
518
519function soundFailed($: EngineInterface, err: unknown) {
520  if (isSoundWarned) return
521  isSoundWarned = true
522  $.ui.toast(`🔇 音を鳴らせませんでした: ${cut(err instanceof Error ? err.message : String(err), 80)}`)
523}
524
525// Switches kept in the plugin's store across sessions; each is on until turned off.
526const SOUND_KEY = 'sound'
527const OVERLAY_KEY = 'overlay'
528
529async function isOn($: EngineInterface, key: string): Promise<boolean> {
530  try {
531    return (await $.store.get(key)) !== 'off'
532  } catch {
533    return true // a store that can't be read leaves the switch as it was made: on
534  }
535}
536
537const isSoundOn = ($: EngineInterface) => isOn($, SOUND_KEY)
538
539// `/<command> on`, `/<command> off`, or bare `/<command>` to flip it.
540async function toggle(
541  $: EngineInterface,
542  key: string,
543  args: string,
544  say: { command: string; on: string; off: string },
545): Promise<string> {
546  const word = args.trim().toLowerCase()
547  if (word !== '' && word !== 'on' && word !== 'off') {
548    return `使い方: /${say.command} on | off(何も付けなければ切り替え)`
549  }
550  const isNowOn = word === '' ? !(await isOn($, key)) : word === 'on'
551  await $.store.set(key, isNowOn ? 'on' : 'off')
552  return isNowOn ? say.on : say.off
553}
554
555const setSound = ($: EngineInterface, args: string) =>
556  toggle($, SOUND_KEY, args, {
557    command: 'dopa-sound',
558    on: '🔊 dopa-ide の音を ON にしました',
559    off: '🔇 dopa-ide の音を OFF にしました(見た目の演出はそのまま)',
560  })
561
562const setOverlay = ($: EngineInterface, args: string) =>
563  toggle($, OVERLAY_KEY, args, {
564    command: 'dopa-overlay',
565    on: '🎊 完了時の全画面演出を ON にしました(Windows のみ)',
566    off: '🙈 完了時の全画面演出を OFF にしました(画面共有中などに)',
567  })
568
569// Built and played on a timer, so the hook that asks never waits for the synth.
570function playFanfare($: EngineInterface) {
571  $.clock.after(0, async () => {
572    if (!(await isSoundOn($))) return
573    fanfare ??= wav(fanfareSamples(), FANFARE_GAIN)
574    void playClip($, fanfare, 1, SAY).catch(err => soundFailed($, err))
575  })
576}
577
578let isOverlayWarned = false
579
580// The full-screen celebration, Windows only; off with /dopa-overlay off.
581function showOverlay($: EngineInterface) {
582  $.clock.after(0, async () => {
583    isWindows ??= (await $.env.get('OS')) === 'Windows_NT'
584    if (!isWindows || !(await isOn($, OVERLAY_KEY))) return
585    const argv = [
586      await powershellPath($),
587      '-NoProfile',
588      '-NonInteractive',
589      '-STA',
590      '-ExecutionPolicy',
591      'Bypass',
592      '-WindowStyle',
593      'Hidden',
594      '-EncodedCommand',
595      base64(utf16le(WIN_OVERLAY)),
596    ]
597    void $.process
598      .run(argv, { env: { DOPA_TEXT: OVERLAY_TEXT }, timeoutMs: 20000 })
599      .then(ran => {
600        if (ran.exitCode !== 0) throw new Error(cut(ran.stderr || `exit ${ran.exitCode}`, 80))
601      })
602      .catch(err => {
603        if (isOverlayWarned) return
604        isOverlayWarned = true
605        $.ui.toast(`🎊 全画面演出を出せませんでした: ${cut(err instanceof Error ? err.message : String(err), 80)}`)
606      })
607  })
608}
609
610function playPip($: EngineInterface) {
611  $.clock.after(0, async () => {
612    if (!(await isSoundOn($))) return
613    pip ??= wav(pipSamples(), 1.5)
614    void playClip($, pip, 0.6).catch(err => soundFailed($, err))
615  })
616}
617
618// ---- timers (take $; top-level so validation can follow them) ----------
619
620function startBlink($: EngineInterface) {
621  stopTimer()
622  timer = $.clock.every(BLINK_MS, () => {
623    void update($, frame, n => n + 1)
624  })
625}
626
627function startRainbow($: EngineInterface) {
628  stopTimer()
629  void update($, isAnimating, () => true)
630  timer = $.clock.every(RAINBOW_MS, () => {
631    void update($, frame, n => n + 1)
632  })
633  $.clock.after(RAINBOW_FOR_MS, () => {
634    stopTimer()
635    void update($, isAnimating, () => false)
636    void $.ui.close({ id: FIREWORKS_PANE }).catch(() => undefined)
637  })
638}
639
640// The desktop app's blue dot in the sidebar, set through its own tool; on a
641// surface without that tool the call is refused and only a toast says so.
642const UNREAD_TOOL = 'mcp__ccd_sidebar__set_unread'
643
644const DOT_BLINKS = 8 // on/off pairs before it stays on
645const DOT_BLINK_MS = 350
646
647// Blinks the dot: on, off, on, off ... and on to stay. A refusal stops the rest.
648function markUnread($: EngineInterface) {
649  let isRefused = false
650  for (let i = 0; i <= DOT_BLINKS * 2; i++) {
651    $.clock.after(i * DOT_BLINK_MS, () => {
652      if (isRefused) return
653      void $.tool
654        .call({ tool: UNREAD_TOOL, session_id: 'self', unread: i % 2 === 0 } as never)
655        .catch((err: unknown) => {
656          if (isRefused) return
657          isRefused = true
658          $.ui.toast(`🔵 未読ドットを付けられませんでした: ${cut(err instanceof Error ? err.message : String(err), 80)}`)
659        })
660    })
661  }
662}
663
664// A ✅ at the head of the session's title in the sidebar: put on when a turn
665// completes, taken off when the next one starts. The title itself is kept.
666const GET_SESSION_TOOL = 'mcp__ccd_session_mgmt__get_session'
667const TITLE_TOOL = 'mcp__ccd_session_mgmt__set_session_title'
668const OWN_TOOLS = [UNREAD_TOOL, GET_SESSION_TOOL, TITLE_TOOL]
669const DONE_MARK = '✅ '
670
671function titleOf(text: string | undefined): string | undefined {
672  if (!text) return undefined
673  try {
674    const data = JSON.parse(text) as { title?: unknown; session?: { title?: unknown } }
675    const title = data.title ?? data.session?.title
676    if (typeof title === 'string') return title
677  } catch {
678    // not JSON: fall through to the first "title" field in the text
679  }
680  const m = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text)
681  return m ? (JSON.parse(`"${m[1]}"`) as string) : undefined
682}
683
684// Every ✅ at the head, with or without its variation selector or a space.
685const MARKS = /^(?:\s*✅️?\s*)+/u
686
687function stripMarks(title: string): string {
688  return title.replace(MARKS, '')
689}
690
691async function readTitle($: EngineInterface): Promise<string> {
692  const got = await $.tool.call({ tool: GET_SESSION_TOOL, session_id: 'self' } as never)
693  if (got.deny !== undefined || got.isError) throw new Error(got.deny ?? got.text ?? 'get_session failed')
694  const title = titleOf(got.text)
695  if (title === undefined) throw new Error('タイトルを読み取れませんでした')
696  return title
697}
698
699// Changes the title, then reads it back: a change the app did not keep throws.
700async function retitle($: EngineInterface, change: (title: string) => string) {
701  const title = await readTitle($)
702  const next = change(title)
703  if (next === title) return
704  const set = await $.tool.call({ tool: TITLE_TOOL, session_id: 'self', title: next } as never)
705  if (set.deny !== undefined || set.isError) throw new Error(set.deny ?? set.text ?? 'set_session_title failed')
706  const kept = await readTitle($)
707  if (kept !== next) throw new Error(`反映されませんでした(今: ${cut(kept, 30)})`)
708}
709
710function wait($: EngineInterface, ms: number): Promise<void> {
711  return new Promise(resolve => $.clock.after(ms, () => resolve()))
712}
713
714// The result goes into the run log, so /dopa-log shows what became of it.
715async function noteTitle($: EngineInterface, isDone: boolean, why?: string) {
716  const entry: LogEntry = {
717    id: `title-${await $.clock.now()}-${isDone ? 'on' : 'off'}`,
718    at: await $.clock.now(),
719    icon: '🏷️',
720    label: (isDone ? 'タイトルに ✅' : 'タイトルの ✅ を外す') + (why ? `: ${why}` : ''),
721    status: why ? 'fail' : 'ok',
722  }
723  await update($, run, r => ({ ...r, log: [...r.log, entry].slice(-200) }))
724}
725
726// One title change at a time, in the order asked, so a quick turn's ✅ and
727// the next prompt's removal never cross. Each tries four times.
728let titleQueue: Promise<void> = Promise.resolve()
729
730function markTitle($: EngineInterface, isDone: boolean) {
731  const change = isDone ? (t: string) => DONE_MARK + stripMarks(t) : stripMarks
732  titleQueue = titleQueue.then(async () => {
733    let why = ''
734    for (let k = 0; k < 4; k++) {
735      try {
736        await retitle($, change)
737        await noteTitle($, isDone)
738        return
739      } catch (err) {
740        why = cut(err instanceof Error ? err.message : String(err), 80)
741        if (k < 3) await wait($, 700 * (k + 1))
742      }
743    }
744    await noteTitle($, isDone, why)
745    $.ui.toast(`✅ タイトルを変えられませんでした: ${why}`)
746  })
747}
748
749// ---- hooks -------------------------------------------------------------
750
751export const register: Register = on => {
752  // The next prompt takes the ✅ off at once.
753  on('prompt.submit', async ($, e, next) => {
754    markTitle($, false)
755    return next(e)
756  })
757
758  on('session.start', async ($, e, next) => {
759    await $.command.register({
760      name: 'dopa-log',
761      description: 'このターンで実行したことと、今やっていることをペインで開く',
762    })
763    await $.command.register({
764      name: 'dopa-sound',
765      description: 'dopa-ide の効果音と読み上げを ON / OFF する(on・off、なしで切り替え)',
766    })
767    await $.command.register({
768      name: 'dopa-overlay',
769      description: '完了時の全画面の紙吹雪と虹色の枠を ON / OFF する(Windows、on・off、なしで切り替え)',
770    })
771    return next(e)
772  })
773
774  on('command.run', { command: 'dopa-log' }, async $ => {
775    await $.ui.open({ id: PANE, title: '🌈 dopa-ide log' })
776    return { text: '実行履歴のペインを開きました。' }
777  })
778
779  on('command.run', { command: 'dopa-sound' }, async ($, e) => ({ text: await setSound($, e.args) }))
780
781  on('command.run', { command: 'dopa-overlay' }, async ($, e) => ({ text: await setOverlay($, e.args) }))
782
783  // A new main-loop turn: back to blinking yellow.
784  on('turn.start', async ($, e, next) => {
785    if (e.agentId === undefined) {
786      const now = await $.clock.now()
787      await update($, run, () => ({ ...EMPTY, phase: 'working' as Phase, startedAt: now }))
788      startBlink($)
789      markTitle($, false)
790      await update($, finale, () => ({ prompt: e.text ?? '', answer: '', toolIds: [] }))
791      $.ui.status(undefined)
792    }
793    return next(e)
794  })
795
796  // Every tool call goes into the log; tasks and todos feed the progress bar.
797  on('tool.call', async ($, e, next) => {
798    if (OWN_TOOLS.includes(e.tool)) return next(e) // our own dot and title, not part of the run
799    const input = e as unknown as Record<string, unknown>
800    const { icon, label } = describe(e.tool, input)
801    const entry: LogEntry = {
802      id: e.tool_use_id,
803      at: await $.clock.now(),
804      icon: e.agentId === undefined ? icon : '🤖',
805      label,
806      status: 'run',
807    }
808    await update($, run, r => ({ ...r, tools: r.tools + 1, log: [...r.log, entry].slice(-200) }))
809
810    const count = (await read($, run)).tools
811    if (e.agentId === undefined && COMBO_AT.includes(count)) {
812      $.ui.toast(`🔥 ${count} COMBO!`)
813      playPip($)
814    }
815
816    if (e.tool.endsWith('update_plan')) {
817      await update($, run, r => ({ ...r, steps: applyPlan(r.steps, input) }))
818    }
819
820    if (e.tool === 'TodoWrite') {
821      const todos = (input.todos ?? []) as { content: string; status: Step['status'] }[]
822      await update($, run, r => ({
823        ...r,
824        steps: todos.map((t, i) => ({ id: `todo-${i}`, title: t.content, status: t.status })),
825      }))
826    }
827
828    const ran = await next(e)
829    const failed = ran.deny !== undefined || ran.isError === true
830
831    await update($, run, r => ({
832      ...r,
833      log: r.log.map(x => (x.id === entry.id ? { ...x, status: failed ? ('fail' as const) : ('ok' as const) } : x)),
834    }))
835
836    if (!failed && e.tool === 'TaskCreate') {
837      const task = (ran.result as { task?: { id: string; subject: string } } | undefined)?.task
838      if (task) {
839        const step: Step = { id: task.id, title: task.subject, status: 'pending' }
840        await update($, run, r => ({ ...r, steps: [...r.steps.filter(s => s.id !== step.id), step] }))
841      }
842    }
843
844    if (!failed && e.tool === 'TaskUpdate') {
845      const id = String(input.taskId ?? '')
846      const status = input.status as Step['status'] | 'deleted' | undefined
847      const subject = input.subject as string | undefined
848      await update($, run, r => ({
849        ...r,
850        steps:
851          status === 'deleted'
852            ? r.steps.filter(s => s.id !== id)
853            : r.steps.map(s =>
854                s.id === id ? { ...s, status: status ?? s.status, title: subject ?? s.title } : s,
855              ),
856      }))
857    }
858
859    return ran
860  }).catch(($, e, next) => next(e)) // a failure here never blocks the tool
861
862  // The main loop finished: rainbow time.
863  on('turn.complete', async ($, e, next) => {
864    if (e.agentId === undefined) {
865      const now = await $.clock.now()
866      await update($, run, r => ({ ...r, phase: 'done' as Phase, endedAt: now }))
867      if (e.isAborted) {
868        stopTimer()
869      } else {
870        startRainbow($)
871        $.ui.toast('🌈 Completed!')
872        markUnread($)
873        markTitle($, true)
874        const ids = (await read($, run)).log.map(x => x.id)
875        await update($, finale, f => ({ ...f, answer: e.answer ?? '', toolIds: ids }))
876        $.ui.status('✅ 完了!')
877        void $.ui.notify('✅ 完了しました', { title: 'dopa-ide' }).catch(() => undefined)
878        void $.ui.open({ id: FIREWORKS_PANE, title: '🎆 Completed!' }).catch(() => undefined)
879        playFanfare($)
880        showOverlay($)
881      }
882    }
883    return next(e)
884  })
885
886  // The band above the prompt.
887  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
888    const r = await read($, run)
889    if (e.props.hasSurvey || r.phase === 'idle') return next(e)
890
891    const { Box, Text } = $.ui.resolve(e)
892    const now = await $.clock.now()
893    const p = progress(r.steps)
894    const current = [...r.log].reverse().find(x => x.status === 'run')
895    const last = r.log[r.log.length - 1]
896    const recent = r.log.slice(-HOVER_ROWS)
897    const elapsed = clock((r.phase === 'done' ? r.endedAt : now) - r.startedAt)
898
899    const details = (
900      <Box flexDirection="column" display="none" hover={{ display: 'flex' }} paddingX={1}>
901        <Text dimColor>── ここまでの実行({r.log.length}件・全部は /dopa-log)──</Text>
902        {recent.length === 0 && <Text dimColor>まだ何も実行していません</Text>}
903        {recent.map(x => (
904          <Text color={x.status === 'fail' ? 'error' : undefined} dimColor={x.status === 'ok'}>
905            {x.status === 'run' ? '▶ ' : x.status === 'fail' ? '✗ ' : '✓ '}
906            {x.icon} {x.label}
907          </Text>
908        ))}
909        {p && (
910          <Text dimColor>
911            {r.steps.map(s => (s.status === 'completed' ? '☑' : s.status === 'in_progress' ? '◐' : '☐')).join('')}{' '}
912            {r.steps.find(s => s.status === 'in_progress')?.title ?? ''}
913          </Text>
914        )}
915      </Box>
916    )
917
918    if (r.phase === 'working') {
919      const n = await read($, frame)
920      const isOn = n % 2 === 0
921      const bg = isOn ? '#FFD400' : '#8A7300'
922      const fg = isOn ? '#1A1A1A' : '#FFF6C2'
923      return (
924        <Box key="dopa" flexDirection="column">
925          <Box paddingX={1} backgroundColor={bg} hover={{ backgroundColor: '#FFE45C' }}>
926            <Text color={fg} bold>
927              ⚡ WORKING{' '}
928            </Text>
929            {p && (
930              <Text color={fg}>
931                {p.bar} {p.done}/{p.total}{' '}
932              </Text>
933            )}
934            <Text color={fg} wrap="truncate-end">
935              {current
936                ? `▶ ${current.icon} ${current.label}`
937                : last
938                  ? `🧠 考え中…(直前: ${last.label})`
939                  : '🧠 考え中…'}
940            </Text>
941            <Text color={fg}>
942              {'  '}⏱ {elapsed} · 🔧 {r.tools}
943            </Text>
944          </Box>
945          {details}
946        </Box>
947      )
948    }
949
950    // done: fireworks while the celebration lasts, then the rainbow row stays.
951    // The desktop's SVG animates itself, so it never reads the frame (a tick
952    // would redraw it and restart the show); the terminal steps by the frame.
953    const animating = await read($, isAnimating)
954    const seed = r.endedAt
955    let show: unknown = null
956    let n = 0
957    if (animating && e.surface === 'desktop') {
958      const { Svg } = $.ui.resolve(e)
959      show = <Svg source={fireworksSvg(seed)} alt="🎆 花火" isInteractive height={220} />
960    } else if (animating) {
961      n = await read($, frame)
962      const columns = Math.max(20, e.props.bodyColumns ?? 80)
963      const rows = Math.max(0, Math.min(FIREWORK_ROWS, e.props.maxRows - 2))
964      show =
965        rows > 0 ? (
966          <Box flexDirection="column">
967            {fireworksRows(n, columns, rows, seed).map(row => (
968              <Text>
969                {spans(row).map(s => (s.color ? <Text color={s.color}>{s.ch}</Text> : s.ch))}
970              </Text>
971            ))}
972          </Box>
973        ) : null
974    }
975    const letters = rainbow('✨ Completed! ✨', n)
976    return (
977      <Box key="dopa" flexDirection="column">
978        {show}
979        <Box paddingX={1} backgroundColor="#101418">
980          {letters.map(l => (
981            <Text color={l.color} bold>
982              {l.ch}
983            </Text>
984          ))}
985          <Text color="#9BE89B">
986            {'  '}⏱ {elapsed} · 🔧 {r.tools}
987            {p ? ` · ☑ ${p.done}/${p.total}` : ''}
988          </Text>
989        </Box>
990        {details}
991      </Box>
992    )
993  })
994
995  // The spinner wears what the turn is doing, with a spark that changes
996  // every blink.
997  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
998    const icon = { thinking: '🧠', requesting: '📡', responding: '✍️', 'tool-use': '⚡', 'tool-input': '⚡' }[e.props.mode]
999    const spark = ['✨', '🔥', '💥', '🌟'][(await read($, frame)) % 4]
1000    return next({ ...e, props: { ...e.props, word: `${icon} ${e.props.word} ${spark}` } })
1001  })
1002
1003  // The turn's tool rows blink green with the show.
1004  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
1005    const f = await read($, finale)
1006    if (!f.toolIds.includes(e.props.tool_use_id) || !(await read($, isAnimating))) return next(e)
1007    const inner = await next(e)
1008    const { Box } = $.ui.resolve(e)
1009    return (
1010      <Box flexDirection="column" borderStyle="round" borderColor={green(await read($, frame))}>
1011        {inner}
1012      </Box>
1013    )
1014  })
1015
1016  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1017    const f = await read($, finale)
1018    const isOurs = e.props.calls.some(c => c.tool_use_id !== undefined && f.toolIds.includes(c.tool_use_id))
1019    if (!isOurs || !(await read($, isAnimating))) return next(e)
1020    const inner = await next(e)
1021    const { Box } = $.ui.resolve(e)
1022    return (
1023      <Box flexDirection="column" borderStyle="round" borderColor={green(await read($, frame))}>
1024        {inner}
1025      </Box>
1026    )
1027  })
1028
1029  // The hint under the prompt keeps the combo count while Claude works.
1030  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1031    if (!e.props.isDraft && !e.props.isWorking && (await read($, isAnimating))) {
1032      const { Text } = $.ui.resolve(e)
1033      const n = await read($, frame)
1034      return (
1035        <Text>
1036          <Text color={green(n)} bold>
1037            ✅ 完了!{' '}
1038          </Text>
1039          <Text dimColor>{e.props.hint}</Text>
1040        </Text>
1041      )
1042    }
1043    const r = await read($, run)
1044    if (!e.props.isWorking || e.props.isDraft || r.phase !== 'working' || r.tools === 0) return next(e)
1045    const combo = `🔥 ${r.tools} COMBO`
1046    return e.surface === 'terminal'
1047      ? next({ ...e, props: { ...e.props, tail: combo } })
1048      : next({ ...e, props: { ...e.props, hint: `${combo} · ${e.props.hint}` } })
1049  })
1050
1051  // The answer that closed the turn: a green frame, blinking while the show
1052  // lasts, steady until the next prompt.
1053  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1054    const f = await read($, finale)
1055    if (!f.answer || !isAnswerTail(e.props.text, f.answer)) return next(e)
1056    const inner = await next(e)
1057    const { Box, Text } = $.ui.resolve(e)
1058    const live = await read($, isAnimating)
1059    const color = live ? green(await read($, frame)) : '#22C55E'
1060    return (
1061      <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
1062        {inner}
1063        <Text color={color} bold>
1064          ✅ 完了
1065        </Text>
1066      </Box>
1067    )
1068  })
1069
1070  // The prompt that started it blinks green with it while the show lasts.
1071  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1072    const f = await read($, finale)
1073    if (!f.answer || !f.prompt || e.props.text.trim() !== f.prompt.trim()) return next(e)
1074    if (!(await read($, isAnimating))) return next(e)
1075    const inner = await next(e)
1076    const { Box } = $.ui.resolve(e)
1077    return (
1078      <Box flexDirection="column" borderStyle="round" borderColor={green(await read($, frame))}>
1079        {inner}
1080      </Box>
1081    )
1082  })
1083
1084  // The footer's mode labels say it is done until the next prompt.
1085  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1086    const f = await read($, finale)
1087    if (!f.answer) return next(e)
1088    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, '✅ 完了'] } })
1089  })
1090
1091  // The terminal's closing line of each turn (`Baked for 3s`).
1092  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) =>
1093    next({ ...e, props: { ...e.props, word: `✅ ${e.props.word}` } }),
1094  )
1095
1096  // A pane that opens on completion for the fireworks and closes after them.
1097  on('ui.render', { component: 'Pane', requestId: FIREWORKS_PANE }, async ($, e) => {
1098    const r = await read($, run)
1099    if (e.surface === 'desktop') {
1100      // The Svg takes the pane's width and its markup's aspect, so an aspect
1101      // from the body's cells (a cell about 2.2 times as tall as wide) fills
1102      // the dock top to bottom. Inline above the prompt it stays a band.
1103      const { Svg } = $.ui.resolve(e)
1104      const columns = Math.max(10, e.props.bodyColumns)
1105      const rows = Math.max(4, e.props.scroll.bodyRows - 2)
1106      const size =
1107        e.props.placement === 'dock'
1108          ? { width: DOCK_WIDTH, height: Math.round((420 * rows * CELL_ASPECT) / columns) }
1109          : { width: 800, height: 220 }
1110      return <Svg source={fireworksSvg(r.endedAt + 1, size)} alt="🎆 花火" isInteractive />
1111    }
1112    const { Box, Text } = $.ui.resolve(e)
1113    const n = await read($, frame)
1114    const columns = Math.max(20, e.props.bodyColumns ?? 60)
1115    const rows = Math.max(4, e.props.scroll.bodyRows - 2)
1116    return (
1117      <Box flexDirection="column">
1118        {fireworksRows(n, columns, rows, r.endedAt + 1).map(row => (
1119          <Text>{spans(row).map(s => (s.color ? <Text color={s.color}>{s.ch}</Text> : s.ch))}</Text>
1120        ))}
1121        <Text color={green(n)} bold>
1122          ✅ 完了!
1123        </Text>
1124      </Box>
1125    )
1126  })
1127
1128  // The full log, for terminals that can't hover.
1129  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1130    const { Box, Text } = $.ui.resolve(e)
1131    const r = await read($, run)
1132    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
1133    return (
1134      <Box flexDirection="column">
1135        {r.log.length === 0 && <Text dimColor>まだ何も実行していません</Text>}
1136        {r.log.slice(-room).map(x => (
1137          <Text color={x.status === 'fail' ? 'error' : x.status === 'run' ? 'warning' : undefined}>
1138            {x.status === 'run' ? '▶ ' : x.status === 'fail' ? '✗ ' : '✓ '}
1139            {x.icon} {x.label}
1140          </Text>
1141        ))}
1142      </Box>
1143    )
1144  })
1145}
1146
types/index.d.ts 30 lines
1export type Phase = 'idle' | 'working' | 'done'
2
3export type LogEntry = {
4  id: string
5  at: number
6  icon: string
7  label: string
8  status: 'run' | 'ok' | 'fail'
9}
10
11export type Step = { id: string; title: string; status: 'pending' | 'in_progress' | 'completed' }
12
13export type Run = {
14  phase: Phase
15  startedAt: number
16  endedAt: number
17  tools: number
18  log: LogEntry[]
19  steps: Step[]
20}
21
22// The turn that just completed: its prompt and its answer, to find their rows.
23export type Finale = { prompt: string; answer: string; toolIds: string[] }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'dopa-ide': { run: Run; frame: number; isAnimating: boolean; finale: Finale }
28  }
29}
30