このセッションで変更したファイルと実行したコマンドを横のパネルに一覧で出す

Claude Codeが実行したコマンドと変更したファイルを、色分けした一覧としてパネルに出すmodです。
Claude Codeに作業を任せていると、コマンドの実行やファイルの変更が会話の中を流れていき、いま何をしているのか、どこで失敗したのかを後から追いにくくなります。work-logはこれを1枚のパネルにまとめます。
✗にしますmodの仕組みはClaude Codeの早期提供の機能で、リリースの間で予告なく変わることがあります。Claude Code 2.1.296で動作を確かめています。
Claude Codeの中で次を実行します。
/plugin install work-log --marketplace BoxPistols/claude-work-log
入れたあとは/work-logでパネルを開きます。
全画面の表示で端末が110列以上あると、パネルは会話の横に端末の幅の40%(36〜96列)で開きます。それ以外では入力欄の上に開きます。広く見たいときは/work-log 100のように列数を付けて開き直してください。パネルの幅が80列以上(入力欄の上では60列以上)あると、コマンドとファイルの一覧を左右に並べます。
コマンドにトークンやパスワードが含まれていると、そのまま画面に出てしまいます。work-logは記録する前に次の形式を•••に置き換えます。
Bearer、Basic、tokenの後ろの値--password=や--tokenのような指定の値TOKEN、SECRET、PASSWORD、API_KEYなどを含む代入の値結果は先頭3行・300字までしか持ちません。
正規表現による置き換えなので、すべての秘密の値を見つけられるわけではありません。上に挙げた形式はhooks/mask.test.tsで固定しています。それ以外の形式は画面に出ることがあるので、画面を共有するときは注意してください。
work-logはファイルの読み書きや外部への通信をしません。claude plugin validate .で、呼んでいる機能の一覧を確かめられます。
hooks/
├── register.tsx 本体。イベントの登録とパネルの描画
├── kind.ts コマンドの種類分けと、種類ごとの色・記号
├── mask.ts 伏せ字
├── filter.ts 一覧の絞り込みと並び順
└── width.ts 端末での表示幅の計算
開発中は、このフォルダを置いた場所をClaude Codeの設定のCLAUDE_CODE_PLUGIN_DIRSに足すと、保存するたびに読み込み直されます。
確認には次の3つを使います。
claude plugin validate . # 設定と本体を読み、登録しているイベントと呼んでいる機能を一覧にする
claude plugin test . # *.test.ts をClaude Code本体の上で動かす
tsc -p . # 型検査。TypeScript 5.9で通ることを確かめている
tscに使う型の定義とtsconfig.jsonは、Claude Codeがmodを一度読み込むと.claude-plugin/types/に置きます。手元でTypeScript 5.6を使うと、テストのファイルで型が深すぎるという理由で通りません。
初めて手を入れる人向けのissueにgood first issueを付けています。どれもファイル1つとテスト1つで完結する大きさです。
MIT
hooks/register.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CommandEntry, FileEntry, Filter, Order } from '../types'
5import { latest, pickCommands, pickFiles } from './filter'
6import { classify, KIND_COLOR, KIND_LABEL, KIND_MARK, KIND_ORDER } from './kind'
7import { mask } from './mask'
8import { columnsOf, fit } from './width'
9
10const PANE = 'work-log'
11const TITLE = '作業の記録'
12// 横に置いたパネルが端末の幅に占める割合と、その列数の下限・上限
13const SHARE = 0.4
14const MIN_COLUMNS = 36
15const MAX_COLUMNS = 96
16// `/work-log 80`のように列数を指定されたときに受け付ける範囲
17const ARG_MIN = 20
18const ARG_MAX = 200
19// コマンドとファイルの一覧を左右に並べる幅。これ未満では上下に積む
20// 入力欄の上は高さが少なく、積むと一覧が半分になるので、横に置くときより狭い幅から左右に並べる
21const WIDE = { dock: 80, inline: 60 } as const
22// 入力欄の上に置くときの高さ。見出し2行の下に一覧が出る分を取る。右に置くときは使われない
23const ROWS = 12
24// 再読み込みしても記録が消えないよう$.stateに持つ
25const files = atom({ plugin: 'work-log', key: 'files' } as const, [] as FileEntry[])
26const commands = atom({ plugin: 'work-log', key: 'commands' } as const, [] as CommandEntry[])
27const selected = atom({ plugin: 'work-log', key: 'selected' } as const, '')
28const filter = atom({ plugin: 'work-log', key: 'filter' } as const, '' as Filter)
29const query = atom({ plugin: 'work-log', key: 'query' } as const, '')
30const order = atom({ plugin: 'work-log', key: 'order' } as const, 'new' as Order)
31
32// 長いパスは末尾の2階層だけ見せる
33const shortPath = (path: string) => path.split('/').slice(-2).join('/')
34// 詳細に出す行数の上限
35const headLines = (text: string, max: number) => text.split('\n').filter(Boolean).slice(0, max)
36
37// 状態は記号と色で示す
38const MARK = { runs: '…', ok: '✓', failed: '✗' } as const
39// 実行中の行だけ回る記号にする。動かすのはここ1か所に限る
40const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
41const SPIN_MS = 100
42let running = 0
43let frame = 0
44let spinner: { cancel: () => void } | undefined
45// 見出しの検索欄を出すのに要る、一覧の最低行数
46const MIN_LIST = 2
47const COLOR = { runs: 'warning', ok: 'inactive', failed: 'error' } as const
48
49export const register: Register = on => {
50 on('session.start', async ($, e, next) => {
51 await $.command.register({
52 name: 'work-log',
53 description: '変更したファイルと実行したコマンドをパネルに出す',
54 })
55
56 return next(e)
57 })
58
59 on('command.run', { command: 'work-log' }, async ($, e) => {
60 // 引数に列数があればそれを使う。無ければ端末の幅の40%を36〜96列に収める。幅が届かないときは80列とみなす
61 const asked = Number.parseInt(e.args.trim(), 10)
62 const columns = Number.isInteger(asked)
63 ? Math.min(ARG_MAX, Math.max(ARG_MIN, asked))
64 : Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.floor((e.presentation?.columns ?? 80) * SHARE)))
65 const { isPlaced } = await $.ui.open({ id: PANE, title: TITLE, columns, rows: ROWS })
66
67 return { text: isPlaced ? '作業の記録を開きました' : '作業の記録は画面の幅が空くと表示されます' }
68 })
69
70 on('tool.call', async ($, e, next) => {
71 if (e.tool === 'Edit' || e.tool === 'Write') {
72 const ran = await next(e)
73 if (ran.deny === undefined && ran.isError !== true) {
74 const path = e.file_path
75 await update($, files, list => {
76 const found = list.find(one => one.path === path)
77 const rest = list.filter(one => one.path !== path)
78 return [...rest, { path, tool: e.tool, count: (found?.count ?? 0) + 1 }].slice(-100)
79 })
80 }
81 return ran
82 }
83
84 if (e.tool === 'Bash') {
85 const entry: CommandEntry = {
86 id: e.tool_use_id,
87 command: mask(e.command),
88 description: mask(e.description ?? ''),
89 output: '',
90 kind: classify(e.command),
91 state: 'runs',
92 }
93 await update($, commands, list => [...list, entry].slice(-100))
94 // 実行中のコマンドがある間だけ描き直す。無くなったら止める
95 running += 1
96 try {
97 spinner ??= $.clock.every(SPIN_MS, () => {
98 frame = (frame + 1) % SPIN.length
99 $.ui.invalidate('ui.render')
100 })
101 } catch {
102 // 時計が使えない環境では止まった記号のまま記録だけ続ける
103 }
104 const ran = await next(e).finally(() => {
105 running -= 1
106 if (running === 0) {
107 spinner?.cancel()
108 spinner = undefined
109 }
110 })
111 const state: CommandEntry['state'] = ran.deny === undefined && ran.isError !== true ? 'ok' : 'failed'
112 // 結果は先頭3行・300字だけを伏せ字にしてから持つ
113 const output = mask(headLines(ran.deny ?? ran.text ?? '', 3).join('\n').slice(0, 300))
114 await update($, commands, list =>
115 list.map(one => (one.id === entry.id ? { ...one, state, output } : one)),
116 )
117 return ran
118 }
119
120 return next(e)
121 }).catch(($, e, next) => next(e)) // 記録に失敗してもツールの実行は止めない
122
123 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
124 const elements = $.ui.resolve(e)
125 const { Box, Button, Text } = elements
126 // スマートフォンのアプリには入力欄が無いので、そこでは検索欄を出さない
127 const Input = 'Input' in elements ? elements.Input : undefined
128 const fileList = await read($, files)
129 const commandList = await read($, commands)
130 const open = await read($, selected)
131 const picked = await read($, filter)
132 const words = await read($, query)
133 const sort = await read($, order)
134 // 同じ行をもう一度押すと閉じる
135 const toggle = (id: string) => () => update($, selected, now => (now === id ? '' : id))
136 // 同じ凡例をもう一度押すと絞り込みを解く
137 const pick = (next: Filter) => () => update($, filter, now => (now === next ? '' : next))
138 const columns = Math.max(20, e.props.bodyColumns)
139 const stateMark = (state: CommandEntry['state']) => (state === 'runs' ? SPIN[frame] : MARK[state])
140
141 // 詳細を開いているときは一覧の代わりに詳細を出す
142 const openCommand = commandList.find(one => `c:${one.id}` === open)
143 if (openCommand) {
144 return (
145 <Box flexDirection="column">
146 <Button plain key="close" onPress={toggle(open)}>
147 <Text color={KIND_COLOR[openCommand.kind ?? 'other']}>{KIND_MARK[openCommand.kind ?? 'other']}</Text>{' '}
148 {openCommand.description || '説明なし'}{' '}
149 <Text color={COLOR[openCommand.state]}>{stateMark(openCommand.state)}</Text>
150 <Text dimColor> 閉じる</Text>
151 </Button>
152 {headLines(openCommand.command, 8).map(line => (
153 <Text color="suggestion">{line}</Text>
154 ))}
155 {headLines(openCommand.output, 3).map(line => (
156 <Text dimColor>{line}</Text>
157 ))}
158 </Box>
159 )
160 }
161 const openFile = fileList.find(one => `f:${one.path}` === open)
162 if (openFile) {
163 return (
164 <Box flexDirection="column">
165 <Button plain key="close" onPress={toggle(open)}>
166 {shortPath(openFile.path)}
167 <Text dimColor> 閉じる</Text>
168 </Button>
169 <Text color="suggestion">{openFile.path}</Text>
170 <Text dimColor>{openFile.tool}で{openFile.count}回変更</Text>
171 </Box>
172 )
173 }
174
175 // 端末全体ではなくパネル本体の行数と幅で割り付ける
176 const rows = Math.max(1, e.props.scroll.bodyRows)
177 const isWide = columns >= WIDE[e.props.placement]
178 const width = isWide ? Math.floor((columns - 2) / 2) : columns
179 // 見出しは流れと凡例(狭いときは2行)、検索欄。低いときは検索欄を隠して一覧を残す
180 const headRows = isWide ? 1 : 2
181 const hasSearch = rows - headRows - 1 >= (isWide ? MIN_LIST : MIN_LIST * 2)
182 const listRows = rows - headRows - (hasSearch ? 1 : 0)
183 const room = Math.max(1, isWide ? listRows : Math.floor(listRows / 2))
184
185 // 流れ: 1コマンド1マスを古い順に並べる。失敗は赤の✗
186 const flow = commandList.slice(-Math.max(1, width - 5)).map(one =>
187 one.state === 'failed' ? <Text color="error">✗</Text>
188 : one.state === 'runs' ? <Text color={KIND_COLOR[one.kind ?? 'other']}>…</Text>
189 : <Text color={KIND_COLOR[one.kind ?? 'other']}>■</Text>,
190 )
191 // 凡例は押すとその種類だけに絞る。選んでいない種類は薄くする
192 const failures = commandList.filter(one => one.state === 'failed').length
193 const counts = [
194 ...KIND_ORDER.map(kind => ({
195 key: kind as Filter,
196 mark: KIND_MARK[kind],
197 color: KIND_COLOR[kind],
198 label: KIND_LABEL[kind],
199 count: commandList.filter(one => (one.kind ?? 'other') === kind).length,
200 })),
201 { key: 'failed' as Filter, mark: '✗', color: 'error', label: '失敗', count: failures },
202 ].filter(one => one.count > 0 || picked === one.key)
203 // 名前つきで1行に収まらないときは記号と件数だけにして、見出しを折り返さない
204 const fullWidth = counts.reduce((sum, one) => sum + columnsOf(`${one.mark} ${one.label} ${one.count}`) + 2, -2)
205 const isShort = fullWidth > width
206 const legend = counts.map(one => (
207 <Button plain key={`k:${one.key}`} onPress={pick(one.key)}>
208 <Text dimColor={picked !== '' && picked !== one.key}>
209 <Text color={one.color}>{one.mark}</Text>{isShort ? '' : ` ${one.label}`} {one.count}
210 </Text>
211 </Button>
212 ))
213 const shownCommands = latest(pickCommands(commandList, picked, words), room, sort)
214 const shownFiles = latest(pickFiles(fileList, words), room, sort)
215
216 const commandBox = (
217 <Box flexDirection="column" width={width}>
218 {shownCommands.length === 0 && <Text dimColor>{commandList.length === 0 ? 'コマンドなし' : '該当なし'}</Text>}
219 {shownCommands.map(one => (
220 <Button plain key={`c:${one.id}`} onPress={toggle(`c:${one.id}`)}>
221 <Text color={KIND_COLOR[one.kind ?? 'other']}>{KIND_MARK[one.kind ?? 'other']}</Text>{' '}
222 {fit(one.description || (one.command.split('\n')[0] ?? ''), width - 4 - columnsOf(KIND_MARK[one.kind ?? 'other']))}
223 {' '}<Text color={COLOR[one.state]}>{stateMark(one.state)}</Text>
224 </Button>
225 ))}
226 </Box>
227 )
228
229 const fileBox = (
230 <Box flexDirection="column" width={width}>
231 {shownFiles.length === 0 && <Text dimColor>{fileList.length === 0 ? 'ファイルなし' : '該当なし'}</Text>}
232 {shownFiles.map(one => (
233 <Button plain key={`f:${one.path}`} onPress={toggle(`f:${one.path}`)}>
234 <Text color={KIND_COLOR.write}>✎</Text>{' '}
235 <Text color="suggestion">{fit(shortPath(one.path), width - 7)}</Text>
236 <Text dimColor> ×{one.count}</Text>
237 </Button>
238 ))}
239 </Box>
240 )
241
242 // 狭いときはコマンドを先に出す
243 return (
244 <Box flexDirection="column">
245 <Box flexDirection={isWide ? 'row' : 'column'} columnGap={2}>
246 <Box width={width}>
247 <Text>
248 <Text color="claude">流れ </Text>
249 {commandList.length === 0 ? <Text dimColor>まだありません</Text> : flow}
250 </Text>
251 </Box>
252 <Box width={width} flexDirection="row" columnGap={2}>{legend}</Box>
253 </Box>
254 {hasSearch && <Box flexDirection="row" columnGap={2}>
255 {/* 幅が足りないときに切れるのが検索欄の説明の側になるよう、並び順を先に置く */}
256 <Button plain key="order" onPress={() => update($, order, now => (now === 'new' ? 'old' : 'new'))}>
257 <Text dimColor>{sort === 'new' ? '新しい順' : '古い順'}</Text>
258 </Button>
259 {Input && <Input
260 key="query"
261 label="検索 "
262 placeholder="説明・コマンド・パス"
263 value={words}
264 submitLabel="絞る"
265 onInput={(value: string) => update($, query, () => value)}
266 onSubmit={(value: string) => update($, query, () => value)}
267 />}
268 </Box>}
269 <Box flexDirection={isWide ? 'row' : 'column'} columnGap={2}>
270 {commandBox}
271 {fileBox}
272 </Box>
273 </Box>
274 )
275 })
276}
277hooks/filter.ts 21 lines1// 一覧の絞り込みと並び順。描画から切り離してテストできるようにする
2import type { CommandEntry, FileEntry, Filter, Order } from '../types'
3
4// 大文字小文字を区別せず、どれか1つに部分一致すれば残す
5const matches = (query: string, texts: string[]) => {
6 const q = query.trim().toLowerCase()
7 return q === '' || texts.some(text => text.toLowerCase().includes(q))
8}
9
10export const pickCommands = (list: CommandEntry[], filter: Filter, query: string) =>
11 list.filter(one =>
12 (filter === '' || (filter === 'failed' ? one.state === 'failed' : (one.kind ?? 'other') === filter)) &&
13 matches(query, [one.description, one.command]),
14 )
15
16export const pickFiles = (list: FileEntry[], query: string) => list.filter(one => matches(query, [one.path]))
17
18// 収まる件数だけ最新から取り、新しい順なら最新を上にする
19export const latest = <T,>(list: T[], room: number, order: Order) =>
20 order === 'new' ? list.slice(-room).reverse() : list.slice(-room)
21hooks/kind.ts 40 lines1// コマンドの中身から作業の種類を決める。色分けの元になる
2export type Kind = 'check' | 'git' | 'write' | 'read' | 'other'
3
4export const KIND_LABEL: Record<Kind, string> = {
5 read: '調べる',
6 write: '書き換え',
7 check: '検証',
8 git: 'git',
9 other: 'その他',
10}
11export const KIND_COLOR: Record<Kind, string> = {
12 read: 'suggestion',
13 write: 'warning',
14 check: 'merged',
15 git: 'success',
16 other: 'inactive',
17}
18// 色だけに頼らず形でも見分けられるよう、種類ごとに記号を変える
19export const KIND_MARK: Record<Kind, string> = {
20 read: '●',
21 write: '✎',
22 check: '▲',
23 git: '⑂',
24 other: '・',
25}
26// 凡例に並べる順
27export const KIND_ORDER: Kind[] = ['read', 'write', 'check', 'git', 'other']
28
29const CHECK = /(^|[\s;&|(])(npm (run )?test|pnpm (run )?test|yarn test|vitest|jest|tsc|eslint|validate|plugin test|pytest|shellcheck|lint)\b/
30const GIT = /(^|[\s;&|(])(git|gh)\s/
31const WRITE = /(^|[\s;&|(])(sed -i|cp|mv|mkdir|rm|touch|tee|chmod|ln|python3? -|cat\s*>)\s|[^>]>\s*(?!\/dev\/null\b)[^&\s]/
32const READ = /(^|[\s;&|(])(ls|grep|rg|find|cat|head|tail|sed -n|wc|awk|less|tree|stat|which|file)\b/
33
34export const classify = (command: string): Kind =>
35 CHECK.test(command) ? 'check'
36 : GIT.test(command) ? 'git'
37 : WRITE.test(command) ? 'write'
38 : READ.test(command) ? 'read'
39 : 'other'
40hooks/mask.ts 25 lines1// 秘密の値に見える文字列を伏せ字にする。記録と表示の前に必ず通す
2const PATTERNS: RegExp[] = [
3 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
4 /\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
5 /\b(sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})\b/g,
6 /\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g,
7]
8// 名前に秘密らしい語を含む代入・指定の値
9const NAMED = /\b([A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|PASS|API_?KEY|PRIVATE_?KEY|CREDENTIAL)[A-Za-z0-9_]*)(\s*[=:]\s*|\s+)("[^"]*"|'[^']*'|[^\s"']+)/gi
10const BEARER = /\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi
11const URL_USERINFO = /(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi
12const FLAG = /(--?(?:password|passwd|token|secret|api-key|apikey)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi
13
14export const MASK = '•••'
15
16export const mask = (text: string): string => {
17 let out = text
18 for (const pattern of PATTERNS) out = out.replace(pattern, MASK)
19 out = out.replace(URL_USERINFO, `$1${MASK}@`)
20 out = out.replace(BEARER, (_m, kind: string) => `${kind} ${MASK}`)
21 out = out.replace(FLAG, (_m, flag: string) => `${flag}${MASK}`)
22 out = out.replace(NAMED, (_m, name: string, sep: string) => `${name}${sep}${MASK}`)
23 return out
24}
25hooks/width.ts 20 lines1// 端末での表示幅を数える。全角は2列、それ以外は1列
2const WIDE =
3 /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]|[\u{20000}-\u{3FFFD}]/u
4
5export const columnsOf = (text: string) => [...text].reduce((sum, ch) => sum + (WIDE.test(ch) ? 2 : 1), 0)
6
7// 幅に収まらない文字列は末尾を…で切る。文字数ではなく表示幅で数える
8export const fit = (text: string, width: number) => {
9 if (columnsOf(text) <= width) return text
10 let out = ''
11 let used = 0
12 for (const ch of text) {
13 const w = WIDE.test(ch) ? 2 : 1
14 if (used + w > width - 1) break
15 out += ch
16 used += w
17 }
18 return `${out}…`
19}
20types/index.d.ts 32 lines1// 変更したファイル1件
2export type FileEntry = { path: string; tool: string; count: number }
3// 実行したコマンド1件。説明と結果の先頭は詳細の表示に使う
4export type CommandEntry = {
5 id: string
6 command: string
7 description: string
8 output: string
9 kind: 'check' | 'git' | 'write' | 'read' | 'other'
10 state: 'runs' | 'ok' | 'failed'
11}
12
13// 一覧の絞り込み。空文字は全件、'failed'は失敗した行だけ
14export type Filter = '' | CommandEntry['kind'] | 'failed'
15// 一覧の並び。'new'は最新を上に出す
16export type Order = 'new' | 'old'
17
18declare module 'claude-code' {
19 interface PluginState {
20 // selectedは詳細を開いている行。'c:<id>'か'f:<path>'、閉じているときは空文字
21 // filter・query・orderは一覧の絞り込み、検索語、並び順
22 'work-log': {
23 files: FileEntry[]
24 commands: CommandEntry[]
25 selected: string
26 filter: Filter
27 query: string
28 order: Order
29 }
30 }
31}
32