SLOPSHOPPER

work-log

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

newpaneguardcommandtimer
v0.2.0MITupdated 2026-10-14BoxPistols/claude-work-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-log
│ ┃ 作業の記録 ✕ › fix the failing auth test and add an audit log call │ ┃ 流れ ✗■■■ │ ┃ k:read k:git k:other k:failed ⏺ Read(src/auth.ts) │ ┃ order 検索 : 説明・コマンド・パス ⏎ 絞る ⎿ Read 6 lines │ ┃ c:toolu_09 ⏺ Update(src/auth.ts) │ ┃ c:toolu_08 ⎿ Added 2 lines, removed 1 line │ ┃ c:toolu_07 ⏺ Bash(bun test) │ ┃ c:toolu_06 ⎿ 3 pass, 1 fail │ ┃ f:/work/app/src/cache.ts │ ┃ f:/work/app/src/audit.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ f:/work/app/src/auth.ts │ ✻ Worked for 42s · done 4:20 PM │ │ › /work-log │ ⎿ work-log: 作業の記録を開きました │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 作業の記録
流れ ✗■■■ k:read k:git k:other k:failed order 検索 : 説明・コマンド・パス ⏎ 絞る c:toolu_09 c:toolu_08 c:toolu_07 c:toolu_06 f:/work/app/src/cache.ts f:/work/app/src/audit.ts f:/work/app/src/auth.ts
README

claude-work-log

Claude Codeが実行したコマンドと変更したファイルを、色分けした一覧としてパネルに出すmodです。

Claude Codeに作業を任せていると、コマンドの実行やファイルの変更が会話の中を流れていき、いま何をしているのか、どこで失敗したのかを後から追いにくくなります。work-logはこれを1枚のパネルにまとめます。

  • 1行目に、実行したコマンドを1件1マスで古い順に並べます。色はコマンドの種類(調べる、書き換え、検証、git、その他)で分け、失敗したものは赤の✗にします
  • その下に、コマンドの一覧と変更したファイルの一覧を並べます。幅が十分あるときは左右に、狭いときは上下に並べます
  • 行を押すと、コマンドと結果の先頭が開きます。同じ行をもう一度押すと閉じます
  • 凡例を押すとその種類だけに絞ります。検索欄では説明・コマンド・パスで絞れます

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は記録する前に次の形式を•••に置き換えます。

  • よく知られた形式のトークン(GitHub、OpenAI、Slack、AWSのアクセスキー、GoogleのAPIキー、JWT)
  • Bearer、Basic、tokenの後ろの値
  • --password=や--tokenのような指定の値
  • 名前にTOKEN、SECRET、PASSWORD、API_KEYなどを含む代入の値
  • URLに埋め込んだ認証情報
  • 秘密鍵のブロック

結果は先頭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

Source 6 files
hooks/register.tsx 277 lines
1import { 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}
277
hooks/filter.ts 21 lines
1// 一覧の絞り込みと並び順。描画から切り離してテストできるようにする
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)
21
hooks/kind.ts 40 lines
1// コマンドの中身から作業の種類を決める。色分けの元になる
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'
40
hooks/mask.ts 25 lines
1// 秘密の値に見える文字列を伏せ字にする。記録と表示の前に必ず通す
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}
25
hooks/width.ts 20 lines
1// 端末での表示幅を数える。全角は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}
20
types/index.d.ts 32 lines
1// 変更したファイル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