このセッションが Edit・Write で変えたファイルと増減をペインに並べ、選ぶと差分を出す

English | 日本語
A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.
| Mod | What it does |
|---|---|
| branch-watch | Stops commits on a branch another session switched to, and lists each session's worktree and branch |
| push-gate | Holds git push and similar commands until a person approves |
| auto-worktree | Creates a worktree on the first prompt and starts the work there |
| session-diff | Shows the files this session edited and their diffs |
| pr-pane | Shows open pull requests and their CI results |
| side-question | /btw asks a side question without stopping the work |
| done-chime | Plays a sound when a long turn ends and when Claude waits for permission |
| ja-check | Tells you when a reply starts in English |
| usage-meter | Shows each turn's token counts and prompt cache rate |
claude plugin marketplace add yut0takagi/mods
claude plugin install branch-watch@yut0takagi-mods # one line per mod you want
Developed with Claude Code 2.1.289. Mods are an early access feature, so a Claude Code update may break them. The VS Code and Cursor extension draws no mod UI, so some mods are used differently there.
To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.
hooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { dirOf, KEEP, listText, parseNumstat, patchOf, patchText, pickOf, shortOf, statText } from './diff'
5
6const PANE = 'session-diff'
7const files = atom({ plugin: 'session-diff', key: 'files' } as const, [])
8const stats = atom({ plugin: 'session-diff', key: 'stats' } as const, {})
9const patch = atom({ plugin: 'session-diff', key: 'patch' } as const, null)
10
11/** 1 ファイルの、HEAD からのまだ commit していない差分。追跡していないファイルは空のファイルとの差分にする。 */
12async function gitDiff($: EngineInterface, path: string, options: readonly string[]): Promise<string> {
13 const cwd = dirOf(path)
14 const git = (args: readonly string[], timeoutMs = 10_000) =>
15 $.process.run(['git', ...args], { cwd, timeoutMs }).catch(() => undefined)
16 const tracked = await git(['ls-files', '--error-unmatch', '--', path], 5_000)
17
18 if (tracked?.exitCode !== 0) {
19 // --no-index は差分があると 1 で終わるので、終了コードは見ない
20 return (await git(['diff', '--no-index', ...options, '--', '/dev/null', path]))?.stdout ?? ''
21 }
22
23 const ran = await git(['diff', ...options, 'HEAD', '--', path])
24
25 if (ran?.exitCode === 0) {
26 return ran.stdout
27 }
28
29 // コミットのないリポジトリには HEAD がない。add 済みのファイルは空の tree と比べる(-w がないので何も書かない)
30 const empty = await git(['hash-object', '-t', 'tree', '/dev/null'], 5_000)
31
32 return empty?.exitCode === 0 ? ((await git(['diff', ...options, empty.stdout.trim(), '--', path]))?.stdout ?? '') : ''
33}
34
35async function refreshStat($: EngineInterface, path: string) {
36 const stat = parseNumstat(await gitDiff($, path, ['--numstat']))
37 await update($, stats, all => ({ ...all, [path]: stat }))
38}
39
40async function refreshAll($: EngineInterface) {
41 for (const path of await read($, files)) {
42 await refreshStat($, path)
43 }
44}
45
46async function openPatch($: EngineInterface, path: string) {
47 const next = patchOf(path, await gitDiff($, path, []))
48 await update($, patch, () => next)
49}
50
51/** 画面のないセッションで返す文。パスが引き当たればそのファイルの差分、でなければ一覧。 */
52async function answerOf($: EngineInterface, cwd: string, want: string, path: string | null) {
53 if (path !== null) {
54 const stdout = await gitDiff($, path, [])
55 await refreshStat($, path)
56
57 return patchText(path, stdout, (await read($, stats))[path] ?? null, cwd)
58 }
59
60 await refreshAll($)
61
62 return listText(await read($, files), await read($, stats), cwd, want === '' ? undefined : want)
63}
64
65export const register: Register = on => {
66 let cwd = ''
67
68 on('session.start', async ($, e, next) => {
69 cwd = e.cwd
70 await $.command.register({
71 name: 'session-diff',
72 description: 'このセッションで編集したファイルと差分を出す',
73 argumentHint: '[パス]',
74 })
75
76 return next(e)
77 })
78
79 on('command.run', { command: 'session-diff' }, async ($, e) => {
80 const want = e.args.trim()
81 const path = want === '' ? null : pickOf(await read($, files), want, cwd)
82
83 // VS Code・Cursor の拡張機能のセッションには画面がなく、ペインは開いても描かれない
84 // (ui.open は isPlaced を返すので当てにならない)。中身を返答の文で見せる
85 if ((await $.session.surfaces()).length === 0) {
86 return { text: await answerOf($, cwd, want, path) }
87 }
88
89 await $.ui.open({ id: PANE, title: '変更' })
90 await refreshAll($)
91
92 // パスを渡されたら、そのファイルの差分も開いておく
93 if (path !== null) {
94 await openPatch($, path)
95 } else if (want !== '') {
96 $.ui.toast(`このセッションで編集したファイルに ${want} はありません`)
97 }
98
99 // 会話に何も足さないよう、出力の文は返さない
100 return {}
101 })
102
103 on('tool.call', async ($, e, next) => {
104 const path = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
105 const ran = await next(e)
106
107 if (path === undefined || ran.deny !== undefined || ran.isError === true) {
108 return ran
109 }
110
111 await update($, files, list => [...list.filter(one => one !== path), path].slice(-KEEP))
112 await refreshStat($, path)
113
114 // 開いている差分のファイルを編集したら、差分も新しくする
115 if ((await read($, patch))?.path === path) {
116 await openPatch($, path)
117 }
118
119 return ran
120 })
121
122 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
123 const { Box, Button, Code, Text } = $.ui.resolve(e)
124 const list = await read($, files)
125 const all = await read($, stats)
126 const open = await read($, patch)
127
128 return (
129 <Box flexDirection="column">
130 <Box gap={1}>
131 <Text dimColor>このセッションで編集したファイル(まだ commit していない変更)</Text>
132 <Button key="refresh" label="更新" onPress={() => refreshAll($)} />
133 </Box>
134 {list.length === 0 && <Text dimColor>まだ何も編集していません。</Text>}
135 {list.map((path, i) => (
136 <Button
137 key={`file-${i}`}
138 plain
139 label={`${path in all ? statText(all[path] ?? null) : '…'} ${shortOf(path, cwd)}`}
140 onPress={() => openPatch($, path)}
141 />
142 ))}
143 {open !== null && (
144 <Box flexDirection="column" marginTop={1}>
145 <Text bold>{shortOf(open.path, cwd)}</Text>
146 {open.text === '' ? (
147 <Text dimColor>まだ commit していない変更はありません。</Text>
148 ) : (
149 <Code source={open.text} format="diff" path={open.path} />
150 )}
151 {open.isCut && <Text dimColor>長いので先頭だけ出しています。</Text>}
152 </Box>
153 )}
154 </Box>
155 )
156 })
157}
158hooks/diff.ts 119 lines1/** git の出力の読み方と、表示の整え方。$ に触れない部分。 */
2
3import type { Patch, Stat } from '../types'
4
5/** 覚えておくファイルの数。古いものから外す。 */
6export const KEEP = 100
7
8/** Code に渡せる長さ。 */
9const MAX_SOURCE = 10_000
10
11export const dirOf = (path: string) => path.slice(0, Math.max(1, path.lastIndexOf('/')))
12
13/** `git diff --numstat` の 1 行目。バイナリ(`-`)は 0 とみなす。 */
14export function parseNumstat(stdout: string): Stat {
15 const [added, removed] = stdout.trim().split('\n')[0]?.split('\t') ?? []
16
17 if (added === undefined || removed === undefined || added === '') {
18 return null
19 }
20
21 return { added: Number(added) || 0, removed: Number(removed) || 0 }
22}
23
24/** unified diff の `@@` の行から後。最後の改行は落とす。 */
25function bodyOf(stdout: string) {
26 const start = stdout.indexOf('@@')
27
28 return start === -1 ? '' : stdout.slice(start).replace(/\n$/, '')
29}
30
31/** unified diff を `@@` の行から始め、Code に渡せる長さで hunk の切れ目に切る。 */
32export function patchOf(path: string, stdout: string): Patch {
33 const body = bodyOf(stdout)
34
35 if (body.length <= MAX_SOURCE) {
36 return { path, text: body, isCut: false }
37 }
38
39 const cut = body.lastIndexOf('\n@@', MAX_SOURCE)
40
41 return { path, text: body.slice(0, cut > 0 ? cut : MAX_SOURCE), isCut: true }
42}
43
44/** セッションのディレクトリの中なら相対パスで。 */
45export const shortOf = (path: string, cwd: string) => (path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path)
46
47export const statText = (stat: Stat) => (stat === null ? '変更なし' : `+${stat.added} −${stat.removed}`)
48
49/**
50 * `/session-diff <パス>` のパスを、このセッションが編集したファイルの絶対パスに引き当てる。
51 * 絶対パス、セッションのディレクトリからの相対パス、末尾が 1 つだけ合うもの(`a.ts`)の順に探す。
52 * 編集していないファイルの中身は出さないよう、一覧にないものは null。
53 */
54export function pickOf(list: readonly string[], arg: string, cwd: string): string | null {
55 const want = arg.trim().replace(/^\.\//, '')
56
57 if (want === '') {
58 return null
59 }
60
61 const full = want.startsWith('/') ? want : `${cwd}/${want}`
62
63 if (list.includes(full)) {
64 return full
65 }
66
67 const tails = list.filter(path => path.endsWith(`/${want}`))
68
69 return tails.length === 1 ? (tails[0] ?? null) : null
70}
71
72const linesOf = (text: string) => (text === '' ? 0 : text.split('\n').length)
73
74/** 中身のどの ` の並びより長い囲み。Markdown のコードブロックを含む差分でも途中で閉じないように。 */
75const fenceOf = (text: string) => '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map(run => run.length + 1)))
76
77/**
78 * 画面のないセッションで `/session-diff` が返す、編集したファイルと増減の一覧。
79 * `missing` は引き当たらなかったパスで、渡すと一覧の前にそう書く。
80 */
81export function listText(list: readonly string[], all: Readonly<Record<string, Stat>>, cwd: string, missing?: string): string {
82 const head = missing === undefined ? [] : [`このセッションで編集したファイルに \`${missing}\` はありません。`, '']
83
84 if (list.length === 0) {
85 return [...head, 'このセッションではまだ何も編集していません。'].join('\n')
86 }
87
88 return [
89 ...head,
90 `このセッションで編集したファイル(まだ commit していない変更): ${list.length} 件`,
91 '',
92 ...list.map(path => `- ${path in all ? statText(all[path] ?? null) : '…'} \`${shortOf(path, cwd)}\``),
93 '',
94 '差分は `/session-diff <パス>` で出します。',
95 ].join('\n')
96}
97
98/**
99 * 画面のないセッションで `/session-diff <パス>` が返す、1 ファイルの unified diff。
100 * ペインと同じ長さで hunk の切れ目に切り、切ったときは何行のうち何行かを書く。
101 */
102export function patchText(path: string, stdout: string, stat: Stat, cwd: string): string {
103 const name = `\`${shortOf(path, cwd)}\``
104 const { text, isCut } = patchOf(path, stdout)
105
106 if (text === '') {
107 return `${name} にまだ commit していない変更はありません。`
108 }
109
110 const fence = fenceOf(text)
111 const lines = [`${name} の差分(まだ commit していない変更${stat === null ? '' : ` ${statText(stat)}`})`, '', `${fence}diff`, text, fence]
112
113 if (isCut) {
114 lines.push('', `長いので先頭だけ出しています(全 ${linesOf(bodyOf(stdout))} 行のうち ${linesOf(text)} 行)。`)
115 }
116
117 return lines.join('\n')
118}
119types/index.d.ts 23 lines1/** まだ commit していない変更の行数。`null` は変更がない(commit 済みか、元に戻した)。 */
2export type Stat = { added: number; removed: number } | null
3
4/** ペインで開いている差分。 */
5export type Patch = {
6 path: string
7 /** `@@` から始まる unified diff。 */
8 text: string
9 /** 長すぎて先頭だけにしたか。 */
10 isCut: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'session-diff': {
16 /** このセッションが編集したファイルの絶対パス。編集した順。 */
17 files: string[]
18 stats: Record<string, Stat>
19 patch: Patch | null
20 }
21 }
22}
23