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

<h1 align="center">Awesome Claude Code Mods</h1>
<a id="showcase"></a>
<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>
<a id="browse-by-category"></a>
<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>
Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods
Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.
Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.
<a id="usage"></a>
Track model usage, costs, effort and quotas.
<a id="agents"></a>
Coordinate agents and follow their actual work.
hooks/register.tsx 1522 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