返答中の path:line をクリックすると、そのファイルを pane に開いて該当行までスクロールする mod

Claude の返答に出てくる path:line をクリックすると、そのファイルを pane に開いて該当行までスクロールする mod。レビュー結果や調査結果の「どのコードの話か」を、エディタに切り替えずに確認できる。
| 操作 | 動作 |
|---|---|
返答中の src/app/api.ts:88 / api.ts:88-92 / api.ts#L88-L92 をクリック | ファイルを pane に開き、対象行を中央に表示し、黄色の印と黄色太字の行番号で示す。行番号の幅はファイルの総行数の桁数で固定する。ホイールで前後を読める。最上段には反転表示のヘッダー (相対パス・表示中の行範囲 / 総行数) を固定する |
返答中の src/app/api.ts / tsconfig.json (行番号なし) をクリック | ファイルを先頭から開く。/ を含むパスか、拡張子が許可リスト (ts json md yml py など) のファイル名が対象。JSON.parse や console.log のようなコードの記述と区別するため、リストにない拡張子のファイル名は対象外 |
| ctrl / cmd + クリック | mod を通さず、通常どおりリンクとして開く |
/peek | 直近 3 回の返答に出てきた path:line を一覧表示し、ボタンで選んで開く (最大 20 件、新しい返答を優先)。pane を開いたままにすると返答のたびに更新される |
/peek <path>:<line>[-<end>] (#L 形式も可) | 指定した箇所を直接開く |
| コード表示中の「← 一覧」 | 一覧に戻る |
返答中のリンクのクリックが mod に届くのは fullscreen 表示の端末だけ。それ以外では返答の描画に手を加えず、/peek の一覧から選ぶ (動作確認は端末のみ。Desktop は標準のファイルペインがあるため対象外)。一覧は返答の完了時に本文から抽出するので、ツール呼び出しの途中に書かれた文章の参照は含まれない。
ファイル名だけ (api.ts:88) でも開ける。クリック時に次の順で 1 ファイルに絞る:
git ls-files (未追跡を含む) をファイル名の pathspec で絞って候補を探す。全件を列挙しないので大規模リポジトリでも出力が膨らまないorigin/HEAD などとの merge-base 以降 + 作業ツリー) を優先するヘッダーを固定するため、pane のスクロールは engine に任せず mod 側で処理し、見えている行だけを描画する。ファイルの行はモジュール内に保持し、$.state にはパスと行番号だけを置く (スクロールのたびに state を読むため)。
コードフェンス内、既存の Markdown リンク、URL、path:line 以外の文字を含むインラインコードは書き換えない。
code-peek/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ ├── register.tsx # 返答の書き換え / パス解決 / pane 描画 / /peek
│ └── register.test.tsx # claude plugin test plugins/code-peek
├── types/index.d.ts # $.state の型 (code-peek.view)
├── CHANGELOG.md
└── README.mdhooks/register.tsx 344 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Candidate, Pos, Ref, View } from '../types'
5
6const PANE = 'code-peek'
7const HEADER_ROWS = 1
8const MAX_CANDIDATES = 20
9const RECENT_TURNS = 3
10const RECENT_REFS = 20
11const view = atom({ plugin: 'code-peek', key: 'view' } as const, null as View | null)
12const pos = atom({ plugin: 'code-peek', key: 'pos' } as const, { top: 0 } as Pos)
13const recent = atom({ plugin: 'code-peek', key: 'recent' } as const, [] as Ref[][])
14
15// `path:10-20` or GitHub's `path#L10-L20`
16const REF_SOURCE = String.raw`(\/?(?:[\w.@~-]+\/)*[\w.@-]*\.[A-Za-z]\w{0,9})(?::(\d+)(?:-(\d+))?|#L(\d+)(?:-L(\d+))?)`
17const REF = new RegExp(`^${REF_SOURCE}$`)
18// Without a line, a path needs a directory, or a file name needs a listed extension:
19// `JSON.parse` and `console.log` read like file names, so the extension is what tells them apart.
20const PATH_SOURCE = String.raw`\/?(?:[\w.@~-]+\/)+[\w.@-]*\.[A-Za-z]\w{0,9}`
21const FILE_EXTS = 'ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|toml|py|sh|css|scss|html|vue|go|rs|java|kt|rb|php|sql'
22const FILE_SOURCE = String.raw`[\w@-][\w.@-]*\.(?:${FILE_EXTS})(?!\w|\.\w)`
23const PATH_ONLY = new RegExp(`^(?:${PATH_SOURCE}|${FILE_SOURCE})$`)
24// Markdown links, URLs and inline code are matched first so a ref inside them is left alone.
25// A ref may only start at a token boundary, so a long token without one is not rescanned from every character.
26const TOKEN = new RegExp(
27 String.raw`(\[[^\]\n]*\]\([^)\n]*\))|(https?:\/\/\S+)|(\x60[^\x60\n]+\x60)|` +
28 String.raw`(?<![\w.@~/-])(?:${REF_SOURCE}|${PATH_SOURCE}|${FILE_SOURCE})`,
29 'g',
30)
31
32const refOf = (m: RegExpExecArray): Ref => {
33 const line = Number(m[2] ?? m[4])
34 const endLine = Number(m[3] ?? m[5])
35 return { path: m[1]!, line, ...(endLine > line ? { endLine } : {}) }
36}
37
38const refFrom = (s: string): Ref | undefined => {
39 const m = REF.exec(s)
40 return m ? refOf(m) : PATH_ONLY.test(s) ? { path: s, line: 0 } : undefined
41}
42
43const toHref = (ref: Ref) =>
44 `file:${ref.path}${ref.line ? `#L${ref.line}` : ''}${ref.endLine ? `-L${ref.endLine}` : ''}`
45
46export const parseHref = (href: string): Ref | undefined => {
47 const m = /^file:(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/.exec(href)
48 if (!m) return undefined
49 // A pressed link comes back as the surface resolved it (`file:///abs/path`, percent-encoded), not as written.
50 let path = m[1]!.replace(/^\/\/[^/]*/, '')
51 try {
52 path = decodeURIComponent(path)
53 } catch {}
54 const line = Number(m[2] ?? 0)
55 const endLine = m[3] ? Number(m[3]) : undefined
56 return { path, line, ...(endLine && endLine > line ? { endLine } : {}) }
57}
58
59type Linked = { text: string; hrefs: string[]; refs: Ref[] }
60// Every redraw of the transcript re-renders each message, while a finished message's text never changes.
61const linked = new Map<string, Linked>()
62
63export const linkify = (text: string): Linked => {
64 const hit = linked.get(text)
65 if (hit) return hit
66 const refs = new Map<string, Ref>()
67 const linkifySegment = (segment: string) =>
68 segment.replace(TOKEN, (whole: string, mdLink?: string, url?: string, code?: string) => {
69 if (mdLink || url) return whole
70 const ref = refFrom(code ? code.slice(1, -1) : whole)
71 if (!ref) return whole
72 const href = toHref(ref)
73 refs.set(href, ref)
74 return `[${whole}](${href})`
75 })
76 const out = text
77 .split(/(^```[\s\S]*?^```)/m)
78 .map((part, i) => (i % 2 === 1 ? part : linkifySegment(part)))
79 .join('')
80 const result = { text: out, hrefs: [...refs.keys()], refs: [...refs.values()] }
81 if (linked.size >= 200) linked.delete(linked.keys().next().value!)
82 linked.set(text, result)
83 return result
84}
85
86// Changed files come first, then candidates whose length can hold the line.
87export const rankCandidates = (candidates: string[], changed: Set<string>, lineCounts: Map<string, number>, line: number) => {
88 const fits = candidates.filter(c => (lineCounts.get(c) ?? Infinity) >= line)
89 const pool = fits.length > 0 ? fits : candidates
90 const touched = pool.filter(c => changed.has(c))
91 return touched.length > 0 ? touched : pool
92}
93
94const git = async ($: EngineInterface, argv: string[], cwd?: string) => {
95 const r = await $.process.run(['git', ...argv], cwd ? { cwd } : undefined)
96 return r.exitCode === 0 ? r.stdout : undefined
97}
98
99const changedFiles = async ($: EngineInterface, top: string) => {
100 const bases = await Promise.all(['origin/HEAD', 'origin/main', 'origin/master'].map(ref => git($, ['merge-base', 'HEAD', ref], top)))
101 const base = bases.find(Boolean)?.trim()
102 const [committed, working] = await Promise.all([
103 base ? git($, ['diff', '--name-only', `${base}...HEAD`], top) : '',
104 git($, ['diff', '--name-only', 'HEAD'], top),
105 ])
106 return new Set(`${committed ?? ''}\n${working ?? ''}`.split('\n').filter(Boolean))
107}
108
109const countLines = async ($: EngineInterface, path: string) => {
110 try {
111 return (await $.fs.read(path)).split('\n').length
112 } catch {
113 return 0
114 }
115}
116
117// The session's directory never changes, so neither does its repository root.
118let repoRoot: Promise<string | undefined> | undefined
119
120const rootOf = ($: EngineInterface) => (repoRoot ??= git($, ['rev-parse', '--show-toplevel']).then(s => s?.trim()))
121
122const under = (dir: string | undefined, path: string) => (dir && path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path)
123
124const resolve = async ($: EngineInterface, ref: Ref): Promise<Candidate[]> => {
125 const [top, exists] = await Promise.all([rootOf($), $.fs.exists(ref.path)])
126 if (exists) return [{ file: ref.path, display: under(top, ref.path) }]
127 if (!top) return []
128 // A pressed link comes back resolved against the session directory (`/cwd/api.ts`), so search by what was written.
129 const want = under(await $.session.cwd(), ref.path).replace(/^\.?\//, '')
130 // A pathspec keeps the output small; listing every file overflows the 4 MiB stdout cap in large repos.
131 const listed = await git($, ['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', want, `:(glob)**/${want}`], top)
132 const matches = [...new Set((listed ?? '').split('\0').filter(Boolean))].slice(0, MAX_CANDIDATES)
133 const toCandidate = (f: string): Candidate => ({ file: `${top}/${f}`, display: f })
134 if (matches.length <= 1) return matches.map(toCandidate)
135 const [changed, counts] = await Promise.all([
136 changedFiles($, top),
137 Promise.all(matches.map(f => countLines($, `${top}/${f}`))),
138 ])
139 const lineCounts = new Map(matches.map((f, i) => [f, counts[i]!]))
140 return rankCandidates(matches, changed, lineCounts, ref.line).map(toCandidate)
141}
142
143const setView = ($: EngineInterface, v: View) => update($, view, () => v)
144
145// Only the path goes into $.state: the pane reads state on every scroll tick, so the lines stay here.
146let cached: { file: string; lines: string[] } | undefined
147
148const linesOf = async ($: EngineInterface, file: string) => {
149 if (cached?.file !== file) cached = { file, lines: (await $.fs.read(file)).split('\n') }
150 return cached.lines
151}
152
153const show = async ($: EngineInterface, c: Candidate, ref: Ref) => {
154 let lines: string[]
155 try {
156 cached = undefined
157 lines = await linesOf($, c.file)
158 } catch (err) {
159 await setView($, { kind: 'error', message: `${c.display} を読めませんでした: ${String(err)}` })
160 return
161 }
162 if (ref.line > lines.length) {
163 await setView($, { kind: 'error', message: `${c.display} は ${lines.length} 行しかありません (指定: ${ref.line} 行目)` })
164 return
165 }
166 const endLine = Math.min(ref.endLine ?? ref.line, lines.length)
167 await update($, pos, (): Pos => (ref.line ? { center: Math.floor((ref.line + endLine) / 2) } : { top: 0 }))
168 await setView($, { kind: 'code', ...c, line: ref.line, endLine, total: lines.length })
169}
170
171const refKey = (r: Ref) => (r.line ? `${r.path}:${r.line}${r.endLine ? `-${r.endLine}` : ''}` : r.path)
172
173// Newest reply first; a ref already listed under a newer reply is dropped from the older ones.
174export const addRecent = (groups: Ref[][], refs: Ref[]): Ref[][] => {
175 const seen = new Set<string>()
176 let room = RECENT_REFS
177 return [refs, ...groups].slice(0, RECENT_TURNS).flatMap(g => {
178 const kept = g
179 .filter(r => {
180 const key = refKey(r)
181 return !seen.has(key) && !!seen.add(key)
182 })
183 .slice(0, room)
184 room -= kept.length
185 return kept.length > 0 ? [kept] : []
186 })
187}
188
189export const topOf = (p: Pos, total: number, rows: number) => {
190 const top = 'top' in p ? p.top : p.center - 1 - Math.floor(rows / 2)
191 return Math.max(0, Math.min(top, total - rows))
192}
193
194export const peek = async ($: EngineInterface, ref: Ref) => {
195 const found = await resolve($, ref)
196 if (found.length === 1) await show($, found[0]!, ref)
197 else if (found.length === 0) await setView($, { kind: 'error', message: `${ref.path} に一致するファイルが見つかりません` })
198 else await setView($, { kind: 'choose', ref, candidates: found })
199 await $.ui.open({ id: PANE, title: refKey(found.length === 1 ? { ...ref, path: found[0]!.display } : ref) })
200}
201
202export const register: Register = on => {
203 on('session.start', async ($, e, next) => {
204 await $.command.register({
205 name: 'peek',
206 description: '直近の返答の参照一覧、または path:line のファイルを pane に表示する',
207 argumentHint: '[<path>:<line>[-<end>] | <path>#L<line>[-L<end>]]',
208 })
209 return next(e)
210 })
211
212 on('command.run', { command: 'peek' }, async ($, e) => {
213 const arg = e.args.trim().replace(/^\x60|\x60$/g, '')
214 if (arg === '') {
215 await update($, view, () => null)
216 await $.ui.open({ id: PANE, title: 'code-peek' })
217 return {}
218 }
219 const ref = refFrom(arg)
220 if (!ref) return { text: `使い方: /peek (直近の参照一覧) | /peek <path>[:<line>[-<end>]] | /peek <path>#L<line>[-L<end>]` }
221 await peek($, ref)
222 return {}
223 })
224
225 on('turn.complete', async ($, e, next) => {
226 const result = await next(e)
227 if (e.agentId === undefined && e.reason === 'answer') {
228 const { refs } = linkify(e.answer)
229 if (refs.length > 0) await update($, recent, groups => addRecent(groups, refs))
230 }
231 return result
232 })
233
234 // Clicks only reach plugins in the fullscreen terminal; elsewhere the engine's own rendering is kept.
235 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
236 if (e.surface !== 'terminal' || !e.viewport?.isFullscreen) return next(e)
237 const linked = linkify(e.props.text)
238 if (linked.hrefs.length === 0) return next(e)
239 const { Markdown } = $.ui.resolve(e)
240 return (
241 <Markdown
242 key="code-peek-refs"
243 text={linked.text}
244 pressableLinks={linked.hrefs.slice(0, 256)}
245 onLinkPress={link => {
246 const ref = parseHref(link.href)
247 if (ref) void peek($, ref)
248 }}
249 />
250 )
251 })
252
253 // Scrolled here instead of by the engine so the header row never leaves the pane.
254 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
255 const v = await read($, view)
256 if (v?.kind !== 'code') return next(e)
257 const total = v.total
258 const rows = Math.max(1, e.bodyRows - HEADER_ROWS)
259 await update($, pos, (p): Pos => ({ top: topOf({ top: topOf(p, total, rows) + e.by }, total, rows) }))
260 return {}
261 })
262
263 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264 const { Box, Text, Code, Button } = $.ui.resolve(e)
265 const v = await read($, view)
266 if (!v) {
267 const groups = await read($, recent)
268 if (groups.length === 0) return <Text dimColor>直近の返答に path:line の参照はまだありません</Text>
269 return (
270 <Box flexDirection="column">
271 {groups.map((g, gi) => (
272 <Box key={`group-${gi}`} flexDirection="column" marginBottom={1}>
273 <Text dimColor>{`${gi === 0 ? '直近の返答' : `${gi} つ前の返答`} (${g.length})`}</Text>
274 {g.map((r, i) => (
275 <Button key={`ref-${gi}-${i}`} plain onPress={() => void peek($, r)}>
276 {refKey(r)}
277 </Button>
278 ))}
279 </Box>
280 ))}
281 </Box>
282 )
283 }
284 const back = (
285 <Button key="back" plain onPress={() => void update($, view, () => null)}>
286 ← 一覧
287 </Button>
288 )
289 if (v.kind !== 'code') {
290 return (
291 <Box flexDirection="column">
292 {back}
293 {v.kind === 'error' ? (
294 <Text color="red">{v.message}</Text>
295 ) : (
296 <>
297 <Text>{`${refKey(v.ref)} に一致するファイルが複数あります`}</Text>
298 {v.candidates.map((c, i) => (
299 <Button key={`pick-${i}`} plain onPress={() => void show($, c, v.ref)}>
300 {c.display}
301 </Button>
302 ))}
303 </>
304 )}
305 </Box>
306 )
307 }
308 let lines: string[]
309 try {
310 lines = await linesOf($, v.file)
311 } catch (err) {
312 return <Text color="red">{`${v.display} を読めませんでした: ${String(err)}`}</Text>
313 }
314 const rows = Math.max(1, e.props.scroll.bodyRows - HEADER_ROWS)
315 const top = topOf(await read($, pos), lines.length, rows)
316 const shown = lines.slice(top, top + rows)
317 const lo = Math.max(0, Math.min(v.line - 1 - top, shown.length))
318 const hi = Math.max(lo, Math.min(v.endLine - top, shown.length))
319 // Drawn here rather than by Code so the width stays at the file's digit count while scrolling.
320 const digits = String(lines.length).length
321 const gutter = (from: number, to: number, mark: string) =>
322 shown.slice(from, to).map((_, i) => `${mark} ${String(top + from + i + 1).padStart(digits)}`).join('\n')
323 const at = v.endLine > v.line ? `:${v.line}-${v.endLine}` : v.line ? `:${v.line}` : ''
324 const header = ` ${v.display}${at} (${top + 1}-${top + shown.length} / ${lines.length})`
325 return (
326 <Box flexDirection="column">
327 <Box flexDirection="row">
328 {back}
329 <Text inverse bold wrap="truncate-end">{header.padEnd(e.props.bodyColumns)}</Text>
330 </Box>
331 <Box flexDirection="row">
332 <Box flexDirection="column" width={digits + 3} minWidth={digits + 3} flexShrink={0}>
333 {lo > 0 && <Text dimColor>{gutter(0, lo, ' ')}</Text>}
334 {hi > lo && <Text color="yellow" bold>{gutter(lo, hi, '▌')}</Text>}
335 {hi < shown.length && <Text dimColor>{gutter(hi, shown.length, ' ')}</Text>}
336 </Box>
337 {/* Code trims blank lines at the edges of its source, which would push its rows out of line with the gutter. */}
338 <Code source={shown.map(l => (l.trim() === '' ? '\u00a0' : l)).join('\n')} path={v.display} wrap="truncate-end" />
339 </Box>
340 </Box>
341 )
342 })
343}
344types/index.d.ts 21 lines1// `line` 0 means the whole file, opened from the top with no line marked.
2export type Ref = { path: string; line: number; endLine?: number }
3
4// `file` is what $.fs reads, `display` the repo-relative path shown in the pane.
5export type Candidate = { file: string; display: string }
6
7export type View =
8 | ({ kind: 'code'; line: number; endLine: number; total: number } & Candidate)
9 | { kind: 'choose'; ref: Ref; candidates: Candidate[] }
10 | { kind: 'error'; message: string }
11
12// The pane scrolls itself so the header stays put; `center` holds until the first scroll knows the body height.
13export type Pos = { center: number } | { top: number }
14
15declare module 'claude-code' {
16 interface PluginState {
17 // `recent` holds the refs of the latest replies, newest first.
18 'code-peek': { view: View | null; pos: Pos; recent: Ref[][] }
19 }
20}
21