hooks/register.tsx 476 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { DiffMode, ReviewView } from '../types'
5
6const PANE = 'human-review'
7const TITLE = 'Review'
8const TOOL = 'mcp__human-review-pane__show'
9const view = atom({ plugin: 'human-review-pane', key: 'view' } as const, null as ReviewView | null)
10const isBandHidden = atom({ plugin: 'human-review-pane', key: 'isBandHidden' } as const, false)
11const diffMode = atom({ plugin: 'human-review-pane', key: 'diffMode' } as const, 'unified' as DiffMode)
12const DIFF_MODE_STORE_KEY = 'diffMode'
13// split は左右それぞれにコードが収まる幅がないと読めないので、狭いときは unified で描く
14const SPLIT_MIN_COLUMNS = 100
15
16const STATUS_MARK = { done: '✓', current: '▶', todo: '·' } as const
17const PHASE_LABEL = { map: '🗺 マップ', chunk: '📖 解説中', summary: '📋 まとめ', finished: '✅ 完了' } as const
18const BAR_WIDTH = 20
19
20type FileDiff = { path: string; renamedFrom?: string; hunks: string[] }
21type Side = { no: number; text: string; kind: 'context' | 'removed' | 'added' }
22type SplitRow = { left?: Side; right?: Side } | { header: string }
23
24// git diff の出力から diff --git / index / rename 等のヘッダを除き、ファイルごとのハンクだけを取り出す。
25// Code の diff 表示は --- / +++ 以外のヘッダがあるとハンクとして読めず、ただのコードとして描いてしまう。
26const parseDiff = (diff: string, fallbackPath: string): FileDiff[] => {
27 const files: FileDiff[] = []
28 const lines = diff.split('\n')
29 let file: FileDiff | undefined
30 let isInHunk = false
31 const startFile = (path: string) => {
32 file = { path, hunks: [] }
33 files.push(file)
34 isInHunk = false
35 }
36
37 for (let i = 0; i < lines.length; i++) {
38 const line = lines[i] ?? ''
39 const following = lines[i + 1] ?? ''
40 if (line.startsWith('diff --git ')) {
41 startFile(line.split(' b/').pop() ?? fallbackPath)
42 continue
43 }
44 if (line.startsWith('--- ') && following.startsWith('+++ ')) {
45 const newPath = following.slice(4).replace(/^b\//, '')
46 const oldPath = line.slice(4).replace(/^a\//, '')
47 const path = newPath === '/dev/null' ? oldPath : newPath
48 if (file === undefined || file.hunks.length > 0) {
49 startFile(path)
50 } else {
51 file.path = path
52 }
53 i++
54 continue
55 }
56 if (line.startsWith('@@')) {
57 if (file === undefined) {
58 startFile(fallbackPath)
59 }
60 isInHunk = true
61 file?.hunks.push(line)
62 continue
63 }
64 if (file !== undefined && !isInHunk) {
65 if (line.startsWith('rename from ')) {
66 file.renamedFrom = line.slice('rename from '.length)
67 }
68 continue
69 }
70 if (isInHunk && file !== undefined) {
71 file.hunks.push(line === '' ? ' ' : line)
72 }
73 }
74
75 // 末尾の改行で生じた空のコンテキスト行を落とす
76 for (const one of files) {
77 while (one.hunks.at(-1) === ' ') {
78 one.hunks.pop()
79 }
80 }
81
82 return files
83}
84
85// 削除行の塊と直後の追加行の塊を、GitHub の split 表示と同じく上から順に左右へ対応させる
86const toSplitRows = (hunks: string[]): SplitRow[] => {
87 const rows: SplitRow[] = []
88 let oldNo = 0
89 let newNo = 0
90 let removed: Side[] = []
91 let added: Side[] = []
92 const flush = () => {
93 for (let i = 0; i < Math.max(removed.length, added.length); i++) {
94 rows.push({ left: removed[i], right: added[i] })
95 }
96 removed = []
97 added = []
98 }
99
100 for (const line of hunks) {
101 const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
102 if (header !== null) {
103 flush()
104 oldNo = Number(header[1])
105 newNo = Number(header[2])
106 rows.push({ header: line })
107 } else if (line.startsWith('-')) {
108 removed.push({ no: oldNo++, text: line.slice(1), kind: 'removed' })
109 } else if (line.startsWith('+')) {
110 added.push({ no: newNo++, text: line.slice(1), kind: 'added' })
111 } else if (!line.startsWith('\\')) {
112 flush()
113 const text = line.slice(1)
114 rows.push({ left: { no: oldNo++, text, kind: 'context' }, right: { no: newNo++, text, kind: 'context' } })
115 }
116 }
117 flush()
118
119 return rows
120}
121
122// 背景を塗るとハイライトされた文字が読みにくくなるため、行頭の縦バー・記号・行番号の色で変更行を示す。
123// 色は 256 色に丸められても変わらないよう、パレットにそのまま在るパステルピンクとミントを使う
124const SIDE_ACCENT = { context: undefined, removed: '#ff87af', added: '#87d7af' } as const
125// 行の背景は catppuccin Mocha の赤・緑を背景色に薄く溶かした色。
126// tmux 内では CLAUDE_CODE_TMUX_TRUECOLOR が無いと 256 色に丸められ、両方とも灰色に潰れる
127// 本体はペインを明るめの面の色で描くため、本文全体を端末(catppuccin Mocha)の背景色で塗り潰す
128const PANE_BACKGROUND = '#1e1e2e'
129const SIDE_BACKGROUND = { context: undefined, removed: '#45293a', added: '#283f33' } as const
130const SIDE_BAR = { context: ' ', removed: '▌', added: '▌' } as const
131const SIDE_SIGN = { context: ' ', removed: '-', added: '+' } as const
132
133type UnifiedRow = { oldNo?: number; newNo?: number; text: string; kind: Side['kind'] } | { header: string }
134
135// unified も Code の diff 表示に任せず split と同じ見た目で描く(テーマ次第で追加・削除の背景がほぼ見えないため)
136const toUnifiedRows = (hunks: string[]): UnifiedRow[] => {
137 const rows: UnifiedRow[] = []
138 let oldNo = 0
139 let newNo = 0
140 for (const line of hunks) {
141 const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
142 if (header !== null) {
143 oldNo = Number(header[1])
144 newNo = Number(header[2])
145 rows.push({ header: line })
146 } else if (line.startsWith('-')) {
147 rows.push({ oldNo: oldNo++, text: line.slice(1), kind: 'removed' })
148 } else if (line.startsWith('+')) {
149 rows.push({ newNo: newNo++, text: line.slice(1), kind: 'added' })
150 } else if (!line.startsWith('\\')) {
151 rows.push({ oldNo: oldNo++, newNo: newNo++, text: line.slice(1), kind: 'context' })
152 }
153 }
154
155 return rows
156}
157
158const INPUT_SCHEMA = {
159 type: 'object',
160 properties: {
161 title: { type: 'string', description: 'レビュー対象(例: "PR #123 ユーザー検索の追加")' },
162 phase: {
163 type: 'string',
164 enum: ['map', 'chunk', 'summary', 'finished'],
165 description: '段階。map: 全体マップ表示中 / chunk: チャンク解説中 / summary: まとめ表示・投稿確認中 / finished: 投稿済みまたは終了',
166 },
167 chunks: {
168 type: 'array',
169 description: '全チャンクの一覧と状態',
170 items: {
171 type: 'object',
172 properties: {
173 n: { type: 'number' },
174 kind: { type: 'string', description: '契約 / コア / 配線 / テスト / その他' },
175 label: { type: 'string', description: '対象ファイル・関数名' },
176 status: { type: 'string', enum: ['done', 'current', 'todo'] },
177 },
178 required: ['n', 'kind', 'label', 'status'],
179 },
180 },
181 current: { type: 'number', description: '表示中のチャンク番号。全体マップ表示時は 0' },
182 path: { type: 'string', description: '差分のファイルパス(言語判定に使う)。複数ファイルなら主たるもの' },
183 diff: {
184 type: 'string',
185 description: 'このチャンクの unified diff(@@ ハンク単位、途中で切らない)。全体マップ表示時は空文字',
186 },
187 refs: {
188 type: 'array',
189 description: '解説で参照した周辺コード',
190 items: {
191 type: 'object',
192 properties: {
193 path: { type: 'string' },
194 startLine: { type: 'number', description: 'source の1行目の行番号' },
195 source: { type: 'string', description: '参照箇所のコード(前後数行を含めて 30 行以内)' },
196 note: { type: 'string', description: '何のための参照か(例: 呼び出し元)' },
197 },
198 required: ['path', 'startLine', 'source'],
199 },
200 },
201 comments: {
202 type: 'array',
203 description: '記録済みコメント全件',
204 items: {
205 type: 'object',
206 properties: {
207 chunk: { type: 'number' },
208 location: { type: 'string', description: 'path:L42 など' },
209 text: { type: 'string' },
210 },
211 required: ['chunk', 'location', 'text'],
212 },
213 },
214 },
215 required: ['title', 'phase', 'chunks', 'current', 'path', 'diff', 'refs', 'comments'],
216}
217
218export const register: Register = on => {
219 on('session.start', async ($, e, next) => {
220 await $.command.register({
221 name: 'review-pane',
222 description: 'human-review のペインを開く',
223 })
224 const stored = await $.store.get(DIFF_MODE_STORE_KEY)
225 if (stored === 'unified' || stored === 'split') {
226 await update($, diffMode, () => stored)
227 }
228 await $.tool.register({
229 name: 'show',
230 description:
231 'human-review skill 専用。現在のチャンクの差分・参照コード・進捗・コメントをペインに色付きで表示する。' +
232 '呼ぶたびに表示内容を丸ごと置き換える。',
233 inputSchema: INPUT_SCHEMA,
234 })
235
236 return next(e)
237 })
238
239 on('command.run', { command: 'review-pane' }, async $ => {
240 await $.ui.open({ id: PANE, title: TITLE })
241
242 return { text: 'レビューペインを開きました。' }
243 })
244
245 on('tool.call', { tool: TOOL }, async ($, e) => {
246 // ツールの引数は e.input ではなく e の直下に載る(tool / tool_use_id / agentId は予約キー)
247 const { title, phase, chunks, current, path, diff, refs, comments } = e as unknown as ReviewView
248 const incoming: ReviewView = { title, phase, chunks, current, path, diff, refs, comments }
249 const previous = await read($, view)
250 await update($, view, () => incoming)
251 await update($, isBandHidden, () => false)
252 await $.ui.open({ id: PANE, title: TITLE })
253 // チャンクや段階が変わったときだけ先頭に戻す(c でコメントを足しただけなら読んでいた位置を保つ)
254 if (previous?.current !== incoming.current || previous?.phase !== incoming.phase) {
255 await $.ui.scroll({ in: PANE, to: 'start' })
256 }
257
258 return { result: `#${incoming.current} をペインに表示しました。` }
259 })
260
261 // 引数(差分全文など)を会話ログに展開させず、1行の要約に差し替える
262 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
263 const { Text } = $.ui.resolve(e)
264 const input = e.props.input as Partial<ReviewView>
265 const phase = input.phase ?? 'chunk'
266 const chunk = input.chunks?.find(one => one.n === input.current)
267 const target =
268 phase === 'chunk' && chunk !== undefined
269 ? `#${chunk.n}/${input.chunks?.length ?? 0} ${chunk.kind}: ${chunk.label}`
270 : PHASE_LABEL[phase]
271 const state = e.props.isErrored ? ' (表示に失敗)' : e.props.isRunning ? ' …' : ''
272
273 return (
274 <Text dimColor={!e.props.isErrored} color={e.props.isErrored ? 'error' : undefined} wrap="truncate-end">
275 📝 ペインに表示: {target}
276 {state}
277 </Text>
278 )
279 })
280
281 // 完了表示は次の入力まで残し、その後は帯を消す
282 on('prompt.submit', async ($, e, next) => {
283 const current = await read($, view)
284 if (current?.phase === 'finished') {
285 await update($, isBandHidden, () => true)
286 }
287
288 return next(e)
289 })
290
291 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
292 const current = await read($, view)
293 if (e.props.hasSurvey || current === null || (await read($, isBandHidden))) {
294 return next(e)
295 }
296
297 const { Box, Button, Text } = $.ui.resolve(e)
298 const mode = await read($, diffMode)
299 const total = current.chunks.length
300 const done = current.chunks.filter(chunk => chunk.status === 'done').length
301 const filled = total === 0 ? 0 : Math.round((done / total) * Math.min(total, BAR_WIDTH))
302 const empty = Math.min(total, BAR_WIDTH) - filled
303 const chunk = current.chunks.find(one => one.n === current.current)
304
305 return (
306 <Box flexDirection="column">
307 {/* コミット範囲(「 | origin/..」以降)はペインのヘッダーで見えるので、帯では外す */}
308 <Text bold wrap="truncate-end">
309 📝 {current.title.split(' | ')[0]}
310 </Text>
311 <Text wrap="truncate-end">
312 <Text color="success">{'■'.repeat(filled)}</Text>
313 <Text dimColor>{'□'.repeat(empty)}</Text> {done}/{total}
314 {' '}
315 <Text color="suggestion">{PHASE_LABEL[current.phase] ?? PHASE_LABEL.chunk}</Text>
316 {' '}💬 {current.comments.length}
317 {/* 長いチャンク名で他の情報が切れないよう、末尾に置く */}
318 {chunk !== undefined && current.phase === 'chunk' && (
319 <Text>
320 {' '}▶ #{chunk.n} {chunk.kind}: {chunk.label}
321 </Text>
322 )}
323 </Text>
324 <Box>
325 <Button
326 key="diff-mode"
327 label={mode === 'split' ? '⇆ split' : '☰ unified'}
328 onPress={async () => {
329 const toggled: DiffMode = mode === 'split' ? 'unified' : 'split'
330 await update($, diffMode, () => toggled)
331 await $.store.set(DIFF_MODE_STORE_KEY, toggled)
332 }}
333 />
334 <Text dimColor wrap="truncate-end">
335 {' '}n:次へ b:戻る skip N:ジャンプ c 内容:コメント map:一覧 comments:コメント一覧 done:終了
336 </Text>
337 </Box>
338 </Box>
339 )
340 })
341
342 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
343 const { Box, Text, Code } = $.ui.resolve(e)
344 const current = await read($, view)
345 const fill = { backgroundColor: PANE_BACKGROUND, minHeight: e.props.scroll.bodyRows, flexGrow: 1 } as const
346
347 if (current === null) {
348 return (
349 <Box {...fill}>
350 <Text dimColor>レビュー開始前です。/human-review で始めてください。</Text>
351 </Box>
352 )
353 }
354
355 const total = current.chunks.length
356 const isSplit = (await read($, diffMode)) === 'split' && e.props.bodyColumns >= SPLIT_MIN_COLUMNS
357 const sideColumns = Math.floor((e.props.bodyColumns - 1) / 2)
358 const gutterWidth = 7
359 const files = current.diff === '' ? [] : parseDiff(current.diff, current.path)
360
361 const drawSide = (side: Side | undefined, path: string) => (
362 <Box width={sideColumns} backgroundColor={side === undefined ? undefined : SIDE_BACKGROUND[side.kind]}>
363 <Box width={gutterWidth} flexShrink={0}>
364 {side !== undefined && (
365 <Text>
366 <Text color={SIDE_ACCENT[side.kind]}>{SIDE_BAR[side.kind]}</Text>
367 <Text color={SIDE_ACCENT[side.kind]} dimColor={side.kind === 'context'}>
368 {String(side.no).padStart(gutterWidth - 3)}
369 </Text>
370 <Text color={SIDE_ACCENT[side.kind]} bold>
371 {SIDE_SIGN[side.kind]}
372 </Text>
373 </Text>
374 )}
375 </Box>
376 {side === undefined || side.text === '' ? (
377 <Text> </Text>
378 ) : (
379 <Code source={side.text} path={path} wrap="truncate-end" />
380 )}
381 </Box>
382 )
383
384 return (
385 <Box flexDirection="column" gap={1} {...fill}>
386 <Box flexDirection="column">
387 <Text bold>{current.title}</Text>
388 {current.chunks.map(chunk => (
389 <Text
390 color={chunk.status === 'current' ? 'suggestion' : undefined}
391 bold={chunk.status === 'current'}
392 dimColor={chunk.status === 'done'}
393 wrap="truncate-end"
394 >
395 {STATUS_MARK[chunk.status]} #{chunk.n} {chunk.kind}: {chunk.label}
396 </Text>
397 ))}
398 </Box>
399
400 {files.map(file => (
401 <Box flexDirection="column">
402 <Text color="suggestion" bold wrap="truncate-end">
403 ── #{current.current}/{total} 差分: {file.path}
404 {file.renamedFrom === undefined ? '' : `(← ${file.renamedFrom})`}
405 </Text>
406 {file.hunks.length === 0 ? (
407 <Text dimColor>(内容の変更なし)</Text>
408 ) : isSplit ? (
409 toSplitRows(file.hunks).map(row =>
410 'header' in row ? (
411 <Text dimColor wrap="truncate-end">
412 {row.header}
413 </Text>
414 ) : (
415 <Box flexDirection="row">
416 {drawSide(row.left, file.path)}
417 <Text dimColor>│</Text>
418 {drawSide(row.right, file.path)}
419 </Box>
420 ),
421 )
422 ) : (
423 toUnifiedRows(file.hunks).map(row =>
424 'header' in row ? (
425 <Text dimColor wrap="truncate-end">
426 {row.header}
427 </Text>
428 ) : (
429 <Box flexDirection="row" backgroundColor={SIDE_BACKGROUND[row.kind]}>
430 <Box width={gutterWidth * 2 - 2} flexShrink={0}>
431 <Text>
432 <Text color={SIDE_ACCENT[row.kind]}>{SIDE_BAR[row.kind]}</Text>
433 <Text color={SIDE_ACCENT[row.kind]} dimColor={row.kind === 'context'}>
434 {(row.oldNo === undefined ? '' : String(row.oldNo)).padStart(gutterWidth - 3)}{' '}
435 {(row.newNo === undefined ? '' : String(row.newNo)).padStart(gutterWidth - 3)}
436 </Text>
437 <Text color={SIDE_ACCENT[row.kind]} bold>
438 {SIDE_SIGN[row.kind]}
439 </Text>
440 </Text>
441 </Box>
442 {row.text === '' ? <Text> </Text> : <Code source={row.text} path={file.path} />}
443 </Box>
444 ),
445 )
446 )}
447 </Box>
448 ))}
449
450 {current.refs.map(ref => (
451 <Box flexDirection="column">
452 <Text color="warning">
453 ── 参照: {ref.path}:{ref.startLine}
454 {ref.note === undefined ? '' : `(${ref.note})`}
455 </Text>
456 <Code source={ref.source} path={ref.path} startLine={ref.startLine} />
457 </Box>
458 ))}
459
460 {current.comments.length > 0 && (
461 <Box flexDirection="column">
462 <Text color="success" bold>
463 ── コメント({current.comments.length}件)
464 </Text>
465 {current.comments.map(comment => (
466 <Text>
467 [#{comment.chunk}] <Text dimColor>{comment.location}</Text> {comment.text}
468 </Text>
469 ))}
470 </Box>
471 )}
472 </Box>
473 )
474 })
475}
476