SLOPSHOPPER

finder-pane

Collects the local file and folder paths mentioned in each reply into a 'Files' pane beside the chat; click a row or press its digit to reveal it in Finder…

newpanecommandtoastprocess
v0.1.1MITupdated 2026-10-22i-noma-ru/finder-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · finder-pane
│ ┃ ファイル ✕ › fix the failing auth test and add an audit log call │ ┃ 返答にパスが出ると、ここに並びます。 │ ⏺ 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 │ │ › /files │ ⎿ finder-pane: ファイルペインを開きました。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ファイル
返答にパスが出ると、ここに並びます。
README

finder-pane

A Claude Code mod for macOS. Every time Claude finishes a reply, the mod scans the reply text for local file and folder paths, keeps the ones that actually exist, and lists them in a Files pane beside the chat. Click a row, or focus the pane (ctrl+x tab) and press its digit, and the file is revealed in Finder (open -R); a folder is opened directly. Bundles such as .app and symbolic links are only revealed (open -R), so nothing is launched.

日本語の説明は README.ja.md にあります。

Why

Claude often ends a reply with "open images/03_header.png and check it". In a terminal that path is just text. This mod turns it into something you can click.

Install

At the prompt of a terminal session:

/plugin install finder-pane --marketplace i-noma-ru/finder-pane

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). From a shell: claude plugin install finder-pane --marketplace i-noma-ru/finder-pane. In the desktop app, click + next to the prompt box, then Plugins, Add marketplace with i-noma-ru/finder-pane, then Add plugin.

What it picks up

  • Relative paths with at least one slash (docs/dev_log.md, out/review/), resolved against the session's working directory
  • Absolute paths (/Users/you/...) and ~/...
  • Japanese punctuation (、。「」()) and backticks count as word boundaries, so paths inside Japanese sentences are found

It skips URLs, globs containing *, bare file names without a slash, and anything that does not exist on disk. At most 9 rows per reply; a new reply replaces the list. Only the main conversation's replies are read, not subagent reports.

Commands

  • /files opens or closes the pane. The pane also opens by itself when a reply contains at least one existing path and the pane is closed.

Requirements

  • macOS (uses the open command). Not supported on Windows or Linux.
  • For the pane to open unasked, a terminal of 144 columns or more (110 if you opened it with /files before and have not closed it by hand since). Opening it with /files works at any width.
  • When another mod's pane is open, an unasked pane may land in a tab behind it; a toast then says "ファイル N 件 — /files で表示" (N files, show with /files).

Files

  • hooks/register.tsx: hooks (turn.complete, ui.render for the pane, /files)
  • hooks/logic.ts: pure functions that extract and resolve paths
  • types/index.d.ts: the $.state contract
  • tests/finder-pane.test.tsx: tests for claude plugin test

Status

Version 0.1.0. Validated with claude plugin validate; the path extractor is unit-checked against a sample reply. Feedback and issues are welcome.

License

MIT

Source 3 files
hooks/register.tsx 137 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FinderRow } from '../types'
5import { baseName, extractCandidates, isBundleName, resolveAbs } from './logic'
6
7const PANE_ID = 'finder-pane'
8const PANE_TITLE = 'ファイル'
9const rows = atom({ plugin: 'finder-pane', key: 'rows' } as const, [])
10const cwdAtom = atom({ plugin: 'finder-pane', key: 'cwd' } as const, '')
11
12// $ を受け取る関数はモジュール直下に置く(フックの中で作った関数へ渡すと validate が落とす)
13
14async function openPane($: EngineInterface): Promise<boolean> {
15  try {
16    const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
17    return opened.isPlaced
18  } catch {
19    // 画面の無い実行(claude -p)では開けない
20    return false
21  }
22}
23
24/** 返答の本文から実在するパスだけを集めて行にする */
25async function collect($: EngineInterface, answer: string): Promise<FinderRow[]> {
26  const cwd = await read($, cwdAtom)
27  const home = (await $.env.get('HOME')) ?? ''
28  const found: FinderRow[] = []
29  for (const shown of extractCandidates(answer)) {
30    const abs = resolveAbs(shown, cwd, home)
31    try {
32      if (!(await $.fs.exists(abs))) continue
33      const stat = await $.fs.stat(abs)
34      // バンドル・リンクは open で起動しうるので、フォルダとして開かず open -R で見せるだけにする
35      const isDir =
36        stat.kind === 'dir' &&
37        !stat.isLink &&
38        !isBundleName(abs) &&
39        !(await $.fs.exists(`${abs}/Contents/Info.plist`))
40      found.push({ abs, shown, isDir })
41    } catch {
42      // 読めない場所は載せない
43    }
44  }
45  return found
46}
47
48/** Finder で見せる: ファイルは選択状態で親フォルダを、フォルダはそのフォルダを開く */
49async function reveal($: EngineInterface, row: FinderRow): Promise<void> {
50  const argv = row.isDir ? ['open', row.abs] : ['open', '-R', row.abs]
51  try {
52    const ran = await $.process.run(argv, { timeoutMs: 10000 })
53    if (ran.exitCode !== 0) $.ui.toast(`Finder で開けません: ${baseName(row.abs)}`)
54  } catch {
55    $.ui.toast(`Finder で開けません: ${baseName(row.abs)}`)
56  }
57}
58
59export const register: Register = on => {
60  on('session.start', async ($, e, next) => {
61    await update($, cwdAtom, () => e.cwd)
62    try {
63      await $.command.register({
64        name: 'files',
65        description: 'ファイルペインを開く/閉じる(返答に出たパスを Finder で開く)',
66      })
67    } catch {
68      // 登録できなくても、セッションの開始は止めない
69    }
70    return next(e)
71  })
72
73  on('command.run', { command: 'files' }, async $ => {
74    const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
75    if (pane !== undefined && pane.isShown) {
76      await $.ui.close({ id: PANE_ID })
77      return { text: 'ファイルペインを閉じました。' }
78    }
79    // 後ろのタブにいるペインは、閉じて開き直すと前に出る(再 open で前に出るかは API に書かれていない)
80    if (pane !== undefined) await $.ui.close({ id: PANE_ID })
81    const placed = await openPane($)
82    return {
83      text: placed
84        ? 'ファイルペインを開きました。'
85        : 'ファイルペインは開きましたが、この画面には置けません(ターミナルが狭い、または画面が無い)。',
86    }
87  })
88
89  // 主ループの返答だけ(サブエージェントの報告は拾わない)
90  on('turn.complete', async ($, e, next) => {
91    const done = await next(e)
92    if (e.agentId !== undefined || e.isAborted || e.answer === '') return done
93    const found = await collect($, e.answer)
94    if (found.length === 0) return done
95    await update($, rows, () => found)
96    const isUp = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
97    if (!isUp) await openPane($)
98    else $.ui.invalidate('ui.render')
99    // 頼まれずに開いたペインは他の MOD のペインの後ろのタブに入ることがあり、前に出す API は無い
100    const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
101    if (pane !== undefined && (!pane.isShown || !pane.isPlaced)) {
102      $.ui.toast(`ファイル ${found.length} 件 — /files で表示`)
103    }
104    return done
105  })
106
107  // matcher はリテラルで書く(定数だと validate が requestId=? と読む)
108  on('ui.render', { component: 'Pane', requestId: 'finder-pane' }, async ($, e) => {
109    const { Box, Text, Button } = $.ui.resolve(e)
110    const list = await read($, rows)
111    const width = Math.max(20, (e.viewport?.columns ?? 40) - 6)
112
113    return (
114      <Box flexDirection="column">
115        {list.length === 0 && <Text dimColor>返答にパスが出ると、ここに並びます。</Text>}
116        {list.map((row, index) => {
117          const label = row.shown.length > width ? `…${row.shown.slice(-(width - 1))}` : row.shown
118          return (
119            <Button
120              key={`row${index}`}
121              hotkey={String(index + 1)}
122              plain
123              onPress={() => {
124                void reveal($, row)
125              }}
126            >
127              {label}
128              {row.isDir ? <Text dimColor>/</Text> : null}
129            </Button>
130          )
131        })}
132        {list.length > 0 && <Text dimColor>数字キーかクリックで Finder に表示</Text>}
133      </Box>
134    )
135  })
136}
137
hooks/logic.ts 54 lines
1// 返答の本文からローカルのパス候補を拾う(純粋関数・テストしやすいよう $ を持たない)
2
3/** パスらしい語: 絶対パス、~/、または「a/b.ext」「a/b/」の形。全角文字を含む名前も拾う */
4const CANDIDATE = /(?:^|[\s((「`'"==::])((?:~\/|\/)?(?:[^\s`'"()()「」<>|*?::,、。]+\/)+[^\s`'"()()「」<>|*?::,、。]*|\/[^\s`'"()()「」<>|*?::,、。]+)/g
5
6/** 末尾の句読点・閉じ括弧・読点を落とす */
7function trimTail(raw: string): string {
8  return raw.replace(/[.,、。))」』]+$/u, '')
9}
10
11/** 候補を抜き出し、重複を除いて順に返す(最大 limit 件) */
12export function extractCandidates(text: string, limit = 9): string[] {
13  const seen = new Set<string>()
14  const out: string[] = []
15  // URL と、* を含む語(glob)は丸ごと外してから探す(途中で切れた断片を拾わないため)
16  // 日本語の文は空白で切れないので、句読点・括弧・バッククォートも語の切れ目として扱う
17  const W = '[^\\s、。「」()()`\'"]'
18  const cleaned = text
19    .replace(new RegExp(`[a-z]+:\\/\\/${W}+`, 'g'), ' ')
20    .replace(new RegExp(`${W}*\\*${W}*`, 'g'), ' ')
21  for (const m of cleaned.matchAll(CANDIDATE)) {
22    const raw = trimTail(m[1] ?? '')
23    if (raw.length < 3) continue
24    if (raw.startsWith('http') || raw.includes('://')) continue
25    if (raw.includes('*')) continue
26    if (raw.endsWith('/') && raw.split('/').length <= 2 && !raw.startsWith('/')) continue
27    if (seen.has(raw)) continue
28    seen.add(raw)
29    out.push(raw)
30    if (out.length >= limit) break
31  }
32  return out
33}
34
35/** cwd を基準に絶対パスへ。~ はホームへ */
36export function resolveAbs(raw: string, cwd: string, home: string): string {
37  if (raw.startsWith('/')) return raw
38  if (raw.startsWith('~/')) return `${home}/${raw.slice(2)}`
39  return `${cwd.replace(/\/$/, '')}/${raw.replace(/^\.\//, '')}`
40}
41
42const BUNDLE_EXT = /\.(app|appex|bundle|framework|kext|mpkg|pkg|plugin|prefpane|qlgenerator|saver|service|workflow|xpc)$/i
43
44/** 中身はフォルダでも、open に渡すと起動・インストールしてしまうもの */
45export function isBundleName(p: string): boolean {
46  return BUNDLE_EXT.test(p.replace(/\/$/, ''))
47}
48
49/** 末尾の名前だけ(ペインが狭いときの表示用) */
50export function baseName(p: string): string {
51  const parts = p.replace(/\/$/, '').split('/')
52  return parts[parts.length - 1] ?? p
53}
54
types/index.d.ts 16 lines
1/** ペインの 1 行=返答に出た実在するパス */
2export type FinderRow = {
3  /** 絶対パス(open -R に渡す) */
4  abs: string
5  /** 返答に書かれていた形(表示用) */
6  shown: string
7  /** フォルダなら open、ファイルなら open -R */
8  isDir: boolean
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'finder-pane': { rows: FinderRow[]; cwd: string }
14  }
15}
16