SLOPSHOPPER

code-peek

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

newpanerowscommandprocess
v0.2.0no licenseupdated 2026-10-07kuu13580/dotfiles/plugins/code-peek
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-peek
│ ┃ code-peek ✕ › fix the failing auth test and add an audit log call │ ┃ 直近の返答 (1) │ ┃ src/audit.ts ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /peek │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · code-peek
直近の返答 (1) src/audit.ts
README

code-peek

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 ファイルに絞る:

  1. 書かれたパスがカレントディレクトリから存在すればそれを使う
  2. git ls-files (未追跡を含む) をファイル名の pathspec で絞って候補を探す。全件を列挙しないので大規模リポジトリでも出力が膨らまない
  3. 候補が複数なら、行数が指定行に届かないファイルを除き、ブランチで変更されたファイル (origin/HEAD などとの merge-base 以降 + 作業ツリー) を優先する
  4. それでも複数なら、pane に候補を並べて選ばせる

ヘッダーを固定するため、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.md
Source 2 files
hooks/register.tsx 344 lines
1import { 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}
344
types/index.d.ts 21 lines
1// `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