See which files Claude has listed, read, partially read, edited, created or deleted, as a tree and an activity map in a pane.

Mods for Claude Code by y-hirakaw. Each installs and turns on or off on its own.
| Mod | What it does |
|---|---|
| touch-map | Shows which files Claude read, edited or never opened, as a tree and an activity map in a pane. |
| copy-blocks | Adds copy buttons to the quotes and code blocks in Claude's answers, so you copy them without the terminal's line breaks. |
See how much of your codebase Claude actually looked at.

<sub>Claude and two subagents survey a Unity project, sped up. With the conversation beside it</sub>
You ask Claude something and get an answer. Did it read the file, or only see a few grep matches? Which parts did it never open? touch-map marks every file Claude touches in this session, down to the lines it read, in a pane beside the conversation.
Needs Claude Code 2.1.287 or later. On Ghostty, use 1.3.1 or later: older versions may not pass clicks to the pane. From a shell:
claude plugin marketplace add y-hirakaw/claude-code-mods
claude plugin install touch-map@y-hirakaw-mods
Or inside Claude Code: run /plugin marketplace add y-hirakaw/claude-code-mods, then open /plugin, pick the marketplace, select touch-map and install it.
Then start a new session and run /touch-map once. From then on the pane opens by itself when the terminal is wide enough; otherwise run /touch-map.
git clone https://github.com/y-hirakaw/claude-code-mods
claude --plugin-dir ./claude-code-mods/touch-map
Each file shows the deepest state it reached.
| State | Meaning | Detected from |
|---|---|---|
<img src="docs/colors/created.svg" width="12" height="12"> created | Claude made the file | Write, > redirects, mv, scripts |
<img src="docs/colors/edited.svg" width="12" height="12"> edited | Claude changed the file | Edit, >>, sed -i, scripts |
<img src="docs/colors/read.svg" width="12" height="12"> read | Claude saw the whole file | Read, cat, @file mentions |
<img src="docs/colors/partial.svg" width="12" height="12"> partial | Claude saw only part of it | Read with a line range, head / tail / sed -n, grep matches |
<img src="docs/colors/listed.svg" width="12" height="12"> listed | Claude only saw the name | ls, find, grep -l, git status and similar output |
<img src="docs/colors/deleted.svg" width="12" height="12"> deleted | Claude removed the file | rm, git rm, mv |
<img src="docs/colors/auto.svg" width="12" height="12"> auto | Loaded into context automatically | CLAUDE.md and rules files |

■■■■■■■■■■ is the share of its files in each state, then touched/total.━━━─── marks the lines it read, ┄┄┄ means only grep matches. A file read in full says so with the color of its name.▾ / ▸ to open or close a directory. Untouched files fold into one N untouched: … line.[ save ] writes the record to ~/.claude/touch-map-logs/ and starts over. It is also saved on /clear, compaction and exit.× shrinks it to one line above the prompt.| Command | ||
|---|---|---|
/touch-map | Open the pane | |
/touch-map clear | Save the record and start over | |
/touch-map discard | Start over without saving | |
/touch-map min | Shrink to one line above the prompt | |
| `/touch-map map [on\ | off]` | Show or hide the activity map |
| `/touch-map debug [on\ | off]` | Write how each event was judged, and the pane as text, to ~/.claude/touch-map-logs/debug/ |
@file attachments and instruction-file loads. Nothing is sent anywhere.printenv HOME, git ls-files, git worktree list and git status, and nothing else, never through a shell. git status runs only after a Bash command that may have written files.~/.claude/touch-map-logs/<time>.json when a record is saved, and debug files only while debug output is on.git status and modification times, so files ignored by git are not seen, and a file another process writes while the command runs is counted as Claude's.git status adds about 0.1 s per writing command.Copy a quote or code block from Claude's answer without the terminal's line breaks.

<sub>Copying from the answer and pasting into the prompt. With the row above the prompt</sub>
Selecting text in the terminal copies the wrapped lines and the │ gutter with it. copy-blocks puts a copy button on each > quote and code block, right above the block in the answer, and in a row above the prompt for Claude's last answer:
copy 1 ❝ Thanks for the update… · 2 sh git switch -c featu… · 3 tsx export const regist… 7L ×
+N, which opens a pane listing every block, and below 50 columns only the numbers remain.✓ copied for a moment; in the row it keeps a ✓.×./config → copy-blocks buttons: both (default), inline (in the answer only) or band (the row above the prompt only). With band, answers are drawn exactly as Claude Code draws them.claude plugin marketplace add y-hirakaw/claude-code-mods
claude plugin install copy-blocks@y-hirakaw-mods
Needs Claude Code 2.1.287 or later. Nothing to run afterwards: the buttons appear after Claude's next answer.
band, the text of each answer as it is drawn, to place the buttons. Your stored conversation is not changed./copy does. It writes no files.Developing a mod: see CLAUDE.md.
| mod | できること |
|---|---|
| touch-map | Claude がどのファイルを読んだか、変えたか、開かなかったかを、ペインにツリーとマップで表示する |
| copy-blocks | Claude の返事にある引用やコードブロックにコピーボタンを付け、余計な改行なしにコピーできるようにする |
touch-map は、Claude がこのセッションで触ったファイルを created・edited・read・partial・listed・deleted・auto に分けて、会話の横のペインにツリーとアクティビティマップで表示する Claude Code の mod です。触っていないところも畳んで見せるので、「どこまで見て答えたのか」がわかります。
claude plugin marketplace add y-hirakaw/claude-code-mods
claude plugin install touch-map@y-hirakaw-mods
Claude Code 2.1.287 以上が必要です。Ghostty は 1.3.1 以上を推奨します(古い版ではペインのボタンがクリックできないことがあります)。
インストールしたら、新しいセッションで一度 /touch-map を実行してください。外部に何かを送ることはなく、実行するのは printenv HOME・git ls-files・git worktree list・git status だけです。
copy-blocks は、Claude の返事にある引用(>)とコードブロックにコピーボタンを付ける mod です。ボタンは返事の中のブロックの直上と、入力欄の上の帯に出ます。ターミナルで選択したときのような余計な改行や │ は入りません。出す場所は /config の copy-blocks buttons で both(初期値)・inline(返事の中だけ)・band(帯だけ)から選べます。band なら返事の表示には手を加えません。
claude plugin install copy-blocks@y-hirakaw-mods
copy-blocks はコマンドを実行せず、外部に何も送らず、書き込むのはボタンを押したときのクリップボードだけです。
hooks/register.tsx 1532 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Level, Loaded, Now, Segment, Touch } from '../types'
5
6const PANE = 'touch-map'
7const LOG_DIR = '.claude/touch-map-logs'
8const DEBUG_DIR = `${LOG_DIR}/debug`
9
10const touches = atom({ plugin: 'touch-map', key: 'touches' } as const, {})
11const segment = atom({ plugin: 'touch-map', key: 'segment' } as const, { label: '', startedAt: 0, note: '' })
12const minimized = atom({ plugin: 'touch-map', key: 'minimized' } as const, false)
13const loadedAtom = atom({ plugin: 'touch-map', key: 'loaded' } as const, {})
14const folds = atom({ plugin: 'touch-map', key: 'folds' } as const, {})
15const nowAtom = atom({ plugin: 'touch-map', key: 'now' } as const, null)
16const mapAtom = atom({ plugin: 'touch-map', key: 'map' } as const, true)
17
18const LEVELS = ['listed', 'partial', 'read', 'edited', 'created'] as const
19const COLORS = ['#c084fc', '#818cf8', '#60a5fa', '#fb923c', '#4ade80'] as const
20// 深い順。凡例・帯・並び順はこの順
21const DEEP_FIRST = [4, 3, 2, 1, 0] as const
22// バーの触っていない部分
23const TRACK = '#3f4654'
24const AUTO = '#7dd3fc'
25const GONE = '#f87171'
26// 直近に触ったファイルの行の背景
27const NOW_BG = '#1d2029'
28// ファイル行・ディレクトリ行のバーの幅
29const BAR = 10
30// 区切りをまたいでも残す読み込み理由(/clear や compact のあとにも読み込み直されるもの)
31const EAGER = new Set(['session_start', 'compact', 'include'])
32
33// 名前を出力するコマンド。この出力に出てきたファイルを「名前だけ」とみなす
34const LISTERS = /(^|[\s|;&(])(rg|grep|egrep|fgrep|find|fd|ls|tree|git\s+(ls-files|grep|status|diff|log|show))\b/
35// 一致した行の中身を出すコマンド。-l や -c のように名前や件数だけ出すときは除く
36const GREPS = /(^|[\s|;&(])(rg|grep|egrep|fgrep|git\s+grep)\b/
37const NAMES_ONLY = (t: string) => /^-[a-zA-Z]*[lLc][a-zA-Z]*$/.test(t) || ['--files', '--files-with-matches', '--files-without-match', '--count'].includes(t)
38// ファイルの中身を出すコマンド。引数のファイルを「読み取り」とみなす
39const READERS = new Set(['cat', 'head', 'tail', 'less', 'more', 'bat', 'nl', 'sed', 'awk', 'jq', 'diff', 'cut', 'sort', 'uniq', 'tac', 'xxd', 'od'])
40// ファイルを名指しするが中身は出さないコマンド(行数・属性・種類だけ)。引数のファイルを「名前だけ」とみなす
41const NAMERS = new Set(['wc', 'ls', 'stat', 'file', 'du'])
42// これだけでできたコマンドは何も書き換えないので、git status で調べない
43const READ_ONLY = new Set([
44 ...READERS, ...NAMERS, 'grep', 'egrep', 'fgrep', 'rg', 'find', 'fd', 'tree', 'echo', 'printf', 'pwd', 'which', 'type',
45 'cd', 'true', 'false', 'test', '[', 'date', 'basename', 'dirname', 'realpath', 'tr', 'column', 'sleep', 'pgrep', 'ps',
46])
47const GIT_READ = new Set(['status', 'log', 'diff', 'show', 'ls-files', 'grep', 'branch', 'rev-parse', 'blame', 'remote', 'describe', 'shortlog'])
48
49// セッションごとに作り直す。モジュールの変数は reload で消えるので session.start で埋め直す
50let root = ''
51let home = ''
52let files = new Set<string>()
53// files が変わるたびに増やす。ツリーの骨組みを作り直すかどうかの目印
54let filesVersion = 0
55const addFile = (rel: string) => {
56 if (files.has(rel)) return
57 files.add(rel)
58 // 骨組みと名前順の一覧が今の一覧のものなら、作り直さずに1件だけ足す
59 const fresh = skeleton?.version === filesVersion ? skeleton : undefined
60 const sortedFresh = sortedFiles?.version === filesVersion ? sortedFiles : undefined
61 filesVersion += 1
62 if (fresh) {
63 addToSkeleton(fresh.value, rel)
64 fresh.version = filesVersion
65 }
66 if (sortedFresh) {
67 insertSorted(sortedFresh.list, rel)
68 sortedFresh.version = filesVersion
69 }
70}
71// Bash の作業ディレクトリ。リポジトリの中なら次の呼び出しにも持ち越される
72let shellCwd = ''
73let fileNote = ''
74// session.start より先に届いた読み込みは、ファイル一覧ができてから記録する
75let pending: { path: string; reason: string }[] = []
76// デバッグ出力(/touch-map debug で on にしたときだけ。$.store に残してセッションをまたぐ)。追記の API がないので、溜めた行を毎回まとめて書き直す
77let debug = false
78let sessionId = ''
79let debugLines: string[] = []
80let writing: Promise<void> = Promise.resolve()
81// まだ書いていない最新のペインの中身。書いている間に何度呼ばれても、次に書くのは最後のものだけ
82let queuedView: { base: string; view: string } | undefined
83// 最後に書き出したペインの中身。同じなら書き直さない
84let writtenView = ''
85// ペインに入る行数。最後に描いたときの値をデバッグ出力の区切り線に使う
86let paneRows = 0
87// 最後にペインを描いたときにかかった時間(ミリ秒)。デバッグ出力で重さを見る
88let renderMs = 0
89
90// 正規表現は遅いので、よく通る道では文字を比べて済ませる。結果は正規表現で書いたときと同じ
91
92// 前後の引用符を外す(/^['"]+|['"]+$/g を空にするのと同じ)
93const isQuote = (s: string, i: number) => s[i] === "'" || s[i] === '"'
94const unquote = (s: string): string => {
95 let a = 0
96 let b = s.length
97 while (a < b && isQuote(s, a)) a++
98 while (b > a && isQuote(s, b - 1)) b--
99 return a === 0 && b === s.length ? s : s.slice(a, b)
100}
101
102// 空の区切り・「.」「..」・末尾の / を含むか(/\/\/|\/\.\.?(?:\/|$)|\/$/ と同じ)
103const notPlain = (p: string): boolean =>
104 p.includes('//') || p.includes('/./') || p.includes('/../') || p.endsWith('/') || p.endsWith('/.') || p.endsWith('/..')
105
106// JS の正規表現の \s にあたる文字
107const isSpace = (c: number): boolean =>
108 (c >= 9 && c <= 13) || c === 32 || c === 0xa0 || c === 0x1680 || (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff
109
110// 空白で区切った語。split(/\s+/) から空の語を除いたものと同じ
111const wordsOf = (line: string): string[] => {
112 const out: string[] = []
113 let start = -1
114 for (let i = 0; i < line.length; i++) {
115 if (isSpace(line.charCodeAt(i))) {
116 if (start >= 0) out.push(line.slice(start, i))
117 start = -1
118 } else if (start < 0) start = i
119 }
120 if (start >= 0) out.push(start === 0 ? line : line.slice(start))
121 return out
122}
123
124// base から見たパスを絶対パスにそろえる。base が分からない(cd 先が変数など)ときの相対パスは undefined
125const toAbs = (raw: string, base: string | undefined): string | undefined => {
126 const p = unquote(raw.trim())
127 if (p === '' || p === '/dev/null') return undefined
128 const joined = p.startsWith('/') ? p : p === '~' || p.startsWith('~/') ? home + p.slice(1) : base === undefined ? undefined : `${base}/${p}`
129 if (joined === undefined) return undefined
130 // 空の区切り・「.」「..」・末尾の / がない絶対パスは、たどっても同じなのでそのまま返す
131 if (joined.startsWith('/') && !notPlain(joined)) return joined
132 const parts: string[] = []
133 for (const part of joined.split('/')) {
134 if (part === '' || part === '.') continue
135 if (part === '..') parts.pop()
136 else parts.push(part)
137 }
138 return '/' + parts.join('/')
139}
140
141// リポジトリからの相対パス。リポジトリの外なら undefined
142// base は省略させない(undefined を渡すと既定値が使われ、「分からない」が「リポジトリ直下」になるため)
143// 同じリポジトリの別の作業場所(git worktree)。中のファイルはリポジトリの同じパスとして数える。長い順
144let worktrees: string[] = []
145
146// 絶対パスが属する作業場所(リポジトリ本体かワークツリー)。どちらでもなければ undefined
147const treeOf = (abs: string): string | undefined =>
148 worktrees.find(w => abs === w || abs.startsWith(w + '/')) ?? (abs === root || abs.startsWith(root + '/') ? root : undefined)
149
150const toRel = (raw: string, base: string | undefined): string | undefined => {
151 const abs = toAbs(raw, base)
152 const tree = abs === undefined ? undefined : treeOf(abs)
153 return abs !== undefined && tree !== undefined && abs !== tree ? abs.slice(tree.length + 1) : undefined
154}
155
156// 最後に一覧を取った時刻。リポジトリの外を読むたびに取り直さないよう、force でなければ 30 秒に 1 回まで
157let worktreesAt = 0
158
159async function loadWorktrees($: any, force = true): Promise<void> {
160 const now = await $.clock.now()
161 if (!force && now - worktreesAt < 30_000) return
162 worktreesAt = now
163 const ran = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root, timeoutMs: 10_000 })
164 if (ran.exitCode !== 0) return
165 const found: string[] = ran.stdout
166 .split('\n')
167 .filter((l: string) => l.startsWith('worktree '))
168 .map((l: string) => l.slice('worktree '.length))
169 .filter((w: string) => w !== root)
170 .sort((a: string, b: string) => b.length - a.length)
171 // ワークツリーにしかないファイル(そこで新しく作ったもの)も一覧に入れる。force のときは作り直す
172 for (const w of found) {
173 if (!force && worktrees.includes(w)) continue
174 const listed = await $.process.run(['git', 'ls-files', '--cached', '--others', '--exclude-standard'], { cwd: w, timeoutMs: 60_000 })
175 if (listed.exitCode !== 0) continue
176 for (const rel of listed.stdout.split('\n')) if (rel !== '') addFile(rel)
177 await snapshotDeleted($, w)
178 // 本体と同じく、対になる .meta は外す(ディレクトリの .meta も)
179 if (dropMeta(files) > 0) filesVersion += 1
180 }
181 worktrees = found
182}
183
184const isOk = (ran: { deny?: string; isError?: boolean }) => ran.deny === undefined && ran.isError !== true
185
186async function recordLoad($: any, path: string, reason: string): Promise<void> {
187 const rel = toRel(path, root)
188 const key = rel ?? (home !== '' && path.startsWith(home + '/') ? '~' + path.slice(home.length) : path)
189 if (rel) addFile(rel)
190 await update($, loadedAtom, (all: Loaded) => (all[key] === reason ? all : { ...all, [key]: reason }))
191}
192
193// 3つめは Read で読んだ行の範囲と、ファイルの行数
194type Lines = { from: number; to: number; total: number }
195type Hit = [string, Level, Lines?]
196
197// 範囲を並べて、重なりと隣り合いをまとめる
198const mergeRanges = (ranges: [number, number][]): [number, number][] => {
199 const out: [number, number][] = []
200 for (const [a, b] of [...ranges].sort((x, y) => x[0] - y[0])) {
201 const last = out[out.length - 1]
202 if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b)
203 else out.push([a, b])
204 }
205 return out
206}
207
208// 末尾の空行は数え方で1行ずれるので、1行足りないだけなら全体とみなす
209const coversAll = (r: [number, number][], total: number) => r.length === 1 && (r[0]?.[0] ?? 2) <= 1 && (r[0]?.[1] ?? 0) >= total - 1
210
211// gone は消したファイル(rm・mv の元)。消すのも書き換えなので「更新」として数え、削除の印を付ける
212// agent はサブエージェントの id(本体なら undefined)。光る色を変えるのに使う
213const mark = async ($: any, hits: Hit[], gone: string[] = [], agent?: string) => {
214 if (hits.length === 0 && gone.length === 0) return
215 // .gitignore されたファイルを読んだときなど、一覧に無いものもツリーに出す
216 for (const [path] of hits) addFile(path)
217 for (const path of gone) addFile(path)
218 // 直近の1件。消したものがあればそれ、なければいちばん深く触ったもの(同じなら後のもの)
219 let latest: Now | null = null
220 for (const [path, level] of hits) if (!latest || level >= latest.level) latest = { path, level }
221 const lastGone = gone[gone.length - 1]
222 if (lastGone !== undefined) latest = { path: lastGone, level: 3 }
223 await update($, touches, (all: Record<string, Touch>) => {
224 const next = { ...all }
225 for (const [path, level, lines] of hits) {
226 const prev = next[path]
227 const c: Touch['c'] = prev ? [...prev.c] : [0, 0, 0, 0, 0]
228 c[level] = (c[level] ?? 0) + 1
229 let s = (prev && prev.s > level ? prev.s : level) as Level
230 let r = prev?.r
231 let n = prev?.n
232 if (lines) {
233 r = mergeRanges([...(r ?? []), [lines.from, lines.to]])
234 n = lines.total
235 // 部分読取を重ねて全体を読み終えたら、読み取りに上げる
236 if (s < 2 && coversAll(r, n)) s = 2
237 }
238 // 書き込んだら(作り直したら)削除の印は外す
239 const d = prev?.d === true && level < 3 ? true : undefined
240 next[path] = { s, c, ...(r ? { r, n } : {}), ...(d ? { d } : {}) }
241 }
242 for (const path of gone) {
243 const prev = next[path]
244 const c: Touch['c'] = prev ? [...prev.c] : [0, 0, 0, 0, 0]
245 c[3] = (c[3] ?? 0) + 1
246 next[path] = { ...prev, s: (prev && prev.s > 3 ? prev.s : 3) as Level, c, d: true }
247 }
248 return next
249 })
250 touchesGen += 1
251 if (latest) await update($, nowAtom, () => latest)
252 // 消すメモがあるときだけ書く(書くと描き直しになるため)
253 if ((await read($, segment)).note !== '') await update($, segment, (seg: Segment) => (seg.note === '' ? seg : { ...seg, note: '' }))
254 await flash($, [...hits.map(([path]) => path), ...gone], agent !== undefined)
255}
256
257// ヒアドキュメントの本文はコマンドではないので外す(本文の > をリダイレクトと誤認しないため)
258const stripHeredocs = (command: string): string => {
259 const out: string[] = []
260 let end: string | undefined
261 for (const line of command.split('\n')) {
262 if (end !== undefined) {
263 if (line.trim() === end) end = undefined
264 continue
265 }
266 out.push(line)
267 const m = line.match(/<<-?\s*['"]?([A-Za-z_]\w*)['"]?/)
268 if (m) end = m[1]
269 }
270 return out.join('\n')
271}
272
273// 引用符の中身はコードや文字列なので、> や | を拾わないよう伏せる
274const maskQuotes = (command: string): string => command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, ' _ ')
275
276// 書き込み先として信じてよい文字列か。展開前の変数や記号だけのものは除く
277const isPathLike = (s: string): boolean => !s.includes('$') && /^[\w@%+=:,./~-]+$/.test(s) && /[\w]/.test(s)
278
279// Bash のコマンドと出力から、読んだファイル・書いたファイル・名前を見たファイルを拾う
280// cd も追い、相対パスは cd 先から解決する
281// cwd はコマンドを実行する時点のシェルの場所。Claude Code が cd を覚えているので、それをそのまま使う
282const fromBash = (command: string, stdout: string, cwd: string): { hits: Hit[]; gone: string[] } => {
283 const hits: Hit[] = []
284 const gone: string[] = []
285 let base: string | undefined = cwd || shellCwd || root
286 // 名前を出すコマンドがリポジトリの中を見ていたときだけ、出力から名前を拾う
287 let scan = false
288 // 一致した行の中身まで出す grep があったか。あれば出力の「ファイル:行」は部分読取
289 let showsLines = false
290 for (const seg of maskQuotes(stripHeredocs(command)).split(/\|\||&&|;|\||\n/)) {
291 const tokens = seg.trim().split(/\s+/).filter(t => t !== '')
292 if (tokens.length === 0) continue
293 const cmd = (tokens[0] ?? '').split('/').pop() ?? ''
294 if (cmd === 'cd') {
295 const to = (tokens[1] ?? '~').replace(/^\$HOME(?=\/|$)/, '~')
296 // 変数・cd -・引用符で伏せた先は追えないので、以降の相対パスは拾わない
297 base = to.includes('$') || to === '-' || to === '_' ? undefined : toAbs(to, base)
298 continue
299 }
300 // rm・git rm は消す。mv・git mv は元を消して先に作る(引数が2つの素直な形だけ)
301 const sub = cmd === 'git' ? tokens[1] ?? '' : ''
302 const args = tokens.slice(sub === '' ? 1 : 2).filter(t => !t.startsWith('-') && !t.includes('*'))
303 if (cmd === 'rm' || cmd === 'unlink' || sub === 'rm') {
304 for (const t of args) {
305 const rel = toRel(t, base)
306 if (rel && files.has(rel)) gone.push(rel)
307 }
308 continue
309 }
310 if ((cmd === 'mv' || sub === 'mv') && args.length === 2) {
311 const from = toRel(args[0] ?? '', base)
312 let to = toRel(args[1] ?? '', base)
313 if (from && to && files.has(from)) {
314 // 先が既存のディレクトリなら、その中へ同じ名前で移る
315 if (!files.has(to) && [...files].some(f => f.startsWith(to + '/'))) to = `${to}/${from.split('/').pop()}`
316 gone.push(from)
317 hits.push([to, files.has(to) ? 3 : 4])
318 }
319 continue
320 }
321 if (LISTERS.test(seg)) {
322 const inRepo = base !== undefined && treeOf(base) !== undefined
323 const outside = tokens.some(t => /^[~/]|^\$HOME/.test(t) && t !== '/dev/null' && toRel(t.replace(/^\$HOME/, '~'), base) === undefined)
324 if (inRepo && !outside) scan = true
325 // grep はパターンだけ(ファイルの指定も -r もない)なら、パイプの前の出力を絞っているだけでファイルは読んでいない
326 const operands = tokens.slice(1).filter(t => !t.startsWith('-'))
327 const readsFiles = !/^[ef]?grep$/.test(cmd) || operands.length >= 2 || tokens.some(t => /^-[a-zA-Z]*[rR]/.test(t))
328 if (GREPS.test(seg) && readsFiles && !tokens.some(NAMES_ONLY)) {
329 showsLines = true
330 // ファイルを名指しした grep は、出力にファイル名が出なくても一致した行を見ている
331 for (const t of tokens.slice(1)) {
332 const rel = t.startsWith('-') ? undefined : toRel(t, base)
333 if (rel && files.has(rel)) hits.push([rel, 1])
334 }
335 }
336 }
337 const inPlace = (cmd === 'sed' || cmd === 'perl') && tokens.some(t => /^-[a-zA-Z]*i/.test(t))
338 for (let i = 0; i < tokens.length; i++) {
339 const t = tokens[i] ?? ''
340 const redirect = t === '>' || t === '>>' ? tokens[i + 1] : /^>>?[^>&]/.test(t) ? t.replace(/^>>?/, '') : undefined
341 if (redirect !== undefined || (cmd === 'tee' && i > 0 && !t.startsWith('-'))) {
342 const target = redirect ?? t
343 const rel = isPathLike(target) ? toRel(target, base) : undefined
344 // 新しく作ったとみなすのは、パスか拡張子のある名前だけ
345 if (rel && (files.has(rel) || /[./]/.test(rel))) {
346 hits.push([rel, files.has(rel) ? 3 : 4])
347 addFile(rel)
348 }
349 continue
350 }
351 if (i === 0 || t.startsWith('-')) continue
352 const rel = toRel(t, base)
353 // head・tail・sed -n は一部の行しか出さないので部分読取
354 const partial = cmd === 'head' || cmd === 'tail' || (cmd === 'sed' && tokens.some(x => /^-[a-zA-Z]*n/.test(x)))
355 if (rel && files.has(rel) && (inPlace || READERS.has(cmd))) hits.push([rel, inPlace ? 3 : partial ? 1 : 2])
356 else if (rel && files.has(rel) && NAMERS.has(cmd)) hits.push([rel, 0])
357 }
358 }
359 // 次の呼び出しに持ち越すのはリポジトリの中だけ(外に出るとシェルが戻すため)
360 shellCwd = base !== undefined && treeOf(base) !== undefined ? base : root
361 if (scan && base !== undefined) {
362 const lines = stdout.slice(0, 300_000).split('\n').slice(0, 20_000)
363 // grep の出力では同じ名前が何行も続くので、名前ごとの解決結果を使い回す
364 const known = new Map<string, string | undefined>()
365 for (const line of lines) {
366 for (const word of wordsOf(line)) {
367 const colon = word.indexOf(':')
368 const name = colon < 0 ? word : word.slice(0, colon)
369 let rel = known.get(name)
370 if (rel === undefined && !known.has(name)) known.set(name, (rel = toRel(name, base)))
371 // 「ファイル:行番号:中身」の形なら、その行の中身を見ている
372 if (rel && files.has(rel)) hits.push([rel, showsLines && colon >= 0 ? 1 : 0])
373 }
374 }
375 }
376 // 1回の呼び出しでは、同じファイルをいちばん深い状態で1回だけ数える
377 const once = new Map<string, Level>()
378 for (const [path, level] of hits) once.set(path, Math.max(once.get(path) ?? 0, level) as Level)
379 return { hits: [...once], gone: [...new Set(gone)] }
380}
381
382// 読むだけのコマンドか。ヒアドキュメント(スクリプトのことが多い)やファイルへのリダイレクトがあれば違う
383const readOnlyBash = (command: string): boolean => {
384 if (/<</.test(command)) return false
385 for (const seg of maskQuotes(command).split(/\|\||&&|;|\||\n/)) {
386 const tokens = seg.trim().split(/\s+/).filter(t => t !== '')
387 if (tokens.length === 0) continue
388 const cmd = (tokens[0] ?? '').split('/').pop() ?? ''
389 if (cmd === 'git' ? !GIT_READ.has(tokens[1] ?? '') : !READ_ONLY.has(cmd)) return false
390 if ((cmd === 'sed' || cmd === 'perl') && tokens.some(t => /^-[a-zA-Z]*i/.test(t))) return false
391 for (let i = 0; i < tokens.length; i++) {
392 const t = tokens[i] ?? ''
393 const target = t === '>' || t === '>>' ? tokens[i + 1] ?? '' : /^\d?>>?[^>&]/.test(t) ? t.replace(/^\d?>>?/, '') : undefined
394 if (target !== undefined && target !== '/dev/null') return false
395 }
396 }
397 return true
398}
399
400type StatusEntry = { code: string; path: string; from?: string }
401
402// git status --porcelain -z を読む。名前の変更(R)とコピー(C)は、次の欄が元の名前
403const parseStatus = (out: string): StatusEntry[] => {
404 const parts = out.split('\0')
405 const entries: StatusEntry[] = []
406 for (let i = 0; i < parts.length; i++) {
407 const p = parts[i] ?? ''
408 if (!/^[ MADRCTU?!]{2} ./.test(p)) continue
409 const code = p.slice(0, 2)
410 if (/[RC]/.test(code)) {
411 entries.push({ code, path: p.slice(3), from: parts[i + 1] ?? '' })
412 i++
413 } else entries.push({ code, path: p.slice(3) })
414 }
415 return entries
416}
417
418// 作業場所ごとの、すでに消えているファイル。消えたかどうかはこれとの差で決める
419const deletedBefore = new Map<string, Set<string>>()
420
421async function gitStatus($: any, tree: string): Promise<StatusEntry[] | undefined> {
422 const ran = await $.process.run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: tree, timeoutMs: 30_000 })
423 return ran.exitCode === 0 ? parseStatus(ran.stdout) : undefined
424}
425
426async function snapshotDeleted($: any, tree: string): Promise<void> {
427 const entries = await gitStatus($, tree)
428 if (entries) deletedBefore.set(tree, new Set(entries.filter(e => e.code.includes('D')).map(e => e.path)))
429}
430
431// スクリプトなど、コマンドの文字列からは分からない書き換えを拾う。
432// git status で変更のあるファイルのうち、更新時刻がコマンドの実行中(since 以降)のものだけを、そのコマンドが書いたとみなす
433async function changedSince($: any, tree: string, since: number): Promise<{ hits: Hit[]; gone: string[] }> {
434 const entries = await gitStatus($, tree)
435 if (!entries) return { hits: [], gone: [] }
436 const alive = entries.filter(e => !e.code.includes('D') && !(e.path.endsWith('.meta') && files.has(e.path.slice(0, -5)))).slice(0, 300)
437 const stats = await Promise.all(alive.map(e => $.fs.stat(`${tree}/${e.path}`, { resolve: false }).catch(() => undefined)))
438 const hits: Hit[] = []
439 const gone: string[] = []
440 alive.forEach((e, i) => {
441 const mtime: number | undefined = stats[i]?.mtimeMs
442 if (mtime === undefined || mtime < since || stats[i]?.kind !== 'file') return
443 hits.push([e.path, files.has(e.path) ? 3 : 4])
444 if (e.from) gone.push(e.from)
445 })
446 const before = deletedBefore.get(tree)
447 const deleted = entries.filter(e => e.code.includes('D')).map(e => e.path)
448 if (before) for (const p of deleted) if (!before.has(p)) gone.push(p)
449 deletedBefore.set(tree, new Set(deleted))
450 return { hits, gone }
451}
452
453// Unity の .meta のように、本体と対になるだけのファイルは一覧から外す
454const dropMeta = (all: Set<string>): number => {
455 let any = false
456 for (const path of all) {
457 if (path.endsWith('.meta')) {
458 any = true
459 break
460 }
461 }
462 if (!any) return 0
463 // ディレクトリは下から上へたどり、すでに入っている所で止める(その上はもう入っている)
464 const dirs = new Set<string>()
465 for (const path of all) {
466 for (let i = path.lastIndexOf('/'); i >= 0; i = i === 0 ? -1 : path.lastIndexOf('/', i - 1)) {
467 const dir = path.slice(0, i)
468 if (dirs.has(dir)) break
469 dirs.add(dir)
470 }
471 }
472 let dropped = 0
473 for (const path of [...all]) {
474 if (path.endsWith('.meta') && (all.has(path.slice(0, -5)) || dirs.has(path.slice(0, -5)))) {
475 all.delete(path)
476 dropped += 1
477 }
478 }
479 return dropped
480}
481
482const loadFiles = async ($: any) => {
483 root = await $.session.root()
484 shellCwd = root
485 const env = await $.process.run(['printenv', 'HOME'])
486 home = env.stdout.trim()
487 const git = await $.process.run(['git', 'ls-files', '--cached', '--others', '--exclude-standard'], { cwd: root, timeoutMs: 60_000 })
488 if (git.exitCode === 0) {
489 files = new Set(git.stdout.split('\n').filter((l: string) => l !== ''))
490 filesVersion += 1
491 const dropped = dropMeta(files)
492 fileNote = [git.isStdoutTruncated ? '(list cut at 4 MiB)' : '', dropped > 0 ? `(${dropped} .meta hidden)` : ''].filter(s => s !== '').join(' ')
493 return
494 }
495 // git 管理外: 浅い階層だけたどる
496 files = new Set()
497 filesVersion += 1
498 const walk = async (dir: string, level: number) => {
499 if (level > 3 || files.size > 5000) return
500 const entries = await $.fs.list(dir === '' ? root : `${root}/${dir}`)
501 for (const entry of entries) {
502 if (entry.name === '.git' || entry.name === 'node_modules') continue
503 const path = dir === '' ? entry.name : `${dir}/${entry.name}`
504 if (entry.kind === 'directory') await walk(path, level + 1)
505 else files.add(path)
506 }
507 }
508 await walk('', 1)
509 dropMeta(files)
510 filesVersion += 1
511 fileNote = '(not a git repo: 3 levels only)'
512}
513
514// ---- ツリー。骨組みはファイル一覧が変わったときだけ作り、描画では触ったファイルの分だけ上乗せする ----
515
516type Stat = { touched: number; c: number[]; s: number }
517// 骨組み: ディレクトリの親子、ディレクトリごとのファイル数、ディレクトリ直下のファイル。ファイル一覧だけで決まる
518// dirs と leaves は一覧に出てきた順。sorted は名前順に並べたもので、描画で要るディレクトリの分だけ作って持っておく
519type Skeleton = {
520 totals: Map<string, number>
521 dirs: Map<string, string[]>
522 leaves: Map<string, string[]>
523 // ファイルの、一覧での順番
524 order: Map<string, number>
525 sorted: { dirs: Map<string, string[]>; leaves: Map<string, string[]> }
526}
527
528// path が属するディレクトリを上から順に返す
529const dirsOf = (path: string): string[] => {
530 const out: string[] = []
531 for (let i = path.indexOf('/'); i >= 0; i = path.indexOf('/', i + 1)) out.push(path.slice(0, i))
532 return out
533}
534
535let skeleton: { version: number; value: Skeleton } | undefined
536
537const skeletonOf = (): Skeleton => {
538 if (skeleton && skeleton.version === filesVersion) return skeleton.value
539 const totals = new Map<string, number>()
540 const dirSets = new Map<string, Set<string>>()
541 const leaves = new Map<string, string[]>()
542 const order = new Map<string, number>()
543 for (const path of files) {
544 order.set(path, order.size)
545 let parent = ''
546 for (const dir of dirsOf(path)) {
547 let set = dirSets.get(parent)
548 if (!set) dirSets.set(parent, (set = new Set()))
549 set.add(dir)
550 totals.set(dir, (totals.get(dir) ?? 0) + 1)
551 parent = dir
552 }
553 let list = leaves.get(parent)
554 if (!list) leaves.set(parent, (list = []))
555 list.push(path)
556 }
557 const dirs = new Map<string, string[]>()
558 for (const [parent, set] of dirSets) dirs.set(parent, [...set])
559 const value = { totals, dirs, leaves, order, sorted: { dirs: new Map(), leaves: new Map() } }
560 skeleton = { version: filesVersion, value }
561 return value
562}
563
564// 一覧の最後に足したファイルを骨組みに入れる。作り直したときと同じ形になる(新しいディレクトリは親の子の最後に付く)
565const addToSkeleton = (sk: Skeleton, path: string) => {
566 sk.order.set(path, sk.order.size)
567 let parent = ''
568 for (const dir of dirsOf(path)) {
569 if (!sk.totals.has(dir)) {
570 let kids = sk.dirs.get(parent)
571 if (!kids) sk.dirs.set(parent, (kids = []))
572 kids.push(dir)
573 sk.sorted.dirs.delete(parent)
574 }
575 sk.totals.set(dir, (sk.totals.get(dir) ?? 0) + 1)
576 parent = dir
577 }
578 let list = sk.leaves.get(parent)
579 if (!list) sk.leaves.set(parent, (list = []))
580 list.push(path)
581 sk.sorted.leaves.delete(parent)
582}
583
584// 骨組みの一覧を名前順に並べたもの。並べるのはディレクトリごとに最初の1回だけ
585const sortedOf = (from: Map<string, string[]>, cache: Map<string, string[]>, dir: string): string[] => {
586 let list = cache.get(dir)
587 if (!list) cache.set(dir, (list = [...(from.get(dir) ?? [])].sort()))
588 return list
589}
590
591let statCache: { key: unknown; version: number; stats: Map<string, Stat> } | undefined
592// 触ったファイルの dirsOf。パスごとに決まるので、描画をまたいで使い回す
593const dirsMemo = new Map<string, string[]>()
594
595// 描画のたびに回すのは、触ったファイルだけ
596const statsOf = (all: Record<string, Touch>): Map<string, Stat> => {
597 if (statCache && statCache.key === all && statCache.version === filesVersion) return statCache.stats
598 const stats = new Map<string, Stat>()
599 for (const [path, touch] of Object.entries(all)) {
600 let dirs = dirsMemo.get(path)
601 if (!dirs) dirsMemo.set(path, (dirs = dirsOf(path)))
602 for (const dir of dirs) {
603 let stat = stats.get(dir)
604 if (!stat) stats.set(dir, (stat = { touched: 0, c: [0, 0, 0, 0, 0], s: -1 }))
605 stat.touched += 1
606 stat.c[touch.s] = (stat.c[touch.s] ?? 0) + 1
607 stat.s = Math.max(stat.s, touch.s)
608 }
609 }
610 statCache = { key: all, version: filesVersion, stats }
611 return stats
612}
613
614// 表示用の行。dir と file の key はパス、more と quiet は「ディレクトリ#more」「ディレクトリ#quiet」
615type Row =
616 | { kind: 'dir'; key: string; indent: number; name: string; open: boolean; total: number; stat?: Stat }
617 | { kind: 'file'; key: string; indent: number; name: string; touch?: Touch; now: boolean }
618 | { kind: 'more' | 'quiet'; key: string; indent: number; text: string; open: boolean }
619
620// ファイル行の上限。読んだ・書いたものと直近のものは必ず出し、残りの枠を部分読取と名前だけで埋める
621const MAX_FILES = 8
622
623const base = (path: string) => path.split('/').pop() ?? path
624const sum = (c: number[]) => c.reduce((n, x) => n + x, 0)
625
626const toRows = (all: Record<string, Touch>, openState: Record<string, boolean>, now: Now | null): Row[] => {
627 const sk = skeletonOf()
628 const stats = statsOf(all)
629 const hot = (d: string) => (stats.get(d)?.s ?? -1) >= 0
630 const byDepth = (a: string, b: string) => {
631 const sa = stats.get(a)!
632 const sb = stats.get(b)!
633 return sb.s - sa.s || sb.touched - sa.touched || a.localeCompare(b)
634 }
635 // 触ったファイルを直下のディレクトリごとに、一覧の順で。ディレクトリ直下の全ファイルを見て回らずに済ませる
636 const touchedIn = new Map<string, string[]>()
637 for (const path in all) {
638 if (!all[path] || !sk.order.has(path)) continue
639 const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
640 let list = touchedIn.get(parent)
641 if (!list) touchedIn.set(parent, (list = []))
642 list.push(path)
643 }
644 for (const list of touchedIn.values()) list.sort((a, b) => sk.order.get(a)! - sk.order.get(b)!)
645 const rows: Row[] = []
646 const dirRow = (key: string, indent: number, name: string, fallback: boolean): Row => {
647 const stat = stats.get(key)
648 return { kind: 'dir', key, indent, name, open: openState[key] ?? fallback, total: sk.totals.get(key) ?? 0, ...(stat ? { stat } : {}) }
649 }
650
651 // extra は、つなげて1行にした上の階層の未接触。名前は from からの相対で出す
652 const visit = (dir: string, indent: number, extra: string[] = [], from = dir) => {
653 const childDirs = sk.dirs.get(dir) ?? []
654 for (const first of childDirs.filter(hot).sort(byDepth)) {
655 // 触った子ディレクトリが1つだけで、直下に触ったファイルがない間はつなげて1行にする
656 let end = first
657 let name = base(first) + '/'
658 const cold: string[] = []
659 for (;;) {
660 const kids = sk.dirs.get(end) ?? []
661 const hotKids = kids.filter(hot)
662 const leaves = sk.leaves.get(end) ?? []
663 const only = hotKids[0]
664 if (hotKids.length !== 1 || only === undefined || touchedIn.has(end)) break
665 for (const k of kids) if (!hot(k)) cold.push(k)
666 for (const f of leaves) cold.push(f)
667 end = only
668 name += base(only) + '/'
669 }
670 const row = dirRow(end, indent, name, true)
671 rows.push(row)
672 if (row.kind === 'dir' && row.open) visit(end, indent + 1, cold, first.includes('/') ? first.slice(0, first.lastIndexOf('/')) : '')
673 }
674
675 const leaves = sk.leaves.get(dir) ?? []
676 const touched = [...(touchedIn.get(dir) ?? [])]
677 .sort((a, b) => all[b]!.s - all[a]!.s || sum(all[b]!.c) - sum(all[a]!.c) || a.localeCompare(b))
678 const showAll = openState[`${dir}#more`] === true
679 const must = new Set(touched.filter(f => all[f]!.s >= 2 || all[f]!.d === true || now?.path === f))
680 let room = Math.max(0, MAX_FILES - must.size)
681 const shown = touched.filter(f => showAll || must.has(f) || room-- > 0)
682 for (const f of shown) rows.push({ kind: 'file', key: f, indent, name: base(f), touch: all[f]!, now: now?.path === f })
683 const shownSet = new Set(shown)
684 const hidden = touched.filter(f => !shownSet.has(f))
685 if (hidden.length > 0) {
686 const c = [0, 0, 0, 0, 0]
687 for (const f of hidden) c[all[f]!.s] = (c[all[f]!.s] ?? 0) + 1
688 const text = DEEP_FIRST.filter(i => (c[i] ?? 0) > 0).map(i => `${fmt(c[i] ?? 0)} ${LEVELS[i]}`).join(' · ')
689 rows.push({ kind: 'more', key: `${dir}#more`, indent, text, open: false })
690 } else if (showAll && touched.length > MAX_FILES) {
691 rows.push({ kind: 'more', key: `${dir}#more`, indent, text: 'show fewer', open: true })
692 }
693
694 // 触っていないディレクトリとファイルは、1行に名前だけ並べる(名前順のディレクトリ、名前順のファイル、上の階層の分)
695 // 畳んでいる間は件数と先頭 12 件しか使わないので、全部は並べない
696 const open = openState[`${dir}#quiet`] === true
697 const hotDirs = childDirs.reduce((n, d) => (hot(d) ? n + 1 : n), 0)
698 const count = childDirs.length - hotDirs + leaves.length - touched.length + extra.length
699 if (count === 0) return
700 const quiet: string[] = []
701 const take = open ? Infinity : 12
702 for (const d of hotDirs === childDirs.length ? [] : sortedOf(sk.dirs, sk.sorted.dirs, dir)) {
703 if (quiet.length >= take) break
704 if (!hot(d)) quiet.push(d)
705 }
706 const touchedSet = new Set(touched)
707 for (const f of touched.length === leaves.length ? [] : sortedOf(sk.leaves, sk.sorted.leaves, dir)) {
708 if (quiet.length >= take) break
709 if (!touchedSet.has(f)) quiet.push(f)
710 }
711 for (const p of extra) {
712 if (quiet.length >= take) break
713 quiet.push(p)
714 }
715 const fromExtra = new Set(extra)
716 const label = (p: string) => {
717 const rel = fromExtra.has(p) && from !== '' ? p.slice(from.length + 1) : fromExtra.has(p) ? p : base(p)
718 return sk.totals.has(p) ? rel + '/' : rel
719 }
720 rows.push({ kind: 'quiet', key: `${dir}#quiet`, indent, text: `${fmt(count)} untouched: ${quiet.slice(0, 12).map(label).join(' ')}`, open })
721 if (!open) return
722 for (const p of quiet) {
723 if (sk.totals.has(p)) {
724 const row = dirRow(p, indent + 1, label(p), false)
725 rows.push(row)
726 if (row.kind === 'dir' && row.open) visit(p, indent + 2)
727 } else {
728 rows.push({ kind: 'file', key: p, indent: indent + 1, name: label(p), now: false })
729 }
730 }
731 }
732 visit('', 0)
733 return rows
734}
735
736// ---- 見た目の部品 ----
737
738type Seg = { text: string; color: string }
739
740// 1.8k、50k、1.2M のように縮める
741const fmt = (n: number): string =>
742 n < 1000 ? String(n)
743 : n < 10_000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
744 : n < 1_000_000 ? `${Math.round(n / 1000)}k`
745 : `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
746
747const elapsed = (from: number, now: number): string => {
748 if (from <= 0) return ''
749 const m = Math.max(0, Math.round((now - from) / 60_000))
750 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
751}
752
753// 帯の「触っていない部分」。■ はマップのマスと同じ暗さ、━ の残りは ─ で描く
754const SQ_TRACK = '#3a3e48'
755const LINE_TRACK = '#3f4654'
756
757// 状態ごとの件数を width マスの ■ に割り振る。1件でもあれば1マスは取る。残りは触っていない部分。
758// █ だと上下の行の帯とつながって塊に見えるので、マスの間に隙間ができる ■ を使う
759const shareBar = (c: number[], total: number, width: number): Seg[] => {
760 const parts = DEEP_FIRST.filter(i => (c[i] ?? 0) > 0).map(i => ({ i, n: Math.max(1, Math.round(((c[i] ?? 0) / Math.max(1, total)) * width)) }))
761 let used = parts.reduce((n, p) => n + p.n, 0)
762 while (used > width) {
763 const big = parts.reduce((a, b) => (b.n > a.n ? b : a))
764 big.n -= 1
765 used -= 1
766 }
767 const segs = parts.map(p => ({ text: '■'.repeat(p.n), color: COLORS[p.i] as string }))
768 if (used < width) segs.push({ text: '■'.repeat(width - used), color: SQ_TRACK })
769 return segs
770}
771
772// ファイル行の帯は「全部は読んでいない」ときだけ出す(全部読んだ・書いたことは名前の色が言っている)。
773// 行範囲が分かる部分読取は読んだところを ━、残りを ─。grep の一致行だけなら点線
774const fileBar = (t: Touch): Seg[] => {
775 if (t.d === true || t.s !== 1) return []
776 if (!t.r || !t.n) return [{ text: '┄'.repeat(BAR), color: COLORS[1] }]
777 const cells = Array.from({ length: BAR }, () => false)
778 for (const [a, b] of t.r) {
779 for (let k = Math.floor(((a - 1) / t.n) * BAR); k < Math.min(BAR, Math.ceil((b / t.n) * BAR)); k++) cells[k] = true
780 }
781 const segs: Seg[] = []
782 for (const on of cells) {
783 const last = segs[segs.length - 1]
784 const color = on ? COLORS[1] : LINE_TRACK
785 if (last && last.color === color) last.text += on ? '━' : '─'
786 else segs.push({ text: on ? '━' : '─', color })
787 }
788 return segs
789}
790
791const rangeOf = (t: Touch): string => {
792 const first = t.r?.[0]
793 if (t.s !== 1 || !first) return ''
794 return `${first[0]}–${first[1]}${t.r!.length > 1 ? ` +${t.r!.length - 1}` : ''}`
795}
796
797const counts = (all: Record<string, Touch>) => {
798 const c = [0, 0, 0, 0, 0]
799 for (const touch of Object.values(all)) c[touch.s] = (c[touch.s] ?? 0) + 1
800 return c
801}
802
803const goneCount = (all: Record<string, Touch>) => Object.values(all).filter(t => t.d === true).length
804
805const glyphOf = (row: Row) => (row.kind === 'file' ? ' ' : row.open ? '▾' : '▸')
806
807// ---- 操作 ----
808
809async function save($: any, keep: boolean, reason = '', keepLabel = false): Promise<string> {
810 const all: Record<string, Touch> = await read($, touches)
811 const seg: Segment = await read($, segment)
812 let note = 'discarded'
813 if (keep && Object.keys(all).length > 0) {
814 const now = await $.clock.now()
815 const stamp = new Date(now).toISOString().replace(/[:.]/g, '-')
816 const path = `${home}/${LOG_DIR}/${stamp}.json`
817 const instructions: Loaded = await read($, loadedAtom)
818 const body = { levels: LEVELS, label: seg.label, root, startedAt: new Date(seg.startedAt).toISOString(), endedAt: new Date(now).toISOString(), files: all, instructions }
819 await $.fs.write(path, JSON.stringify(body, null, 2))
820 note = `${reason}saved ~/${LOG_DIR}/${stamp}.json`
821 } else if (keep) {
822 note = reason === '' ? 'nothing to save' : ''
823 }
824 await update($, touches, () => ({}))
825 touchesGen += 1
826 await update($, nowAtom, () => null)
827 const startedAt = await $.clock.now()
828 await update($, segment, () => ({ label: keepLabel ? seg.label : '', startedAt, note }))
829 await trace($, keep ? 'save' : 'discard', { label: seg.label, files: Object.keys(all).length, note })
830 return note
831}
832
833// 最小化は好みなので $.store にも残す。/clear で $.state ごと消えても戻せるように
834async function setMinimized($: any, value: boolean): Promise<void> {
835 await update($, minimized, () => value)
836 await $.store.set('pref.minimized', value)
837}
838
839async function loadPrefs($: any): Promise<void> {
840 const min = await $.store.get('pref.minimized')
841 if (typeof min === 'boolean') await update($, minimized, () => min)
842 const map = await $.store.get('pref.map')
843 if (typeof map === 'boolean') await update($, mapAtom, () => map)
844}
845
846// /clear のあと、$.state が作り直された後で好みと区間の開始時刻を戻す。
847// 作り直しと SessionStart の順は決まっていないので、/clear 後の最初のツール呼び出しや読み込みでも戻す
848let afterClear = false
849
850async function restoreAfterClear($: any, done: boolean): Promise<void> {
851 if (!afterClear) return
852 await loadPrefs($)
853 const now = await $.clock.now()
854 await update($, segment, (seg: Segment) => (seg.startedAt > 0 ? seg : { ...seg, startedAt: now }))
855 if (done) afterClear = false
856}
857
858async function minimize($: any): Promise<void> {
859 await setMinimized($, true)
860 await $.ui.close({ id: PANE })
861 await trace($, 'minimize')
862}
863
864// 先に開いてみて、描かれたときだけ最小化を解く。描かれずに保留されたら(人の操作でなく開いたとき、狭い端末では保留される)
865// 帯を残したまま知らせる。帯もペインも消えて何も見えなくなるのを防ぐ
866async function restore($: any): Promise<void> {
867 const opened = await $.ui.open({ id: PANE, title: 'Touch map' })
868 if (opened?.isPlaced === false) {
869 $.ui.toast('Touch map: the terminal is too narrow to open the pane here. Run /touch-map to open it.')
870 await trace($, 'restore', { placed: false, reason: opened.reason })
871 return
872 }
873 await setMinimized($, false)
874 await trace($, 'restore', { placed: true })
875}
876
877async function setLabel($: any, value: string): Promise<void> {
878 await update($, segment, (s: Segment) => ({ ...s, label: value.trim() }))
879 await trace($, 'label', { label: value.trim() })
880}
881
882async function toggle($: any, key: string, open: boolean): Promise<void> {
883 await update($, folds, (all: Record<string, boolean>) => ({ ...all, [key]: !open }))
884 await trace($, 'fold', { key, open: !open })
885}
886
887// ---- アクティビティマップ ----
888// リポジトリ全体を草のようなマス目に並べ、触ったマスを光らせてからゆっくり状態の色に戻す。
889// 位置を読ませるためではなく、作業のリズムが見えて楽しいためのもの
890
891// 長方形のマス目を、近いものが近くに集まる順(一般化ヒルベルト曲線 gilbert2d)でたどる
892const gilbert = (width: number, height: number): [number, number][] => {
893 const out: [number, number][] = []
894 const walk = (x: number, y: number, ax: number, ay: number, bx: number, by: number): void => {
895 const w = Math.abs(ax + ay)
896 const h = Math.abs(bx + by)
897 const dax = Math.sign(ax), day = Math.sign(ay), dbx = Math.sign(bx), dby = Math.sign(by)
898 if (h === 1) {
899 for (let i = 0; i < w; i++, x += dax, y += day) out.push([x, y])
900 return
901 }
902 if (w === 1) {
903 for (let i = 0; i < h; i++, x += dbx, y += dby) out.push([x, y])
904 return
905 }
906 let ax2 = Math.floor(ax / 2), ay2 = Math.floor(ay / 2)
907 let bx2 = Math.floor(bx / 2), by2 = Math.floor(by / 2)
908 const w2 = Math.abs(ax2 + ay2)
909 const h2 = Math.abs(bx2 + by2)
910 if (2 * w > 3 * h) {
911 if (w2 % 2 && w > 2) {
912 ax2 += dax
913 ay2 += day
914 }
915 walk(x, y, ax2, ay2, bx, by)
916 walk(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by)
917 } else {
918 if (h2 % 2 && h > 2) {
919 bx2 += dbx
920 by2 += dby
921 }
922 walk(x, y, bx2, by2, ax2, ay2)
923 walk(x + bx2, y + by2, ax, ay, bx - bx2, by - by2)
924 walk(x + (ax - dax) + (bx2 - dbx), y + (ay - day) + (by2 - dby), -bx2, -by2, -(ax - ax2), -(ay - ay2))
925 }
926 }
927 if (width >= height) walk(0, 0, width, 0, 0, height)
928 else walk(0, 0, 0, height, width, 0)
929 return out
930}
931
932const MAP_ROWS = 6
933// 光ってから状態の色に戻るまで。最初の POP_MS は真っ白に光り、上下左右にも RIPPLE_MS だけ波紋が広がる
934const FADE_MS = 4000
935const POP_MS = 350
936const RIPPLE_MS = 800
937const MAP_EMPTY = '#2b303a'
938
939// マス目の配置。ファイル一覧と幅が変わったときだけ作り直す。パス順に並べて曲線に沿って詰める
940// asked は求められた幅、width は実際に使う幅(ファイルが少なければ縮む)
941type MapLayout = { asked: number; width: number; cells: string[][]; pos: [number, number][]; cellOf: Map<string, number>; version: number }
942let mapLayout: MapLayout | undefined
943
944// 全ファイルを名前順に並べたもの。ファイルが1件増えただけなら、並べ直さずに差し込む
945let sortedFiles: { version: number; list: string[] } | undefined
946
947const insertSorted = (list: string[], path: string) => {
948 let lo = 0
949 let hi = list.length
950 while (lo < hi) {
951 const mid = (lo + hi) >> 1
952 if ((list[mid] ?? '') < path) lo = mid + 1
953 else hi = mid
954 }
955 list.splice(lo, 0, path)
956}
957
958const sortedFilesOf = (): string[] => {
959 if (!sortedFiles || sortedFiles.version !== filesVersion) sortedFiles = { version: filesVersion, list: [...files].sort() }
960 return sortedFiles.list
961}
962
963const layoutOf = (width: number): MapLayout => {
964 if (mapLayout && mapLayout.version === filesVersion && mapLayout.asked === width) return mapLayout
965 const sorted = sortedFilesOf()
966 // ファイルがマスより少なければ幅を縮める。多ければ全マスに均等に配り、空きマスを作らない
967 const w = Math.max(1, Math.min(width, Math.ceil(sorted.length / MAP_ROWS)))
968 const slots = Math.min(w * MAP_ROWS, sorted.length)
969 const cells: string[][] = []
970 const cellOf = new Map<string, number>()
971 sorted.forEach((path, i) => {
972 const k = Math.floor((i * slots) / sorted.length)
973 ;(cells[k] ??= []).push(path)
974 cellOf.set(path, k)
975 })
976 mapLayout = { asked: width, width: w, cells, pos: gilbert(w, MAP_ROWS), cellOf, version: filesVersion }
977 return mapLayout
978}
979
980// 光っているマスと、光った時刻
981const flashes = new Map<number, { at: number; color: number }>()
982// 光る色。本体の操作は白、サブエージェントの操作は黄色
983const FLASH_MAIN = 0xffffff
984const FLASH_SUB = 0xfde047
985let ticking: { cancel: () => void } | undefined
986// 1 秒に 10 回の塗り直しで毎回 touches を読まないよう、マスごとの状態を持っておく。
987// touches を書き終えるたびに touchesGen を進め、読んだときの世代と違えば読み直す
988let touchesGen = 0
989let tickStates: { gen: number; layout: MapLayout; states: CellStates } | undefined
990
991const hex = (c: string) => parseInt(c.slice(1), 16)
992const mix = (a: number, b: number, t: number) => {
993 const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - t) + ((b >> s) & 255) * t) << s
994 return ch(16) | ch(8) | ch(0)
995}
996
997// マスごとのいちばん深い状態(触っていなければ -1)と削除の印。全ファイルではなく、触ったファイルからマスを引く
998type CellStates = { levels: Int8Array; gone: Uint8Array }
999const cellStatesOf = (all: Record<string, Touch>, layout: MapLayout): CellStates => {
1000 const levels = new Int8Array(layout.cells.length).fill(-1)
1001 const gone = new Uint8Array(layout.cells.length)
1002 for (const p in all) {
1003 const k = layout.cellOf.get(p)
1004 const t = all[p]
1005 if (k === undefined || !t) continue
1006 if (t.s > (levels[k] ?? -1)) levels[k] = t.s
1007 if (t.d === true) gone[k] = 1
1008 }
1009 return { levels, gone }
1010}
1011
1012// Raster の cells([文字, 前景, 背景] の u32 を base64 にしたもの)を作る。マスは「■」と空白1つ
1013const mapCells = (all: Record<string, Touch>, layout: MapLayout, now: number): string => paintCells(cellStatesOf(all, layout), layout, now)
1014
1015const paintCells = (states: CellStates, layout: MapLayout, now: number): string => {
1016 const columns = layout.width * 2 - 1
1017 const words = new Uint32Array(columns * MAP_ROWS * 3)
1018 const DEFAULT = 0x01000000
1019 for (let i = 0; i < words.length; i += 3) {
1020 words[i] = 0x20
1021 words[i + 1] = DEFAULT
1022 words[i + 2] = DEFAULT
1023 }
1024 // 波紋: 光ったマスの上下左右を、少しのあいだ弱く光らせる。キーは上下左右に1マスはみ出す分も入る (x+1, y+1) の通し番号
1025 const span = layout.width + 2
1026 const ripple = new Map<number, { strength: number; color: number }>()
1027 for (const [k, lit] of flashes) {
1028 const at = layout.pos[k]
1029 const age = now - lit.at
1030 if (!at || age >= RIPPLE_MS) continue
1031 const strength = 0.45 * (1 - age / RIPPLE_MS)
1032 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
1033 const key = (at[1] + dy + 1) * span + at[0] + dx + 1
1034 if (strength > (ripple.get(key)?.strength ?? 0)) ripple.set(key, { strength, color: lit.color })
1035 }
1036 }
1037 layout.cells.forEach((_paths, k) => {
1038 const at = layout.pos[k]
1039 if (!at) return
1040 const level = states.levels[k] ?? -1
1041 const gone = states.gone[k] === 1
1042 const base = hex(gone ? GONE : level >= 0 ? COLORS[level] ?? MAP_EMPTY : MAP_EMPTY)
1043 let fg = base
1044 const lit = flashes.get(k)
1045 const age = lit === undefined ? FADE_MS : now - lit.at
1046 if (lit && age < POP_MS) {
1047 // 光った瞬間: 本体なら白、サブエージェントなら黄色
1048 fg = lit.color
1049 } else if (lit && age < FADE_MS) {
1050 const left = 1 - (age - POP_MS) / (FADE_MS - POP_MS)
1051 fg = mix(base, lit.color, 0.9 * left)
1052 } else {
1053 const glow = ripple.get((at[1] + 1) * span + at[0] + 1)
1054 if (glow) fg = mix(base, glow.color, glow.strength)
1055 }
1056 const i = (at[1] * columns + at[0] * 2) * 3
1057 words[i] = 0x25a0
1058 words[i + 1] = fg
1059 words[i + 2] = DEFAULT
1060 })
1061 return (new Uint8Array(words.buffer) as unknown as { toBase64: () => string }).toBase64()
1062}
1063
1064// 触ったマスを光らせ、光っている間だけ 1 秒に 10 回塗り直す
1065async function flash($: any, paths: string[], sub = false): Promise<void> {
1066 // 新しいファイルを記録した直後は配置が古い(そのファイルのマスが無く、ほかのマスもずれうる)ので、ここで作り直す
1067 const layout = mapLayout && layoutOf(mapLayout.asked)
1068 if (!layout || paths.length === 0) return
1069 const now = await $.clock.now()
1070 for (const p of paths) {
1071 const k = layout.cellOf.get(p)
1072 if (k !== undefined) flashes.set(k, { at: now, color: sub ? FLASH_SUB : FLASH_MAIN })
1073 }
1074 if (ticking) return
1075 ticking = $.clock.every(100, () => void tick($))
1076}
1077
1078async function tick($: any): Promise<void> {
1079 const now = await $.clock.now()
1080 for (const [k, lit] of flashes) if (now - lit.at >= FADE_MS) flashes.delete(k)
1081 const layout = mapLayout
1082 if (layout && (await read($, mapAtom))) {
1083 try {
1084 if (!tickStates || tickStates.gen !== touchesGen || tickStates.layout !== layout) {
1085 const gen = touchesGen
1086 tickStates = { gen, layout, states: cellStatesOf(await read($, touches), layout) }
1087 }
1088 await $.ui.blit({ requestId: PANE, key: 'map', cells: paintCells(tickStates.states, layout, now) })
1089 } catch {
1090 // ペインを閉じている間は塗るものがない
1091 }
1092 }
1093 if (flashes.size === 0) {
1094 ticking?.cancel()
1095 ticking = undefined
1096 }
1097}
1098
1099// ---- デバッグ出力 ----
1100
1101// ペインに出ているはずの中身を、色の代わりに [状態] を付けたテキストにする
1102async function viewText($: any): Promise<string> {
1103 const all: Record<string, Touch> = await read($, touches)
1104 const seg: Segment = await read($, segment)
1105 const loaded: Loaded = await read($, loadedAtom)
1106 const isMin: boolean = await read($, minimized)
1107 const now: Now | null = await read($, nowAtom)
1108 const rows = toRows(all, await read($, folds), now)
1109 const c = counts(all)
1110 const lines = [
1111 `# Touch map${isMin ? ' (minimized)' : ''}`,
1112 `task: ${seg.label === '' ? '-' : seg.label} ${elapsed(seg.startedAt, await $.clock.now())} · touched ${fmt(Object.keys(all).length)}/${fmt(files.size)} ${fileNote}`.trimEnd(),
1113 `legend: ${DEEP_FIRST.map(i => `${LEVELS[i]} ${c[i]}`).join(' · ')} · deleted ${goneCount(all)} · auto ${Object.keys(loaded).length}`,
1114 ]
1115 if (now) lines.push(`now: ${now.path}`)
1116 if (seg.note !== '') lines.push(`note: ${seg.note}`)
1117 lines.push('')
1118 rows.forEach((row, i) => {
1119 if (paneRows > 0 && i === paneRows) lines.push(`---- below here does not fit in the pane (${paneRows} rows) ----`)
1120 const pad = ' '.repeat(row.indent)
1121 if (row.kind === 'dir') lines.push(`${pad}${glyphOf(row)} ${row.name} ${fmt(row.stat?.touched ?? 0)}/${fmt(row.total)}`)
1122 else if (row.kind === 'file') {
1123 const tag = row.touch?.d ? 'deleted' : row.touch ? LEVELS[row.touch.s] : '----'
1124 const range = row.touch ? rangeOf(row.touch) : ''
1125 lines.push(`${pad}${row.now ? '>' : ' '} [${tag}] ${row.name}${range === '' ? '' : ` ${range}`}`)
1126 } else lines.push(`${pad}${glyphOf(row)} ${row.text}`)
1127 })
1128 const auto = Object.keys(loaded)
1129 if (auto.length > 0) lines.push('', `◆ ${auto.join(' ')}`)
1130 return lines.join('\n') + '\n'
1131}
1132
1133// 受け取ったイベントと判定結果を残し、ペインの中身と一緒に書き出す
1134async function trace($: any, ev: string, data: Record<string, unknown> = {}): Promise<void> {
1135 if (!debug) return
1136 debugLines.push(JSON.stringify({ t: new Date(await $.clock.now()).toISOString(), ev, ...data, renderMs }))
1137 if (debugLines.length > 2000) debugLines = debugLines.slice(-2000)
1138 if (home === '' || sessionId === '') return
1139 const base = `${home}/${DEBUG_DIR}/${sessionId}`
1140 queuedView = { base, view: await viewText($) }
1141 // 書き込みは順番に。後から呼ばれたほうが必ず新しい中身で上書きする
1142 writing = writing.then(() => writeDebug($)).then(() => undefined, () => undefined)
1143 await writing
1144}
1145
1146// 溜まっている最新の中身を書く。前の呼び出しが先に書いていれば、何もしない
1147async function writeDebug($: any): Promise<void> {
1148 const next = queuedView
1149 if (!next) return
1150 queuedView = undefined
1151 const key = `${next.base}\n${next.view}`
1152 await Promise.all([
1153 $.fs.write(`${next.base}.events.jsonl`, debugLines.join('\n') + '\n'),
1154 ...(key === writtenView ? [] : [$.fs.write(`${next.base}.view.txt`, next.view)]),
1155 ])
1156 writtenView = key
1157}
1158
1159// reload でモジュールの変数は消えるので、同じセッションの出力があれば続きから書く
1160async function resumeTrace($: any): Promise<void> {
1161 if (!debug) return
1162 sessionId = await $.session.id()
1163 const path = `${home}/${DEBUG_DIR}/${sessionId}.events.jsonl`
1164 if (!(await $.fs.exists(path))) return
1165 const text: string = await $.fs.read(path, { as: 'text' })
1166 debugLines = [...text.split('\n').filter(l => l !== ''), ...debugLines]
1167}
1168
1169const hitText = (hits: Hit[]) => hits.map(([path, level, lines]) => `${LEVELS[level]}:${path}${lines ? `#${lines.from}-${lines.to}/${lines.total}` : ''}`)
1170
1171// Read の結果から、読んだ範囲が全体かどうかを決める。自動で途中までに切られたときも部分とみなす
1172const readLevel = (result: unknown): { level: Level; lines?: Lines } => {
1173 const file = (result as { type?: string; file?: { startLine?: number; numLines?: number; totalLines?: number; truncatedByTokenCap?: boolean } } | undefined)?.file
1174 const from = file?.startLine
1175 const count = file?.numLines
1176 const total = file?.totalLines
1177 if ((result as { type?: string } | undefined)?.type !== 'text' || from === undefined || count === undefined || total === undefined) return { level: 2 }
1178 const lines = { from, to: from + count - 1, total }
1179 return { level: file?.truncatedByTokenCap !== true && coversAll([[lines.from, lines.to]], total) ? 2 : 1, lines }
1180}
1181
1182export const register: Register = on => {
1183 on('session.start', async ($, e, next) => {
1184 const t0 = await $.clock.now()
1185 await loadFiles($)
1186 await loadWorktrees($)
1187 await snapshotDeleted($, root)
1188 // reload の前に触ったファイルは、消したものでもツリーに残す
1189 for (const path of Object.keys(await read($, touches))) addFile(path)
1190 const loadMs = (await $.clock.now()) - t0
1191 await loadPrefs($)
1192 debug = (await $.store.get('debug')) === true
1193 await resumeTrace($)
1194 const waiting = pending
1195 pending = []
1196 // 起動時の読み込みは debug の設定を読む前に届くので、ここで残す
1197 for (const one of waiting) {
1198 await recordLoad($, one.path, one.reason)
1199 await trace($, 'InstructionsLoaded', { path: one.path, reason: one.reason, beforeStart: true })
1200 }types/index.d.ts 33 lines1// 0: listed(名前だけ) 1: partial(grep の一致行や Read の行範囲だけ見た) 2: read 3: edited 4: created
2export type Level = 0 | 1 | 2 | 3 | 4
3
4// ファイルごとの記録。s はいちばん深い状態、c は状態ごとの回数(ツール呼び出し1回につき1)
5// r は部分読取で読んだ行の範囲(重なりはまとめる)、n はファイルの行数。合わせて全体になったら読み取りに上げる
6// d は rm・mv で消したファイル。作り直したら外す
7export type Touch = { s: Level; c: number[]; r?: [number, number][]; n?: number; d?: true }
8
9// 自動で読み込まれた指示ファイル。キーはリポジトリからの相対パス(外のファイルは ~ 始まり)、値は読み込まれた理由
10export type Loaded = Record<string, string>
11
12// 今の区間。startedAt はミリ秒、note は最後の操作の結果
13export type Segment = { label: string; startedAt: number; note: string }
14
15// 直近に触ったファイル
16export type Now = { path: string; level: Level }
17
18declare module 'claude-code' {
19 interface PluginState {
20 'touch-map': {
21 touches: Record<string, Touch>
22 segment: Segment
23 minimized: boolean
24 loaded: Loaded
25 // 開閉を人が切り替えたところだけ持つ。キーはディレクトリ、または「ディレクトリ#more」「ディレクトリ#quiet」
26 folds: Record<string, boolean>
27 now: Now | null
28 // ペイン上部のアクティビティマップを出すか
29 map: boolean
30 }
31 }
32}
33