SLOPSHOPPER

blade-of-commits

A commit button above the Claude Code prompt: suggests a clean commit message for the last task, Claude commits it, and a pixel-art Malenia dances while it…

newbandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-06steven-ngle/blade-of-commits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blade-of-commits
› 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 › /commit-suggest ⎿ blade-of-commits: No uncommitted changes from recent tasks. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

blade-of-commits

A commit button for Claude Code. When Claude finishes a task that changed files, a band appears above the prompt: one click suggests a clean commit message for exactly those changes, a second click has Claude commit them. A pixel-art Malenia, Blade of Miquella, keeps you company while it happens.

<img src="docs/malenia-ready.svg" alt="Malenia at rest: wings flap, hair and cape sway" width="256"> <img src="docs/malenia-thinking.svg" alt="Malenia's Waterfowl Dance while the commit message is written" width="256"> <img src="docs/malenia-done.svg" alt="Scarlet Aeonia blooms when the commit lands" width="256">

Ready, writing the message, committed: the animations exactly as the desktop app draws them.

In the terminal she is drawn with half-block characters, large when the band has room and hand-drawn small otherwise:

ReadyWriting the messageCommitted
Large<img src="docs/terminal-large-ready.gif" alt="Terminal, large: at rest" width="210"><img src="docs/terminal-large-thinking.gif" alt="Terminal, large: Waterfowl Dance" width="210"><img src="docs/terminal-large-done.gif" alt="Terminal, large: Scarlet Aeonia" width="210">
Small<img src="docs/terminal-small-ready.gif" alt="Terminal, small: at rest" width="200"><img src="docs/terminal-small-thinking.gif" alt="Terminal, small: Waterfowl Dance" width="200"><img src="docs/terminal-small-done.gif" alt="Terminal, small: Scarlet Aeonia" width="200">

What it does

  1. Tracks the task's files. Every file Claude writes with Edit, Write or NotebookEdit is remembered. When the turn ends, the files that git still reports as changed show up in the band: 2 files changed · [ Suggest commit ] [ Hide ].
  2. Suggests a message. Suggest commit sends the diff of those files, plus what you asked Claude for, to Haiku and shows a Conventional Commits message: a subject line of at most 72 characters and a few bullet points when the change is not trivial.
  3. Claude commits it. Commit asks Claude to stage only those files and commit with exactly that message. Because Claude runs git itself, your pre-commit hooks run as usual; if one fails, Claude reports what happened instead of fixing things on its own.
  4. Malenia reacts. She rests while the band waits, performs the Waterfowl Dance while the message is written and while Claude commits, raises her blade over the preview, blooms into Scarlet Aeonia when the commit lands, and turns grey when something goes wrong.
ButtonHotkey
Suggest commitcwrite a commit message for the tracked files
Hideforget the tracked files and hide the band
Commitclet Claude commit with the suggested message
Regeneraterwrite another message
Cancelback to the file list

Hotkeys work while the band has the focus: click into it (in the terminal also Ctrl+X, then Tab). Typed into the prompt, a c is just a c. There is also a slash command: /commit-suggest.

Requirements

  • Claude Code 2.1.288 or newer. The mod is written against Claude Code's function-hook plugin API, which is in early access and may change between releases.
  • A git repository as the session's working directory.
  • macOS or Linux (the mod uses mktemp and rm for the commit message file).
  • The message is written by haiku through your own Claude Code session, so each suggestion costs a small amount of usage.

Install

Clone the repository somewhere permanent:

git clone https://github.com/steven-ngle/blade-of-commits.git ~/blade-of-commits

Terminal, one session:

claude --plugin-dir ~/blade-of-commits

Every session, desktop app included: add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, then start a new session:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/blade-of-commits"
  }
}

Several folders are separated by :.

How it looks where

  • Desktop app: Malenia is a crisp SVG (40 × 48 pixels at 4 CSS pixels each), animated with CSS.
  • Terminal: she is drawn with half-block characters, two pixels per cell. When the band has at least 18 rows and 80 columns she is drawn at 30 × 36 pixels, otherwise a hand-drawn 20 × 24 variant takes over, so a large window shows her best.

Limitations

  • Only files changed through Edit, Write and NotebookEdit are tracked. A file Claude changes through a shell command (sed, a code generator, npm install) is not part of the suggestion.
  • Files you change yourself are never staged; a file you had already modified before the task is committed whole if Claude also edited it.
  • Commit messages are written in English.

Security

The mod reads diffs of whatever repository you work in, so it treats the repository and the model's reply as untrusted:

  • The suggestion never becomes an instruction. The commit message is written to a fresh temporary file (mktemp, readable only by you), and Claude is only asked to run git add -- <files> and git commit -F <that file>. Text in a diff that tries to steer the model can at worst produce a strange message, which you see before you press Commit.
  • No code from the repository runs through the mod. Every git call passes -c core.fsmonitor=false, and every diff --no-ext-diff --no-textconv, so a repository's own .git/config cannot make git start a program. Commands run as argument lists, never through a shell.
  • Nothing reaches your terminal raw. Control characters (escape sequences included) are stripped from the message before it is shown or written.
  • Odd paths are skipped. A file whose name contains control characters or backticks is never tracked; the paths in Claude's commands are shell-quoted.
  • Your git hooks still run, because Claude commits with git as usual. In a repository you do not trust, its hooks are as dangerous as always.

The diff of the tracked files, plus the text of your prompts in the task, is sent to Haiku through your own Claude Code session, the same place your conversation already goes.

Development

.claude-plugin/plugin.json   manifest
hooks/hooks.json             points at the hooks module
hooks/register.tsx           tracking, message generation, the band, the commit
hooks/stage-svg.ts           Malenia for the desktop app (SVG + CSS animation)
hooks/malenia.tsx            Malenia for the terminal (half blocks, frame timer)
hooks/sprite.ts              the pixel data, generated
hooks/register.test.tsx      tests
types/index.d.ts             the mod's state contract
tools/sprites/build.py       Malenia as shapes; regenerates hooks/sprite.ts and docs/*.png

Load your working copy with claude --plugin-dir .; an interactive session reloads the mod whenever you save a file. Then:

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .

tsc needs the type declarations Claude Code writes into .claude-plugin/types/ the first time it loads the mod.

The animated SVGs in docs/ are the output of stageSvg() in hooks/stage-svg.ts; the terminal GIFs are frames recorded from frame() in hooks/malenia.tsx.

To change Malenia, edit the shapes (or the hand-drawn small variant) in tools/sprites/build.py and run it; it needs nothing but Python 3:

python3 tools/sprites/build.py

Disclaimer

An unofficial fan project. Malenia and Elden Ring belong to FromSoftware and Bandai Namco; this project is not affiliated with or endorsed by them. The pixel art is original and contains no game assets.

License

MIT

Source 5 files
hooks/register.tsx 364 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Phase, Proposal } from '../types'
5
6import { stageSvg, SVG_SIZE } from './stage-svg'
7
8const phase = atom({ plugin: 'blade-of-commits', key: 'phase' } as const, 'idle' as Phase)
9const files = atom({ plugin: 'blade-of-commits', key: 'files' } as const, [] as string[])
10const task = atom({ plugin: 'blade-of-commits', key: 'task' } as const, '')
11const proposal = atom({ plugin: 'blade-of-commits', key: 'proposal' } as const, null as Proposal | null)
12const note = atom({ plugin: 'blade-of-commits', key: 'note' } as const, '')
13
14const MAX_DIFF = 14000
15
16const SYSTEM = `You write git commit messages.
17Format: Conventional Commits. First line "<type>(<optional scope>): <summary>",
18imperative mood, at most 72 characters, no trailing period.
19Then a blank line and 1-4 short bullet points ("- ...") explaining what changed and why,
20only when the change is not trivial. Write in English.
21Reply with the commit message only: no code fences, no preamble.`
22
23// core.fsmonitor in a repository's own .git/config names a program git would run on
24// every status: never let a repository run code through the mod's git calls.
25const git = ($: EngineInterface, args: string[]) =>
26  $.process.run(['git', '-c', 'core.fsmonitor=false', ...args])
27
28// Diffs never run a repository's external diff or textconv programs either.
29const DIFF = ['diff', '--no-ext-diff', '--no-textconv']
30
31// Text from the model or the repository is shown in the band: keep line breaks and tabs,
32// drop every other control character (terminal escape sequences included).
33const printable = (text: string) => text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
34
35// A path that could break out of the commit instructions is never tracked.
36const isSafePath = (path: string) => path.length > 0 && !/[\u0000-\u001f\u007f`]/.test(path)
37
38const shellQuote = (text: string) => `'${text.replace(/'/g, `'\\''`)}'`
39
40const baseName = (path: string) => path.split('/').pop() ?? path
41
42const isRepo = async ($: EngineInterface) =>
43  (await git($, ['rev-parse', '--is-inside-work-tree'])).exitCode === 0
44
45const stillDirty = async ($: EngineInterface, list: string[]) => {
46  const checks = await Promise.all(
47    list.map(async file => {
48      const r = await git($, ['status', '--porcelain', '--untracked-files=all', '--', file])
49      return r.exitCode === 0 && r.stdout.trim() !== '' ? file : null
50    }),
51  )
52  return checks.filter((f): f is string => f !== null)
53}
54
55const head = async ($: EngineInterface) => {
56  const r = await git($, ['rev-parse', '--short', 'HEAD'])
57  return r.exitCode === 0 ? r.stdout.trim() : ''
58}
59
60const MAX_MESSAGE = 2000
61
62const clean = (text: string) =>
63  printable(text)
64    .trim()
65    .replace(/^```[a-z]*\n?/i, '')
66    .replace(/\n?```$/, '')
67    .trim()
68    .slice(0, MAX_MESSAGE)
69
70const reset = async ($: EngineInterface) => {
71  await update($, files, () => [])
72  await update($, task, () => '')
73  await update($, proposal, () => null)
74  await update($, note, () => '')
75  await update($, phase, () => 'idle')
76}
77
78const fail = async ($: EngineInterface, why: string) => {
79  await update($, note, () => why)
80  await update($, phase, () => 'error')
81}
82
83const propose = async ($: EngineInterface) => {
84  const list = await read($, files)
85  if (list.length === 0) return fail($, 'No changes from the last task found.')
86  await update($, phase, () => 'thinking')
87
88  const hasHead = (await head($)) !== ''
89  const base = hasHead ? [...DIFF, 'HEAD'] : [...DIFF, '--cached']
90  const stat = await git($, [...base, '--stat', '--', ...list])
91  const tracked = await git($, [...base, '--', ...list])
92  const untracked = await git($, ['ls-files', '--others', '--exclude-standard', '--', ...list])
93  const fresh = untracked.stdout.split('\n').filter(Boolean)
94  const added = await Promise.all(
95    fresh.map(f => git($, [...DIFF, '--no-index', '--', '/dev/null', f]).then(r => r.stdout)),
96  )
97
98  let diff = [tracked.stdout, ...added].join('\n')
99  if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n[... diff truncated ...]`
100
101  const prompt = [
102    'What the user asked for in this task:',
103    (await read($, task)) || '(unknown)',
104    '',
105    'Changed files:',
106    stat.stdout || list.join('\n'),
107    '',
108    'Diff:',
109    diff,
110  ].join('\n')
111
112  const r = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt, maxTokens: 400 })
113  if (!r.isAnswered) return fail($, `The model did not answer (${r.reason}).`)
114
115  await update($, proposal, () => ({ message: clean(r.text), files: list }))
116  await update($, phase, () => 'preview')
117}
118
119const track = async ($: EngineInterface, path: string) => {
120  if (!isSafePath(path)) return
121  await update($, files, list => (list.includes(path) ? list : [...list, path]))
122}
123
124let headBefore = ''
125let messageFile = ''
126
127const removeMessageFile = async ($: EngineInterface) => {
128  if (messageFile === '') return
129  await $.process.run(['rm', '-f', '--', messageFile])
130  messageFile = ''
131}
132
133const commit = async ($: EngineInterface) => {
134  const p = await read($, proposal)
135  if (p === null) return
136  const paths = p.files.filter(isSafePath)
137  if (paths.length === 0) return fail($, 'None of the changed files can be committed safely.')
138
139  // The message goes to git through a file, never through Claude's prompt: text the model
140  // wrote from a diff (which a repository controls) must not reach Claude as instructions.
141  // mktemp creates a new file only this user can read, so nothing can be planted there.
142  const made = await $.process.run(['mktemp', '-t', 'blade-of-commits.XXXXXXXX'])
143  const path = made.stdout.trim()
144  if (made.exitCode !== 0 || path === '') return fail($, 'Could not create a temporary file for the commit message.')
145  await $.fs.write(path, `${p.message}\n`)
146  messageFile = path
147
148  headBefore = await head($)
149  await update($, phase, () => 'committing')
150  await $.prompt.submit({
151    text: [
152      'Please commit the files of the last task now. Run exactly these two commands:',
153      '',
154      '```',
155      `git add -- ${paths.map(shellQuote).join(' ')}`,
156      `git commit -F ${shellQuote(path)}`,
157      '```',
158      '',
159      'The commit message is already in that file; do not change it and stage nothing else.',
160      'If a pre-commit hook fails, do not fix anything on your own; just report what happened.',
161    ].join('\n'),
162  })
163}
164
165export const register: Register = on => {
166  on('session.start', async ($, e, next) => {
167    await $.command.register({
168      name: 'commit-suggest',
169      description: 'Suggest a commit message for the changes of the last task',
170    })
171    return next(e)
172  })
173
174  on('command.run', { command: 'commit-suggest' }, async $ => {
175    if ((await read($, files)).length === 0) return { text: 'No uncommitted changes from recent tasks.' }
176    void propose($)
177    return { text: 'Malenia is writing the commit message…' }
178  })
179
180  on('prompt.submit', async ($, e, next) => {
181    const p = await read($, phase)
182    if (p === 'done') await reset($)
183    if (p !== 'committing') {
184      await update($, phase, () => 'idle')
185      await update($, proposal, () => null)
186      await update($, task, prev => (prev ? `${prev}\n---\n${e.text}` : e.text).slice(-3000))
187    }
188    return next(e)
189  })
190
191  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
192    const ran = await next(e)
193    if (ran.deny === undefined && ran.isError !== true) await track($, e.file_path)
194    return ran
195  })
196
197  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
198    const ran = await next(e)
199    if (ran.deny === undefined && ran.isError !== true) await track($, e.file_path)
200    return ran
201  })
202
203  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
204    const ran = await next(e)
205    if (ran.deny === undefined && ran.isError !== true) await track($, e.notebook_path)
206    return ran
207  })
208
209  on('turn.complete', async ($, e, next) => {
210    const result = await next(e)
211    if (e.agentId !== undefined) return result
212
213    const p = await read($, phase)
214    if (p === 'committing') {
215      await removeMessageFile($)
216      const now = await head($)
217      if (now !== '' && now !== headBefore) {
218        await update($, note, () => now)
219        await update($, phase, () => 'done')
220        $.ui.toast(`blade-of-commits: created commit ${now}`)
221      } else {
222        await fail($, 'No new commit was created. Check Claude\'s reply to see what happened.')
223      }
224      return result
225    }
226
227    const list = await read($, files)
228    if (list.length === 0 || !(await isRepo($))) return result
229    const dirty = await stillDirty($, list)
230    await update($, files, () => dirty)
231    await update($, phase, () => (dirty.length > 0 ? 'ready' : 'idle'))
232    return result
233  })
234
235  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236    const p = await read($, phase)
237    if (p === 'idle' || e.props.hasSurvey) return next(e)
238
239    const { Box, Text, Button } = $.ui.resolve(e)
240    const list = await read($, files)
241    const pr = await read($, proposal)
242    const info = await read($, note)
243
244    let stage = null
245    if (e.surface === 'terminal') {
246      const { Client } = $.ui.resolve(e)
247      // The large Malenia only when the band has enough rows and columns
248      const isLarge = e.props.maxRows >= 18 && e.props.bodyColumns >= 80
249      stage = (
250        <Client
251          key="malenia"
252          module="./malenia.tsx"
253          props={{ phase: p, size: isLarge ? 'large' : 'small' }}
254          width={isLarge ? 42 : 30}
255          height={isLarge ? 18 : 12}
256        />
257      )
258    } else {
259      const { Svg } = $.ui.resolve(e)
260      stage = (
261        <Svg
262          source={stageSvg(p)}
263          alt="Malenia, Blade of Miquella, as a pixel animation"
264          width={SVG_SIZE.width}
265          height={SVG_SIZE.height}
266        />
267      )
268    }
269
270    const fileLine = (
271      <Text dimColor wrap="truncate-end">
272        {list.slice(0, 4).map(baseName).join(', ')}
273        {list.length > 4 ? ` +${list.length - 4}` : ''}
274      </Text>
275    )
276
277    let body
278    if (p === 'ready') {
279      body = (
280        <Box flexDirection="column">
281          <Text bold>
282            {list.length} {list.length === 1 ? 'file' : 'files'} changed
283          </Text>
284          {fileLine}
285          <Box flexDirection="row" gap={1}>
286            <Button key="propose" variant="primary" hotkey="c" onPress={() => propose($)}>
287              Suggest commit
288            </Button>
289            <Button key="hide" role="dismiss" onPress={() => reset($)}>
290              Hide
291            </Button>
292          </Box>
293        </Box>
294      )
295    } else if (p === 'thinking') {
296      body = (
297        <Box flexDirection="column">
298          <Text bold>Waterfowl Dance…</Text>
299          <Text dimColor>Malenia is writing the commit message.</Text>
300        </Box>
301      )
302    } else if (p === 'preview' && pr !== null) {
303      body = (
304        <Box flexDirection="column">
305          <Text color="#d0402f">{pr.message}</Text>
306          {fileLine}
307          <Box flexDirection="row" gap={1}>
308            <Button key="commit" variant="primary" hotkey="c" onPress={() => commit($)}>
309              Commit
310            </Button>
311            <Button key="again" hotkey="r" onPress={() => propose($)}>
312              Regenerate
313            </Button>
314            <Button key="cancel" role="dismiss" onPress={() => update($, phase, () => 'ready')}>
315              Cancel
316            </Button>
317          </Box>
318        </Box>
319      )
320    } else if (p === 'committing') {
321      body = (
322        <Box flexDirection="column">
323          <Text bold>Claude is committing…</Text>
324          <Text dimColor>{pr?.message.split('\n')[0]}</Text>
325        </Box>
326      )
327    } else if (p === 'done') {
328      body = (
329        <Box flexDirection="column">
330          <Text bold color="#ff6a3d">
331            Scarlet Aeonia! Commit {info}
332          </Text>
333          <Text dimColor>{pr?.message.split('\n')[0]}</Text>
334          <Button key="ok" role="dismiss" onPress={() => reset($)}>
335            OK
336          </Button>
337        </Box>
338      )
339    } else {
340      body = (
341        <Box flexDirection="column">
342          <Text bold>I am Malenia, Blade of Miquella… and that did not work.</Text>
343          <Text dimColor>{info}</Text>
344          <Box flexDirection="row" gap={1}>
345            <Button key="retry" variant="primary" onPress={() => propose($)}>
346              Retry
347            </Button>
348            <Button key="close" role="dismiss" onPress={() => reset($)}>
349              Close
350            </Button>
351          </Box>
352        </Box>
353      )
354    }
355
356    return (
357      <Box flexDirection="row" gap={2}>
358        {stage}
359        {body}
360      </Box>
361    )
362  })
363}
364
hooks/stage-svg.ts 145 lines
1import type { Phase } from '../types'
2
3import { BIG, BIG_LAYER, HAND, PALETTE } from './sprite'
4
5// Desktop stage: a real SVG, one rect per run of pixels, animated with CSS
6// (no timer in the mod). 64 × 48 pixels, 4 CSS pixels each.
7const W = 64
8const H = 48
9const FIG_X = 14
10const FIG_W = BIG[0]?.length ?? 40
11const MID = FIG_X + FIG_W / 2
12const SCALE = 4
13const HX = FIG_X + Math.round(HAND.x * FIG_W)
14const HY = Math.round(HAND.y * H)
15
16export const SVG_SIZE = { width: W * SCALE, height: H * SCALE }
17
18const rect = (x: number, y: number, w: number, color: string) =>
19  `<rect x="${x}" y="${y}" width="${w}" height="1" fill="${color}"/>`
20
21// The pixels of one layer as runs of the same color; hair split by row and side
22const runs = (row: string, layer: string, want: string, from: number, to: number, y: number) => {
23  let out = ''
24  let x = from
25  while (x < to) {
26    const ch = row[x]
27    if (ch === undefined || ch === '.' || layer[x] !== want) {
28      x += 1
29      continue
30    }
31    let end = x + 1
32    while (end < to && row[end] === ch && layer[end] === want) end += 1
33    out += rect(FIG_X + x, y, end - x, PALETTE[ch] ?? '#ff00ff')
34    x = end
35  }
36  return out
37}
38
39const figure = () => {
40  let body = ''
41  let wings = ''
42  let hair = ''
43  BIG.forEach((row, y) => {
44    const layer = BIG_LAYER[y] ?? ''
45    body += runs(row, layer, 'b', 0, row.length, y)
46    wings += runs(row, layer, 'w', 0, row.length, y)
47    // Hair and cape sway more further down; a delay per row turns it into a wave
48    const reach = Math.max(0, (y - 10) / 30)
49    const amp = Math.round(reach * 2 * 10) / 10
50    const delay = (-y * 0.07).toFixed(2)
51    const left = runs(row, layer, 'h', 0, FIG_W / 2, y)
52    const right = runs(row, layer, 'h', FIG_W / 2, row.length, y)
53    if (left) hair += `<g class="hair" style="--a:${-amp}px;animation-delay:${delay}s">${left}</g>`
54    if (right) hair += `<g class="hair" style="--a:${amp}px;animation-delay:${delay}s">${right}</g>`
55  })
56  return { body, wings, hair }
57}
58
59// Built once: the figure never changes, only the animation per phase
60const FIGURE = figure()
61
62const blade = (cls: string, extra = '') =>
63  `<g class="${cls}" ${extra}><rect x="${HX}" y="${HY}" width="18" height="1" fill="${PALETTE.B ?? '#e9eef4'}"/>` +
64  `<rect x="${HX}" y="${HY}" width="2" height="1" fill="#7a5c22"/></g>`
65
66const petals = () => {
67  let out = ''
68  for (let i = 0; i < 28; i++) {
69    const a = (i / 28) * Math.PI * 2
70    const r = 14 + (i % 3) * 4
71    const x = Math.round(MID + Math.cos(a) * r)
72    const y = Math.round(24 + Math.sin(a) * r * 0.8)
73    const c = ['#ff6a3d', '#d0402f', '#ff9f68', '#ffd1a8'][i % 4]
74    out += `<rect class="petal" x="${x}" y="${y}" width="${1 + (i % 2)}" height="${1 + (i % 2)}" fill="${c}" style="animation-delay:${(-(i % 7) * 0.4).toFixed(1)}s"/>`
75  }
76  return `<g class="bloom">${out}</g>`
77}
78
79const sparks = () => {
80  let out = ''
81  for (let i = 0; i < 8; i++) {
82    const x = 6 + ((i * 23) % 54)
83    const y = 4 + ((i * 17) % 38)
84    out += `<rect class="spark" x="${x}" y="${y}" width="1" height="1" fill="#f6e2a0" style="animation-delay:${(i * 0.13).toFixed(2)}s"/>`
85  }
86  return out
87}
88
89const STYLE = `
90svg{shape-rendering:crispEdges}
91.hair{animation:sway 1.6s steps(1,end) infinite}
92@keyframes sway{0%,100%{transform:translateX(0)}25%{transform:translateX(var(--a))}50%{transform:translateX(0)}75%{transform:translateX(calc(var(--a) * -0.5))}}
93.wings{animation:flap 1.4s steps(1,end) infinite}
94@keyframes flap{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}
95.blade,.trail{transform-origin:${HX}px ${HY + 0.5}px}
96.idle{animation:rest 2.4s steps(1,end) infinite}
97@keyframes rest{0%,100%{transform:rotate(140deg)}50%{transform:rotate(144deg)}}
98.raised{transform:rotate(-125deg);animation:gleam 1.2s steps(1,end) infinite}
99@keyframes gleam{0%,100%{opacity:1}50%{opacity:.75}}
100.down{transform:rotate(100deg)}
101.spin{animation:spin .7s linear infinite}
102.slow{animation-duration:1.6s}
103.trail{opacity:.35}
104@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
105.spark{opacity:0;animation:blink .9s steps(1,end) infinite}
106@keyframes blink{0%,60%{opacity:0}61%,100%{opacity:1}}
107.bloom{transform-origin:${MID}px 24px;animation:burst 1.2s steps(6,end) 1 both}
108@keyframes burst{from{transform:scale(0)}to{transform:scale(1)}}
109.petal{animation:fall 3s linear infinite}
110@keyframes fall{from{transform:translateY(-4px);opacity:1}to{transform:translateY(10px);opacity:0}}
111.grey{filter:grayscale(1) brightness(.65)}
112`
113
114export const stageSvg = (phase: Phase) => {
115  const isDancing = phase === 'thinking' || phase === 'committing'
116  const speed = phase === 'committing' ? ' slow' : ''
117  let sword = ''
118  if (isDancing) {
119    sword =
120      blade(`trail spin${speed}`, 'style="animation-delay:-.12s"') +
121      blade(`trail spin${speed}`, 'style="animation-delay:-.06s"') +
122      blade(`blade spin${speed}`)
123  } else if (phase === 'preview' || phase === 'done') {
124    sword = blade('blade raised')
125  } else if (phase === 'error') {
126    sword = blade('blade down')
127  } else {
128    sword = blade('blade idle')
129  }
130
131  return (
132    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${SVG_SIZE.width}" height="${SVG_SIZE.height}">` +
133    `<style>${STYLE}</style>` +
134    (phase === 'done' ? petals() : '') +
135    `<g class="${phase === 'error' ? 'grey' : ''}">` +
136    `<g class="hair-all">${FIGURE.hair}</g>` +
137    `<g class="wings">${FIGURE.wings}</g>` +
138    FIGURE.body +
139    sword +
140    `</g>` +
141    (isDancing ? sparks() : '') +
142    `</svg>`
143  )
144}
145
hooks/malenia.tsx 199 lines
1import type { ClientModule } from 'claude-code'
2
3import type { Phase } from '../types'
4
5import { HAND as HAND_AT, MEDIUM, MEDIUM_LAYER, PALETTE, SMALL, SMALL_HAND, SMALL_LAYER } from './sprite'
6
7// Terminal stage in half blocks (▀: foreground = upper pixel, background = lower pixel).
8// Two sizes: "large" (30 × 36 figure, 18 rows) when the band has room, otherwise "small".
9export type StageSize = 'small' | 'large'
10
11const SIZES = {
12  small: { body: SMALL, layer: SMALL_LAYER, w: 30, h: 24, x: 8, blade: 12, hand: SMALL_HAND },
13  large: { body: MEDIUM, layer: MEDIUM_LAYER, w: 42, h: 36, x: 10, blade: 16, hand: undefined },
14}
15const TICK_MS = 110
16
17type Props = { phase: Phase; size?: StageSize }
18type State = { t: number; phase: Phase; since: number }
19type Grid = (string | null)[]
20
21// The current size; scene() sets it for every frame
22let W = SIZES.small.w
23let H = SIZES.small.h
24let BODY = SIZES.small.body
25let LAYER = SIZES.small.layer
26let BODY_X = SIZES.small.x
27let FIG_W = BODY[0]?.length ?? 20
28let BLADE_LEN = SIZES.small.blade
29let HAND = { x: 0, y: 0 }
30
31const use = (size: StageSize) => {
32  const c = SIZES[size]
33  W = c.w
34  H = c.h
35  BODY = c.body
36  LAYER = c.layer
37  BODY_X = c.x
38  FIG_W = BODY[0]?.length ?? 20
39  BLADE_LEN = c.blade
40  HAND = c.hand
41    ? { x: BODY_X + c.hand.x, y: c.hand.y }
42    : { x: BODY_X + Math.round(HAND_AT.x * FIG_W), y: Math.round(HAND_AT.y * H) }
43}
44
45const BLADE = '#e8eef5'
46const TRAIL = '#8fb3d9'
47const ROT = ['#ff6a3d', '#d0402f', '#ff9f68', '#ffd1a8']
48
49const put = (g: Grid, x: number, y: number, c: string | null | undefined) => {
50  if (c === undefined) return
51  const xi = Math.round(x)
52  const yi = Math.round(y)
53  if (xi >= 0 && xi < W && yi >= 0 && yi < H) g[yi * W + xi] = c
54}
55
56// deterministic noise for particles
57const hash = (n: number) => {
58  const s = Math.sin(n * 127.1) * 43758.5453
59  return s - Math.floor(s)
60}
61
62const blade = (g: Grid, angle: number, color: string, length = BLADE_LEN) => {
63  put(g, HAND.x, HAND.y, '#7a5c22')
64  for (let i = 1; i <= length; i++) {
65    put(g, HAND.x + Math.cos(angle) * i, HAND.y + Math.sin(angle) * i, color)
66  }
67}
68
69// Hair and cape first (they sway outwards, more further down), then everything else on top.
70const body = (g: Grid, t: number, wild: number) => {
71  const flap = Math.floor(t / 4) % 2 === 1
72  for (const pass of ['h', 'w', 'b']) {
73    BODY.forEach((line, y) => {
74      const layer = LAYER[y] ?? ''
75      for (let x = 0; x < line.length; x++) {
76        const ch = line[x]
77        if (ch === undefined || ch === '.' || layer[x] !== pass) continue
78        let dx = 0
79        let dy = y
80        if (pass === 'h') {
81          const side = x < FIG_W / 2 ? -1 : 1
82          const reach = Math.max(0, (y - H * 0.2) / (H * 0.6))
83          dx = side * Math.round(Math.sin(t * (0.3 + wild * 0.35) + y * 0.55) * (0.8 + wild * 1.4) * reach)
84        } else if (pass === 'w' && flap) {
85          dy = y - 1 // wing flap
86        }
87        put(g, BODY_X + x + dx, dy, PALETTE[ch])
88      }
89    })
90  }
91}
92
93// Scarlet Aeonia: a ring of rot blossoms, then falling petals
94const bloom = (g: Grid, since: number) => {
95  const cx = BODY_X + FIG_W / 2
96  const cy = H / 2
97  const r = Math.min(since * 1.1, H * 0.8)
98  for (let y = 0; y < H; y++) {
99    for (let x = 0; x < W; x++) {
100      const d = Math.hypot(x - cx, (y - cy) * 1.2)
101      if (since < 18 && Math.abs(d - r) < 0.8) {
102        put(g, x, y, ROT[(x + y + since) % 2])
103      } else if (d < r - 2 && hash(x * 31 + y * 7 + Math.floor(since / 3)) > 0.93) {
104        put(g, x, y, ROT[2])
105      }
106    }
107  }
108  for (let i = 0; i < 9; i++) {
109    const x = (hash(i) * W + Math.sin(since * 0.3 + i) * 2 + W) % W
110    const y = (hash(i + 50) * H + since * 0.5) % H
111    put(g, x, y, ROT[(i % 2) + 2])
112  }
113}
114
115// Waterfowl Dance: the blade spins, with afterimages
116const dance = (g: Grid, t: number, speed: number) => {
117  for (let k = 3; k >= 1; k--) blade(g, (t - k) * speed, TRAIL, BLADE_LEN - 1)
118  blade(g, t * speed, BLADE)
119  for (let i = 0; i < 4; i++) {
120    if (hash(t * 4 + i) > 0.5) put(g, hash(t + i * 9) * W, hash(t * 3 + i) * H, PALETTE.W)
121  }
122}
123
124const grey = (hex: string) => {
125  const n = parseInt(hex.slice(1), 16)
126  const v = Math.round(((n >> 16) * 0.3 + ((n >> 8) & 255) * 0.59 + (n & 255) * 0.11) * 0.7)
127  const c = v.toString(16).padStart(2, '0')
128  return `#${c}${c}${c}`
129}
130
131const scene = (phase: Phase, t: number, since: number, size: StageSize): Grid => {
132  use(size)
133  const g: Grid = new Array(W * H).fill(null)
134  if (phase === 'done') bloom(g, since)
135  const wild = phase === 'thinking' || phase === 'committing' ? 1 : phase === 'done' ? 0.6 : 0
136  body(g, t, wild)
137  if (phase === 'thinking') dance(g, t, 0.9)
138  else if (phase === 'committing') dance(g, t, 0.45)
139  else if (phase === 'preview' || phase === 'done') blade(g, -2.2 + Math.sin(t * 0.3) * 0.08, BLADE)
140  else if (phase === 'error') blade(g, 1.75, BLADE, BLADE_LEN - 3)
141  else blade(g, 2.4 + Math.sin(t * 0.2) * 0.06, BLADE)
142  return phase === 'error' ? g.map(c => (c === null ? null : grey(c))) : g
143}
144
145// One frame as rows of colors (null = empty), for tests and for recording the README's GIFs.
146export const frame = (phase: Phase, t: number, since: number, size: StageSize) => {
147  const g = scene(phase, t, since, size)
148  return Array.from({ length: H }, (_, y) => g.slice(y * W, (y + 1) * W))
149}
150
151const Stage: ClientModule<Props, State> = (props, surface) => {
152  const { Box, Text } = surface.elements
153  const state = surface.state
154
155  if (state === undefined) {
156    surface.every(TICK_MS, () => {
157      const s = surface.state
158      if (s !== undefined) surface.setState({ ...s, t: s.t + 1 })
159    })
160    surface.setState({ t: 0, phase: props.phase, since: 0 })
161  } else if (state.phase !== props.phase) {
162    surface.setState({ ...state, phase: props.phase, since: state.t })
163  }
164
165  const t = state?.t ?? 0
166  const g = scene(props.phase, t, t - (state?.since ?? 0), props.size ?? 'small')
167
168  const rows = []
169  for (let y = 0; y < H; y += 2) {
170    const runs: { ch: string; fg?: string; bg?: string; n: number }[] = []
171    for (let x = 0; x < W; x++) {
172      const top = g[y * W + x] ?? null
173      const bottom = g[(y + 1) * W + x] ?? null
174      const cell =
175        top === null && bottom === null
176          ? { ch: ' ' }
177          : top === null
178            ? { ch: '▄', fg: bottom ?? undefined }
179            : { ch: '▀', fg: top, bg: bottom ?? undefined }
180      const last = runs[runs.length - 1]
181      if (last && last.ch === cell.ch && last.fg === cell.fg && last.bg === cell.bg) last.n += 1
182      else runs.push({ ...cell, n: 1 })
183    }
184    rows.push(
185      <Box flexDirection="row">
186        {runs.map(run => (
187          <Text color={run.fg} backgroundColor={run.bg}>
188            {run.ch.repeat(run.n)}
189          </Text>
190        ))}
191      </Box>,
192    )
193  }
194
195  return <Box flexDirection="column">{rows}</Box>
196}
197
198export default Stage
199
hooks/sprite.ts 276 lines
1// Generated by tools/sprites/build.py - edit the shapes there, not this file.
2// Malenia: winged helmet over the upper face, chin and long red hair below, scarf with
3// fur collar, crimson cape, pleated dress with a chainmail panel, golden prosthetic arm.
4// Each character is a PALETTE color, '.' is empty. *_LAYER: h = hair and cape (they sway),
5// w = wings (they flap), b = everything else.
6
7export const PALETTE: Record<string, string> = {
8  W: '#e9cf86',
9  w: '#9c7a34',
10  Y: '#c9a24a',
11  y: '#7a5c22',
12  L: '#efd58a',
13  k: '#1c1612',
14  S: '#a3302f',
15  s: '#6a1a1e',
16  F: '#b8935f',
17  f: '#7d5f3c',
18  C: '#6e1f2e',
19  c: '#41121c',
20  G: '#b5893a',
21  D: '#857262',
22  d: '#5b4b3f',
23  M: '#a4a8ad',
24  m: '#666a70',
25  o: '#140e0e',
26  K: '#e3b99c',
27  j: '#b3826a',
28  p: '#b5645a',
29  R: '#c8352a',
30  r: '#7e1d1a',
31  O: '#f0603f',
32}
33
34// The hand holding the katana (left side of the picture), as a fraction of the figure's size
35export const HAND = { x: 0.283, y: 0.518 }
36
37// Desktop (40 × 48)
38export const BIG = [
39  '........................................',
40  '...............o........o...............',
41  '..............owo......owo..............',
42  '..............owo......owo..............',
43  '...............oWo....oWo...............',
44  '...............oWWo..oWWo...............',
45  '................oWooooWo................',
46  '...............oLLLyyYYyo...............',
47  '...............oLkkYYkkyo...............',
48  '..............oRYYYYYYYyOo..............',
49  '..............oRrjKKKKjROo..............',
50  '.............oORrRKppKRRORoo............',
51  '.............oORrrRjjrrRORCCo...........',
52  '............oSORrFfFFFfRORSCCo..........',
53  '...........oCsORssssssssORsCcco.........',
54  '...........occORDDDDDDDDORCCcco.........',
55  '...........ocYORDDDDDDDDORDCccCo........',
56  '............oLODDDDDDDDDDRDcccCCo.......',
57  '...........oLYODDDDDDDDDDRDDcCCCo.......',
58  '...........oYYODDDDDDDDDDRLycCCCCo......',
59  '...........oYYORDDDDDDDDOCyyyCCCCCo.....',
60  '..........oyYYRRmmmmmmmmRCcyyCCCCCo.....',
61  '..........oyYORrDDDMmDDDRCcyLCCCCcco....',
62  '.........oyyLoordDdMMDdDRoccyyCCCccco...',
63  '.........oLLLo.odDmMmMdDoocCYYCCCccCo...',
64  '.........oLLo..odDMmMMdDo.oCCYYCcccCCo..',
65  '..........oo...odDmMmMdDo.oCCCCCcccCCCo.',
66  '..............oDdDMMMmdDdooCCCCCccCCCCo.',
67  '..............oDdDmMmMdDdo.oCCCcccCCCCCo',
68  '..............oDdDMmMMdDdo..oCCccCCCCCCc',
69  '..............oDdDmMmMdDdo...oCccCCCCCCc',
70  '..............oDdDMMMmdDdo....occCCCCCcc',
71  '..............oDdDmMmMdDdo.....ooCCCCCcc',
72  '.............odDdDMmMMdDdDo......oCCCccc',
73  '.............odDdDmMmMdDdDo.......oooooc',
74  '.............odDdDMMMmdDdDo............o',
75  '.............odDdDmMmMdDdDo.............',
76  '.............odDdDMmMMdDdDo.............',
77  '.............odDdDmMmMdDdDo.............',
78  '............oDdDdDMMMmdDdDdo............',
79  '............oDdDdDmMmMdDdDdo............',
80  '............oDdDdDMmMMdDdDdo............',
81  '............oDdDdDmMmMdDdDdo............',
82  '............oDdDdDMMMmdDdDdo............',
83  '............oDdDdDmMmMdDdDdo............',
84  '...........odDdYYYYDdYYYYDdDo...........',
85  '............oooYYYYooYYYYooo............',
86  '...............oooo..oooo...............',
87]
88
89export const BIG_LAYER = [
90  '........................................',
91  '...............w........w...............',
92  '..............www......www..............',
93  '..............www......www..............',
94  '...............www....www...............',
95  '...............wwww..wwww...............',
96  '................wwwbbwww................',
97  '...............bbbbbbbbbb...............',
98  '...............hbbbbbbbbh...............',
99  '..............hhbbbbbbbbhh..............',
100  '..............hhhbbbbbbhhh..............',
101  '.............hhhhhbbbbhhhhhh............',
102  '.............hhhhhhbbhhhhhhhh...........',
103  '............hbhhhbbbbbbhhhbhhh..........',
104  '...........hhbhhbbbbbbbbhhbhhhh.........',
105  '...........hhhhhbbbbbbbbhhhhhhh.........',
106  '...........hhbhhbbbbbbbbhhbhhhhh........',
107  '............hbhbbbbbbbbbbhbhhhhhh.......',
108  '...........bbbhbbbbbbbbbbhbbhhhhh.......',
109  '...........bbbhbbbbbbbbbbhbbhhhhhh......',
110  '...........bbbhhbbbbbbbbhhbbbhhhhhh.....',
111  '..........bbbbhhbbbbbbbbhhhbbhhhhhh.....',
112  '..........bbbhhhbbbbbbbbhhhbbhhhhhhh....',
113  '.........bbbbhhhbbbbbbbbhhhhbbhhhhhhh...',
114  '.........bbbbb.hbbbbbbbbhhhhbbhhhhhhh...',
115  '.........bbbb..bbbbbbbbbb.hhhbbhhhhhhh..',
116  '..........bb...bbbbbbbbbb.hhhhhhhhhhhhh.',
117  '..............bbbbbbbbbbbbhhhhhhhhhhhhh.',
118  '..............bbbbbbbbbbbb.hhhhhhhhhhhhh',
119  '..............bbbbbbbbbbbb..hhhhhhhhhhhh',
120  '..............bbbbbbbbbbbb...hhhhhhhhhhh',
121  '..............bbbbbbbbbbbb....hhhhhhhhhh',
122  '..............bbbbbbbbbbbb.....hhhhhhhhh',
123  '.............bbbbbbbbbbbbbb......hhhhhhh',
124  '.............bbbbbbbbbbbbbb.......hhhhhh',
125  '.............bbbbbbbbbbbbbb............h',
126  '.............bbbbbbbbbbbbbb.............',
127  '.............bbbbbbbbbbbbbb.............',
128  '.............bbbbbbbbbbbbbb.............',
129  '............bbbbbbbbbbbbbbbb............',
130  '............bbbbbbbbbbbbbbbb............',
131  '............bbbbbbbbbbbbbbbb............',
132  '............bbbbbbbbbbbbbbbb............',
133  '............bbbbbbbbbbbbbbbb............',
134  '............bbbbbbbbbbbbbbbb............',
135  '...........bbbbbbbbbbbbbbbbbb...........',
136  '............bbbbbbbbbbbbbbbb............',
137  '...............bbbb..bbbb...............',
138]
139
140// Terminal, small variant (20 × 24), drawn by hand
141export const SMALL = [
142  '......o......o......',
143  '.....oWo....oWo.....',
144  '.....oWWooooWWo.....',
145  '......oWwYYwWo......',
146  '......oYLLYYyo......',
147  '.....oRykkkkyRo.....',
148  '.....oRjKKKKjRo.....',
149  '....orRRKppKRRro....',
150  '...orRSFFFFFFSRCo...',
151  '...oRRSssssssSRCCo..',
152  '..orRLYDDDDDDdRCCco.',
153  '..orRYyDDDDDDdRCCco.',
154  '...oRYyDDDDDDdRCCcco',
155  '...oOYdyyLLyydYCCcco',
156  '...oLodDDMmDDdoLCCcC',
157  '....oodDDmMDDdooCCcC',
158  '.....odDDMmDDdoocCCc',
159  '....odDDDmMDDDdooCcc',
160  '....odDDDMmDDDdo.occ',
161  '....odDDDmMDDDdo..oo',
162  '....odDDDMmDDDdo....',
163  '...odDDDDmMDDDDdo...',
164  '...odDDDDMmDDDDdo...',
165  '....oYYYooooYYYo....',
166]
167
168// The katana hand in SMALL, in pixels
169export const SMALL_HAND = { x: 4, y: 14 }
170
171export const SMALL_LAYER = [
172  '......w......w......',
173  '.....www....www.....',
174  '.....wwwwbbwwww.....',
175  '......wwwbbwww......',
176  '......hbbbbbbh......',
177  '.....hhbbbbbbhh.....',
178  '.....hhbbbbbbhh.....',
179  '....hhhhbbbbhhhh....',
180  '...hhhbbbbbbbbhhh...',
181  '...hhhbbbbbbbbhhhh..',
182  '..hhhbbbbbbbbbhhhhh.',
183  '..hhhbbbbbbbbbhhhhh.',
184  '...hhbbbbbbbbbhhhhhh',
185  '...hhbbbbbbbbbbhhhhh',
186  '...bbbbbbbbbbbbbhhhh',
187  '....bbbbbbbbbbbhhhhh',
188  '.....bbbbbbbbbbhhhhh',
189  '....bbbbbbbbbbbbhhhh',
190  '....bbbbbbbbbbbb.hhh',
191  '....bbbbbbbbbbbb..hh',
192  '....bbbbbbbbbbbb....',
193  '...bbbbbbbbbbbbbb...',
194  '...bbbbbbbbbbbbbb...',
195  '....bbbbbbbbbbbb....',
196]
197
198// Terminal, large variant (30 × 36)
199export const MEDIUM = [
200  '...........o......o...........',
201  '..........owo....owo..........',
202  '...........oo....oo...........',
203  '...........oWo..oWo...........',
204  '...........oWooooWo...........',
205  '............oLyyYo............',
206  '...........oLkLYkyo...........',
207  '..........oRrKKKKRRo..........',
208  '..........oRrKppKRRoo.........',
209  '.........oLRrSjjSROCCo........',
210  '........oCsRfFFFfFOssco.......',
211  '........ocRRssssssORCcco......',
212  '........ocRODDDDDDORCcco......',
213  '........oYRODDDDDDCRccCCo.....',
214  '........oYYDDDDDDDDyycCCCo....',
215  '........oYLRDDDDDDOCLcCCCo....',
216  '.......oyLOrmmmmmmRcyyCCCco...',
217  '.......oLYordDMmdDrccyCCCcco..',
218  '......oLLo.odDmMdDooCYYCCcCo..',
219  '.......oo..odDMMdDooCYCCccCCo.',
220  '...........odDmMdDooCCCCccCCCo',
221  '..........oDdDMmdDdooCCccCCCCo',
222  '..........oDdDmMdDdo.oCccCCCCc',
223  '..........oDdDMMdDdo..occCCCCc',
224  '..........oDdDmMdDdo...ooCCCcc',
225  '..........oDdDMmdDdo.....oCCcc',
226  '..........oDdMmMmDdo......oooo',
227  '.........odDdmMMMDdDo.........',
228  '.........odDdMmMmDdDo.........',
229  '.........odDdMMmMDdDo.........',
230  '.........odDdMmMmDdDo.........',
231  '.........odDdmMMMDdDo.........',
232  '.........odDdMmMmDdDo.........',
233  '........oDdDdMMmMDdDdo........',
234  '.........oooYYooYYooo.........',
235  '..........oYYYooYYYo..........',
236]
237
238export const MEDIUM_LAYER = [
239  '...........w......w...........',
240  '..........www....www..........',
241  '...........ww....ww...........',
242  '...........www..www...........',
243  '...........wwwbbwww...........',
244  '............wbbbbw............',
245  '...........hbbbbbbh...........',
246  '..........hhhbbbbhhh..........',
247  '..........hhhbbbbhhhh.........',
248  '.........hbhhbbbbhhhhh........',
249  '........hhbhbbbbbbhbbhh.......',
250  '........hhhhbbbbbbhhhhhh......',
251  '........hhhhbbbbbbhhhhhh......',
252  '........bbhhbbbbbbhhhhhhh.....',
253  '........bbbbbbbbbbbbbhhhhh....',
254  '........bbbhbbbbbbhhbhhhhh....',
255  '.......bbbhhbbbbbbhhbbhhhhh...',
256  '.......bbbhhbbbbbbhhhbhhhhhh..',
257  '......bbbb.hbbbbbbhhhbbhhhhh..',
258  '.......bb..bbbbbbbbhhbhhhhhhh.',
259  '...........bbbbbbbbhhhhhhhhhhh',
260  '..........bbbbbbbbbbhhhhhhhhhh',
261  '..........bbbbbbbbbb.hhhhhhhhh',
262  '..........bbbbbbbbbb..hhhhhhhh',
263  '..........bbbbbbbbbb...hhhhhhh',
264  '..........bbbbbbbbbb.....hhhhh',
265  '..........bbbbbbbbbb......hhhh',
266  '.........bbbbbbbbbbbb.........',
267  '.........bbbbbbbbbbbb.........',
268  '.........bbbbbbbbbbbb.........',
269  '.........bbbbbbbbbbbb.........',
270  '.........bbbbbbbbbbbb.........',
271  '.........bbbbbbbbbbbb.........',
272  '........bbbbbbbbbbbbbb........',
273  '.........bbbbbbbbbbbb.........',
274  '..........bbbbbbbbbb..........',
275]
276
types/index.d.ts 16 lines
1export type Phase = 'idle' | 'ready' | 'thinking' | 'preview' | 'committing' | 'done' | 'error'
2
3export type Proposal = { message: string; files: string[] }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'blade-of-commits': {
8      phase: Phase
9      files: string[]
10      task: string
11      proposal: Proposal | null
12      note: string
13    }
14  }
15}
16