Writes an accurate conventional commit message for your staged changes, using the Claude session that made them to explain why. Matches your repo's commit…

<h1 align="center">Claude Code plugins</h1>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/tomshaw/claude-code-plugins/actions/workflows/ci.yml"><img src="https://github.com/tomshaw/claude-code-plugins/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="#-install">Install</a> · <a href="#-mods">Mods</a> · <a href="#-skills">Skills</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>
Add the marketplace once, then install any plugin by name:
claude plugin marketplace add tomshaw/claude-code-plugins
claude plugin install proofread@tomshaw
claude plugin install work-journal@tomshaw
claude plugin install laravel-tooling@tomshaw
claude plugin install commit-msg@tomshaw
Or from inside Claude Code:
/plugin install proofread --marketplace tomshaw/claude-code-plugins
Restart Claude Code after installing. To try one without installing:
git clone https://github.com/tomshaw/claude-code-plugins && cd claude-code-plugins
claude --plugin-dir mods/proofread
Needs Claude Code 2.1.293+.
Plugins built on function hooks: they change what Claude Code shows and does, live.
| Mod | What it does | Command | |
|---|---|---|---|
| ✍️ | proofread | Fixes spelling and grammar in your prompt before Claude reads it, and highlights your prompts with the fixed words called out | /proofread |
| 📓 | work-journal | Daily, weekly and monthly reviews of your work, summarized from the prompts you typed: projects, threads, decisions and open items. In a pane, or as a webpage | /journal |
| ⚒️ | laravel-tooling | Runs Pint and Larastan after Claude edits PHP, and Pest on demand, with the results in a live pane and the status line | /laravel-tooling |
| 📝 | commit-msg | Writes the commit message for your staged changes from the Claude session that made them, so it says why, not just what. Matches your repo's commit style; edit, regenerate and copy it | /commit-msg |
Instructions Claude loads when your request matches them. Ask in plain words.
| Skill | What it does | Try | |
|---|---|---|---|
| 📊 | commit-summary | Summarizes git commits for a day, week or month, for everyone or one author, grouped by theme with line counts and highlights | "what did I do this week?" |
hooks/register.tsx 640 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Composer, Draft, Style } from '../types'
5
6const COMMAND = 'commit-msg'
7const PANE = 'commit-msg'
8const MAX_DIFF = 60_000
9const RECENT_COMMITS = 20
10const MAX_DRAFTS = 10
11const SUBJECT_LIMIT = 72
12const MAX_UNTRACKED = 25
13const LOCKFILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'composer.lock', 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'uv.lock']
14const QUIET_PATHS = ['--', '.', ...LOCKFILES.map(name => `:(exclude,glob)**/${name}`)]
15const CONVENTIONAL = /^([A-Za-z]+)(\([^)]*\))?(!)?(:\s*)(.*)$/s
16
17const STYLES: Style[] = ['simple', 'body', 'full']
18const STYLE_LABELS: Record<Style, string> = { simple: 'Simple', body: 'With body', full: 'With footer' }
19const STYLE_ALIASES: Record<string, Style> = { simple: 'simple', body: 'body', full: 'full', footer: 'full' }
20
21const settings = { model: 'sonnet', style: 'simple' as Style, useSession: true }
22const runs = { latest: 0 }
23
24const composer = atom({ plugin: 'commit-msg', key: 'composer' } as const, {
25 phase: 'idle',
26 style: 'simple',
27 note: '',
28 scope: '',
29 error: '',
30 drafts: [],
31 index: -1,
32 isEditing: false,
33 copiedAt: 0,
34} satisfies Composer)
35
36const STYLE_RULES: Record<Style, string> = {
37 simple: 'Write the subject line only: "body" and "footer" are empty arrays.',
38 body: `Write the subject line and a body explaining what changed and why, as short paragraphs or "- " bullets.
39Each "body" entry is one line of at most ${SUBJECT_LIMIT} characters; an empty string separates paragraphs. "footer" is an empty array.`,
40 full: `Write the subject line, a body explaining what changed and why, as short paragraphs or "- " bullets, and a footer of git trailers.
41Each "body" entry is one line of at most ${SUBJECT_LIMIT} characters; an empty string separates paragraphs.
42Each "footer" entry is one trailer: "Refs #123" or "Closes #123" when the branch, a note or the recent commits give an issue number, "BREAKING CHANGE: ..." when the diff changes behaviour callers rely on. Use only trailers the evidence supports.`,
43}
44
45const systemFor = (style: Style): string => `You write git commit messages. The user message holds a repository's recent commit subjects, its branch, and a diff.
46Write one commit message for the diff. The subject line matches the recent commits' style exactly: their prefixes (feat:, fix:, chore: and so on), tense, casing, length, and how they reference issue numbers. Add an issue number only when the branch name or a note gives one.
47${STYLE_RULES[style]}
48When choosing the type: feat adds a capability, fix corrects a bug, refactor restructures without changing behaviour, perf speeds something up, docs, test, build, ci, style and chore cover the rest. Put "!" after the type or scope when the change breaks behaviour callers rely on.
49Write the description in the imperative mood, with no trailing period.
50Describe what changed and why it matters, not file names. Never invent work the diff does not show.
51Reply with JSON only, no code fences: {"subject": "...", "body": ["..."], "footer": ["..."]}`
52
53const forkFor = (style: Style, prompt: string): string => `Pause the work and write a git commit message instead. Take no actions; reply only as these rules say.
54
55${systemFor(style)}
56
57This conversation is where the changes below were made. Use it to explain why: the problem being solved, what was asked for, the decisions taken and what was ruled out.
58The diff is the only record of what this commit contains. Describe only work the diff shows: leave out anything this conversation did that the diff does not hold, such as unstaged edits, reverted attempts or other commits, and describe changes the conversation never discussed from the diff alone.
59
60${prompt}`
61
62type Written = { text: string; isFromSession: boolean } | { reason: string }
63
64const write = async ($: EngineInterface, style: Style, prompt: string): Promise<Written> => {
65 if (settings.useSession) {
66 const forked = await $.model.fork({ prompt: forkFor(style, prompt) })
67 if (forked.isAnswered) {
68 return { text: forked.text, isFromSession: true }
69 }
70 if (forked.reason === 'aborted') {
71 return { reason: forked.reason }
72 }
73 }
74
75 const reply = await $.model.complete({
76 model: settings.model,
77 system: systemFor(style),
78 prompt,
79 maxTokens: 1200,
80 timeoutMs: 90_000,
81 })
82
83 return reply.isAnswered ? { text: reply.text, isFromSession: false } : { reason: reply.reason }
84}
85
86type Changes = { diff: string; stat: string; isStaged: boolean; untracked: string[]; added: string }
87
88const git = async ($: EngineInterface, args: string[]): Promise<string> => {
89 const ran = await $.process.run(['git', ...args], { timeoutMs: 20_000 })
90 if (ran.exitCode !== 0) {
91 throw new Error(ran.stderr.trim() || `git ${args[0]} failed`)
92 }
93
94 return ran.stdout
95}
96
97const readChanges = async ($: EngineInterface): Promise<Changes> => {
98 const staged = await git($, ['diff', '--cached', '--no-color', '--no-ext-diff'])
99 if (staged.trim() !== '') {
100 return {
101 diff: await git($, ['diff', '--cached', '--no-color', '--no-ext-diff', ...QUIET_PATHS]),
102 stat: await git($, ['diff', '--cached', '--stat']),
103 isStaged: true,
104 untracked: [],
105 added: '',
106 }
107 }
108
109 const hasHead = (await $.process.run(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'])).exitCode === 0
110 const base = hasHead ? ['HEAD'] : []
111
112 const untracked = (await git($, ['ls-files', '--others', '--exclude-standard'])).split('\n').filter(line => line !== '')
113
114 return {
115 diff: await git($, ['diff', ...base, '--no-color', '--no-ext-diff', ...QUIET_PATHS]),
116 stat: await git($, ['diff', ...base, '--stat']),
117 isStaged: false,
118 untracked,
119 added: await readUntracked($, untracked),
120 }
121}
122
123const readUntracked = async ($: EngineInterface, files: string[]): Promise<string> => {
124 const parts: string[] = []
125 let size = 0
126 for (const file of files.filter(one => !LOCKFILES.some(name => one.endsWith(name))).slice(0, MAX_UNTRACKED)) {
127 const ran = await $.process.run(['git', 'diff', '--no-index', '--no-color', '--', '/dev/null', file], { timeoutMs: 10_000 })
128 if (ran.exitCode > 1 || size + ran.stdout.length > MAX_DIFF / 2) {
129 continue
130 }
131 parts.push(ran.stdout)
132 size += ran.stdout.length
133 }
134
135 return parts.join('')
136}
137
138const tidyLines = (lines: string[]): string[] => {
139 const tidy: string[] = []
140 for (const raw of lines) {
141 const line = raw.trimEnd()
142 if (line !== '' || (tidy.length > 0 && tidy[tidy.length - 1] !== '')) {
143 tidy.push(line)
144 }
145 }
146 while (tidy[tidy.length - 1] === '') {
147 tidy.pop()
148 }
149
150 return tidy
151}
152
153const format = (draft: Draft): string => {
154 const body = draft.style === 'simple' ? [] : tidyLines(draft.body)
155 const footer = draft.style === 'full' ? draft.footer.map(line => line.trim()).filter(line => line !== '') : []
156
157 return [draft.subject.trim(), body.join('\n'), footer.join('\n')].filter(part => part !== '').join('\n\n')
158}
159
160const buildPrompt = (changes: Changes, subjects: string, branch: string, note: string, previous: Draft | undefined): string => {
161 const combined = changes.diff + changes.added
162 const diff =
163 combined.length > MAX_DIFF
164 ? `${combined.slice(0, MAX_DIFF)}\n[diff cut at ${MAX_DIFF} characters; the stat above lists every file]`
165 : combined
166
167 return [
168 `Recent commit subjects, newest first:\n${subjects.trim() || '(none yet)'}`,
169 `Branch: ${branch.trim()}`,
170 note === '' ? '' : `Note from the author: ${note}`,
171 previous === undefined ? '' : `The author's current draft, to rewrite following the note:\n${format(previous)}`,
172 changes.untracked.length > 0 ? `New untracked files (their contents, where small enough, are in the diff):\n${changes.untracked.join('\n')}` : '',
173 `Diff stat:\n${changes.stat.trim()}`,
174 `Diff (lockfile changes omitted; the stat lists them):\n${diff}`,
175 ]
176 .filter(part => part !== '')
177 .join('\n\n')
178}
179
180const strings = (value: unknown): string[] =>
181 Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : []
182
183const isTrailer = (line: string): boolean => /^(BREAKING CHANGE|[A-Z][A-Za-z-]+):\s|^(Refs|Closes|Fixes)\s+#\d+/.test(line)
184
185const parseReply = (text: string, style: Style): Draft => {
186 const cleaned = text
187 .trim()
188 .replace(/^```[a-z]*\n?|\n?```$/g, '')
189 .trim()
190
191 try {
192 const parsed = JSON.parse(cleaned.slice(cleaned.indexOf('{'), cleaned.lastIndexOf('}') + 1)) as Record<string, unknown>
193 if (typeof parsed.subject === 'string' && parsed.subject.trim() !== '') {
194 return { style, subject: parsed.subject.trim(), body: strings(parsed.body), footer: strings(parsed.footer) }
195 }
196 } catch {}
197
198 const [subject = '', ...rest] = cleaned.replace(/^["'`]|["'`]$/g, '').split('\n')
199 const lines = rest.join('\n').trim().split('\n')
200 const footerStart = lines.findIndex((_, index) => lines.slice(index).every(line => line === '' || isTrailer(line)))
201 const hasFooter = footerStart >= 0 && lines.slice(footerStart).some(isTrailer)
202
203 return {
204 style,
205 subject: subject.trim(),
206 body: hasFooter ? lines.slice(0, footerStart) : lines,
207 footer: hasFooter ? lines.slice(footerStart).filter(line => line !== '') : [],
208 }
209}
210
211const current = (state: Composer): Draft | undefined => state.drafts[state.index]
212
213const setDraft = ($: EngineInterface, change: (draft: Draft) => Draft) =>
214 update($, composer, state => {
215 const draft = current(state)
216 if (draft === undefined) {
217 return state
218 }
219
220 return { ...state, drafts: state.drafts.map((one, index) => (index === state.index ? change(one) : one)) }
221 })
222
223const generate = async ($: EngineInterface, style: Style, revise: boolean): Promise<string | undefined> => {
224 const run = ++runs.latest
225 const before = await read($, composer)
226 const previous = revise ? current(before) : undefined
227 const note = before.note.trim()
228 await update($, composer, state => ({ ...state, style, phase: 'writing' as const, error: '', isEditing: false }))
229 $.ui.status('commit-msg: writing…')
230
231 const fail = async (error: string): Promise<undefined> => {
232 if (run === runs.latest) {
233 await update($, composer, state => ({ ...state, phase: 'error' as const, error }))
234 }
235
236 return undefined
237 }
238
239 try {
240 if ((await $.process.run(['git', 'rev-parse', '--is-inside-work-tree'])).exitCode !== 0) {
241 return await fail('Not a git repository.')
242 }
243
244 const changes = await readChanges($)
245 if (changes.diff.trim() === '' && changes.stat.trim() === '' && changes.untracked.length === 0) {
246 return await fail('Nothing to commit: no staged or uncommitted changes.')
247 }
248
249 const subjects = await git($, ['log', '-n', String(RECENT_COMMITS), '--no-merges', '--format=%s']).catch(() => '')
250 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '(none)')
251 const reply = await write($, style, buildPrompt(changes, subjects, branch, note, previous))
252
253 if ('reason' in reply) {
254 return await fail(`Could not write a message (${reply.reason}).`)
255 }
256 if (run !== runs.latest) {
257 return undefined
258 }
259
260 const draft = parseReply(reply.text, style)
261 await update($, composer, state => {
262 const drafts = [...state.drafts, draft].slice(-MAX_DRAFTS)
263
264 return {
265 ...state,
266 phase: 'ready' as const,
267 note: state.note.trim() === note ? '' : state.note,
268 scope: [changes.isStaged ? 'staged changes' : 'all uncommitted changes', reply.isFromSession ? 'with session context' : 'from the diff alone'].join(', '),
269 drafts,
270 index: drafts.length - 1,
271 copiedAt: 0,
272 }
273 })
274
275 return format(draft)
276 } catch (error) {
277 return await fail(error instanceof Error ? error.message : String(error))
278 } finally {
279 if (run === runs.latest) {
280 $.ui.status(undefined)
281 }
282 }
283}
284
285const start = ($: EngineInterface, style: Style, revise = false): void => {
286 $.clock.after(0, () => {
287 void generate($, style, revise)
288 })
289}
290
291const pickStyle = async ($: EngineInterface, style: Style): Promise<void> => {
292 const state = await read($, composer)
293 if (state.phase === 'writing') {
294 return
295 }
296
297 const latest = state.drafts.findLastIndex(draft => draft.style === style)
298 if (latest >= 0) {
299 await update($, composer, one => ({ ...one, style, index: latest, phase: 'ready' as const, error: '', isEditing: false }))
300
301 return
302 }
303
304 start($, style)
305}
306
307const copy = async ($: EngineInterface, surface?: RenderSurface): Promise<void> => {
308 const draft = current(await read($, composer))
309 if (draft === undefined) {
310 return
311 }
312
313 const copied = await $.ui.copy({ text: format(draft), surface })
314 if (copied.isCopied) {
315 const now = await $.clock.now()
316 await update($, composer, state => ({ ...state, copiedAt: now }))
317 $.ui.toast('commit-msg: copied to your clipboard')
318 } else {
319 $.ui.toast(`commit-msg: not copied (${copied.reason})`)
320 }
321}
322
323const step = ($: EngineInterface, by: number) =>
324 update($, composer, state => {
325 const index = Math.min(state.drafts.length - 1, Math.max(0, state.index + by))
326 const draft = state.drafts[index]
327
328 return draft === undefined
329 ? state
330 : { ...state, index, style: draft.style, phase: 'ready' as const, error: '', isEditing: false, copiedAt: 0 }
331 })
332
333const setLine = ($: EngineInterface, field: 'body' | 'footer', index: number) => (value: string) =>
334 void setDraft($, one => ({ ...one, [field]: one[field].map((old, at) => (at === index ? value : old)) }))
335
336const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Commit message', focus: true })
337
338const parseArgs = (args: string): { style: Style | undefined; note: string } => {
339 const [first = '', ...rest] = args.trim().split(/\s+/)
340 const style = STYLE_ALIASES[first.toLowerCase()]
341
342 return style === undefined ? { style: undefined, note: args.trim() } : { style, note: rest.join(' ') }
343}
344
345export const register: Register = (on, options) => {
346 settings.model = String(options.model ?? 'sonnet') || 'sonnet'
347 settings.style = STYLE_ALIASES[String(options.style ?? '')] ?? 'simple'
348 settings.useSession = options.useSession !== false
349
350 on('session.start', async ($, e, next) => {
351 await $.command
352 .register({
353 name: COMMAND,
354 description: 'Write a commit message in a pane: pick a style, edit, regenerate, copy',
355 argumentHint: '[simple|body|full] [note]',
356 })
357 .catch(() => $.ui.toast('commit-msg: could not register /commit-msg'))
358
359 return next(e)
360 })
361
362 on('command.run', { command: COMMAND }, async ($, e) => {
363 const asked = parseArgs(e.args)
364 const before = await read($, composer)
365 const style = asked.style ?? (before.drafts.length > 0 ? before.style : settings.style)
366 await update($, composer, state => ({ ...state, style, note: asked.note, drafts: [], index: -1, copiedAt: 0 }))
367
368 const opened = await openPane($)
369 if (opened.isPlaced) {
370 start($, style)
371
372 return { text: `Writing a ${STYLE_LABELS[style].toLowerCase()} commit message in the pane.` }
373 }
374
375 const message = await generate($, style, false)
376 const after = await read($, composer)
377 if (message === undefined) {
378 return { text: after.error }
379 }
380
381 const copied = await $.ui.copy({ text: message })
382
383 return {
384 text: [
385 message,
386 '',
387 `For ${after.scope}. ${copied.isCopied ? 'Copied to your clipboard.' : `Not copied (${copied.reason}).`}`,
388 ].join('\n'),
389 }
390 }).catch(() => ({ text: 'commit-msg: something went wrong writing the message.' }))
391
392 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
393 const table = $.ui.resolve(e)
394 const { Box, Text, Button } = table
395 const Input = 'Input' in table ? table.Input : undefined
396 const state = await read($, composer)
397 const draft = current(state)
398 const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
399 const isWriting = state.phase === 'writing'
400 const canEdit = Input !== undefined && draft !== undefined && !isWriting
401 const isEditing = canEdit && state.isEditing
402 const isDocked = e.props.placement === 'dock'
403 const rule = isDocked ? <Text dimColor>{'─'.repeat(Math.max(10, width))}</Text> : null
404 const subjectLength = draft?.subject.trim().length ?? 0
405 const counter = (
406 <Text color={subjectLength > SUBJECT_LIMIT ? 'warning' : 'inactive'}>
407 {subjectLength}/{SUBJECT_LIMIT}
408 </Text>
409 )
410
411 const header = (
412 <Box flexDirection="row" justifyContent="space-between">
413 <Text bold color="claude">
414 📝 Commit message
415 </Text>
416 <Text dimColor>
417 {[state.scope, state.drafts.length > 1 ? `draft ${state.index + 1}/${state.drafts.length}` : '']
418 .filter(part => part !== '')
419 .join(' · ')}
420 </Text>
421 </Box>
422 )
423
424 const styles = (
425 <Box flexDirection="row" gap={1}>
426 <Text dimColor>Style</Text>
427 {STYLES.map((style, index) => (
428 <Button
429 key={`style:${style}`}
430 label={STYLE_LABELS[style]}
431 hotkey={String(index + 1)}
432 variant={style === state.style ? 'primary' : undefined}
433 dimColor={isWriting || style !== state.style}
434 onPress={() => pickStyle($, style)}
435 />
436 ))}
437 </Box>
438 )
439
440 const shown = draft === undefined ? undefined : { ...draft, body: tidyLines(draft.body), footer: tidyLines(draft.footer) }
441 const parts = shown === undefined ? null : CONVENTIONAL.exec(shown.subject.trim())
442 const error =
443 state.phase === 'error' ? (
444 <Text color="error" wrap="wrap">
445 ✗ {state.error}
446 </Text>
447 ) : null
448
449 const message =
450 shown === undefined ? (
451 (error ?? (
452 <Text dimColor>{isWriting ? '◌ Reading your changes and writing…' : 'Run /commit-msg to write a message.'}</Text>
453 ))
454 ) : (
455 <Box flexDirection="column">
456 <Box flexDirection="row" justifyContent="space-between" gap={1}>
457 {parts === null ? (
458 <Text bold wrap="wrap">
459 {shown.subject}
460 </Text>
461 ) : (
462 <Text wrap="wrap">
463 <Text bold color="claude">
464 {parts[1]}
465 </Text>
466 {parts[2] !== undefined && <Text color="suggestion">{parts[2]}</Text>}
467 {parts[3] !== undefined && (
468 <Text bold color="error">
469 !
470 </Text>
471 )}
472 <Text dimColor>{parts[4]}</Text>
473 <Text bold>{parts[5]}</Text>
474 </Text>
475 )}
476 {counter}
477 </Box>
478 {shown.style !== 'simple' && shown.body.length > 0 && (
479 <Box flexDirection="column" marginTop={1}>
480 {shown.body.map((line, index) => (
481 <Text key={`body:${index}`} wrap="wrap">
482 {line === '' ? ' ' : line}
483 </Text>
484 ))}
485 </Box>
486 )}
487 {shown.style === 'full' && (
488 <Box flexDirection="column" marginTop={1}>
489 {shown.footer.filter(line => line !== '').length === 0 ? (
490 <Text dimColor>No footer: edit to add a trailer such as Refs #123.</Text>
491 ) : (
492 shown.footer
493 .filter(line => line !== '')
494 .map((line, index) => (
495 <Text
496 key={`footer:${index}`}
497 color={/^BREAKING[ -]CHANGE:/.test(line) ? 'error' : 'suggestion'}
498 bold={/^BREAKING[ -]CHANGE:/.test(line)}
499 wrap="wrap"
500 >
501 {line}
502 </Text>
503 ))
504 )}
505 </Box>
506 )}
507 {isWriting && <Text dimColor>◌ Rewriting…</Text>}
508 {error}
509 </Box>
510 )
511
512 const editor =
513 !isEditing || Input === undefined || draft === undefined ? null : (
514 <Box flexDirection="column">
515 <Input
516 key="subject"
517 label="Subject "
518 value={draft.subject}
519 submitLabel="done"
520 autoFocus
521 onInput={value => void setDraft($, one => ({ ...one, subject: value }))}
522 onSubmit={value => void setDraft($, one => ({ ...one, subject: value }))}
523 />
524 <Box flexDirection="row" justifyContent="flex-end">
525 {counter}
526 </Box>
527 {draft.style !== 'simple' && (
528 <Box flexDirection="column" marginTop={1}>
529 {draft.body.map((line, index) => (
530 <Input
531 key={`body:${index}`}
532 label={index === 0 ? 'Body ' : ' '}
533 placeholder="(blank line)"
534 value={line}
535 onInput={setLine($, 'body', index)}
536 onSubmit={setLine($, 'body', index)}
537 />
538 ))}
539 <Button
540 key="add:body"
541 label="+ body line"
542 hotkey="a"
543 dimColor
544 onPress={() => setDraft($, one => ({ ...one, body: [...one.body, ''] }))}
545 />
546 </Box>
547 )}
548 {draft.style === 'full' && (
549 <Box flexDirection="column" marginTop={1}>
550 {draft.footer.map((line, index) => (
551 <Input
552 key={`footer:${index}`}
553 label={index === 0 ? 'Footer ' : ' '}
554 placeholder="Refs #123"
555 value={line}
556 onInput={setLine($, 'footer', index)}
557 onSubmit={setLine($, 'footer', index)}
558 />
559 ))}
560 <Button
561 key="add:footer"
562 label="+ footer line"
563 hotkey="f"
564 dimColor
565 onPress={() => setDraft($, one => ({ ...one, footer: [...one.footer, ''] }))}
566 />
567 </Box>
568 )}
569 </Box>
570 )
571
572 const guidance =
573 Input === undefined ? null : (
574 <Input
575 key="note"
576 label="Guidance "
577 placeholder="e.g. shorter, mention #690, focus on the API change"
578 value={state.note}
579 submitLabel="regenerate"
580 onInput={value => void update($, composer, one => ({ ...one, note: value }))}
581 onSubmit={value => {
582 void update($, composer, one => ({ ...one, note: value })).then(() => start($, state.style, true))
583 }}
584 />
585 )
586
587 return (
588 <Box flexDirection="column" width={width} gap={isDocked ? 1 : 0}>
589 {header}
590 {styles}
591 {rule}
592 {isEditing ? editor : message}
593 {rule}
594 {guidance}
595 <Box flexDirection="row" gap={1} flexWrap="wrap">
596 {draft !== undefined && (
597 <Button
598 key="copy"
599 label={state.copiedAt > 0 ? 'Copied ✓' : 'Copy'}
600 hotkey="c"
601 variant="primary"
602 onPress={press => copy($, press.surface)}
603 />
604 )}
605 <Button
606 key="regenerate"
607 label={isWriting ? 'Writing…' : 'Regenerate'}
608 hotkey="r"
609 onPress={() => {
610 if (!isWriting) {
611 start($, state.style, draft !== undefined)
612 }
613 }}
614 />
615 {canEdit && (
616 <Button
617 key="edit"
618 label={isEditing ? 'Done' : 'Edit'}
619 hotkey="e"
620 onPress={() => update($, composer, one => ({ ...one, isEditing: !one.isEditing, copiedAt: 0 }))}
621 />
622 )}
623 {state.drafts.length > 1 && (
624 <Button key="prev" label="‹ Prev" hotkey="p" dimColor={state.index === 0} onPress={() => step($, -1)} />
625 )}
626 {state.drafts.length > 1 && (
627 <Button
628 key="next"
629 label="Next ›"
630 hotkey="n"
631 dimColor={state.index === state.drafts.length - 1}
632 onPress={() => step($, 1)}
633 />
634 )}
635 </Box>
636 </Box>
637 )
638 })
639}
640types/index.d.ts 24 lines1export type Style = 'simple' | 'body' | 'full'
2
3export type Draft = { style: Style; subject: string; body: string[]; footer: string[] }
4
5export type Phase = 'idle' | 'writing' | 'ready' | 'error'
6
7export type Composer = {
8 phase: Phase
9 style: Style
10 note: string
11 scope: string
12 error: string
13 drafts: Draft[]
14 index: number
15 isEditing: boolean
16 copiedAt: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'commit-msg': { composer: Composer }
22 }
23}
24