SLOPSHOPPER

commit-msg

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…

newpanecommandtoaststatusmodel
★ 1v0.4.1MITupdated 2026-10-08tomshaw/claude-code-plugins/mods/commit-msg
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commit-msg
│ ┃ Commit message ✕ › fix the failing auth test and add an audit log call │ ┃ 📝 Commit message │ ┃ ⏺ Read(src/auth.ts) │ ┃ Style [ Simple ] [ With body ] [ With footer ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Run /commit-msg to write a message. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Guidance : e.g. shorter, mention #690, focus ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ Regenerate ] › /commit-msg │ ⎿ commit-msg: Writing a simple commit message in the pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Commit message
📝 Commit message Style [ Simple ] [ With body ] [ With footer ] ──────────────────────────────────────────────────────── Run /commit-msg to write a message. ──────────────────────────────────────────────────────── Guidance : e.g. shorter, mention #690, focus on the API chan [ Regenerate ]
README

<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>

🚀 Install

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+.

🧩 Mods

Plugins built on function hooks: they change what Claude Code shows and does, live.

ModWhat it doesCommand
✍️proofreadFixes spelling and grammar in your prompt before Claude reads it, and highlights your prompts with the fixed words called out/proofread
📓work-journalDaily, 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-toolingRuns 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-msgWrites 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

🧠 Skills

Instructions Claude loads when your request matches them. Ask in plain words.

SkillWhat it doesTry
📊commit-summarySummarizes 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?"

📚 More

  • 📖 docs/mods.md: how the mods behave, setup notes and build your own
  • 🧠 docs/skills.md: what each skill does and how to ask for it
  • 🤝 CONTRIBUTING.md: where each kind of plugin goes, and how to add one
Source 2 files
hooks/register.tsx 640 lines
1import { 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}
640
types/index.d.ts 24 lines
1export 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