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…

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:
| Ready | Writing the message | Committed | |
|---|---|---|---|
| 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"> |
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 ].| Button | Hotkey | |
|---|---|---|
| Suggest commit | c | write a commit message for the tracked files |
| Hide | forget the tracked files and hide the band | |
| Commit | c | let Claude commit with the suggested message |
| Regenerate | r | write another message |
| Cancel | back 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.
mktemp and rm for the commit message file).haiku through your own Claude Code session, so each suggestion costs a small amount of usage.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 :.
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.The mod reads diffs of whatever repository you work in, so it treats the repository and the model's reply as untrusted:
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.-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.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.
.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
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.
hooks/register.tsx 364 lines1import { 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}
364hooks/stage-svg.ts 145 lines1import 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}
145hooks/malenia.tsx 199 lines1import 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
199hooks/sprite.ts 276 lines1// 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]
276types/index.d.ts 16 lines1export 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