SLOPSHOPPER

touch-map

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · touch-map
│ ┃ Touch map ✕ › fix the failing auth test and add an audit log call │ ┃ task name ⏎ set 0m [ save ] [ discard ] │ ┃ 3/3 ■created 2 ■edited 1 ⏺ Read(src/auth.ts) │ ┃ now src/audit.ts ⎿ Read 6 lines │ ┃ ■ ⏺ Update(src/auth.ts) │ ┃ ■ ⎿ Added 2 lines, removed 1 line │ ┃ ■ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ▾ src/ ■■■■■■■■■■ 3/3 │ ┃ audit.ts › /touch-map │ ┃ cache.ts ⎿ touch-map: Touch map opened │ ┃ auth.ts │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Touch map
task name ⏎ set 0m [ save ] [ discard ] 3/3 ■created 2 ■edited 1 now src/audit.ts ■ ■ ■ ──────────────────────────────────────────────────────── ▾ src/ ■■■■■■■■■■ 3/3 audit.ts cache.ts auth.ts
README

<h1 align="center">Awesome Claude Code Mods</h1>

<a id="showcase"></a>

Mod showcase

<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>

Usage Mods

Browse Usage Mods

Track model usage, costs, effort and quotas.

  • Auto Effort · arthur-fontaine - Let Jev select Claude's reasoning effort for each prompt, adapting the configured effort level to the incoming request rather than choosing it manually every time.
  • Automatic Model and Effort Router · sofanaja44 - Choose a Claude model and reasoning effort according to task difficulty, with rules that limit unnecessary prompt-cache rewrites. Cost and cache statistics help you follow the routing decisions.
  • Braille Usage · kawase1295 - Check your current model, context usage and five-hour or weekly limits in compact text-based meters above the prompt.
  • Budget and Context Toolkit · Arunjay4213 - Track session spending, subscription quotas, cache use and per-turn token costs. Set budget limits that warn before exhaustion and stop tool calls when a configured limit is exceeded.
  • Budget Governor · ccdwyer - Set session and daily spending caps, monitor them above the prompt, and receive a wrap-up reminder at eighty percent. New prompts are refused once the cap is reached.
  • Cache, Context and Cost Panel · anthonyhungnguyen - Track token spending for the session and recent days, get context-limit warnings, and see what rebuilding the prompt cache cost. A completion alert announces long turns.
  • Clawd Usage Meters · Toshkee - Watch live context, rate-limit and cost meters above the prompt, accompanied by Clawd's wand animations and banner tricks as you work in Claude Code.
  • Cockpit and Bodyguard · Para-FR - Inspect session turns, tools, tokens, durations and changed files in a live cockpit. A companion guard blocks access to .env files and destructive Bash commands, displaying blocked-action alerts.
  • Context and Quota Forecast · imsalik - See a context forecast alongside five-hour and weekly rate-limit usage above the prompt. Reset countdowns help you understand when each plan allowance becomes available again.
  • Context Saver · AlmogBaku - Spot repeated tool calls and wasteful workflows during a Claude Code session. Review their measured time and context cost, then send a suggested correction with one click.
  • Context Weather and Usage · travisoa - Read context fullness as changing weather symbols alongside token totals and quota consumption. Recent-turn bars show how the conversation grew, helping you spot increasing pressure on the context window.
  • Cost and Output Token Band · Luma-Sa - Keep five-hour and seven-day usage limits, session cost and output-token totals visible in a band above the Claude Code prompt while you work.
  • Cost Meter · zaferayan - Track API-equivalent session cost above the prompt, including subagent usage, with a configurable budget warning. On subscription plans, the displayed amount estimates API pricing rather than your bill.
  • Desktop Next Steps and Limits · shichang4fun - See quota limits and reset countdowns above the prompt in Claude Code Desktop. After each turn, suggested next prompts can be sent with a click or copied for editing.
  • Desktop Session Status Bar · sgmonda - Keep five-hour and weekly usage limits, reset times, session clocks and turn duration visible in Claude Code Desktop. The bar also lists active subagents, background jobs and scheduled wakeups.
  • Desktop SVG Usage Meters · s-hiraoku - See context use and the five-hour and seven-day quota windows as SVG meters above the prompt in Claude Code Desktop. The compact display keeps capacity visible while you work.
  • Effort Guard · stefanochieli - Track context and token usage, review an effort log for each turn and receive an escalation alert after repeated command or test failures. It observes activity without blocking it.
  • Effort Router · totally-tim - Choose reasoning effort for each turn with Jev or a compatible classifier, and display the selected level. Shadow mode records the recommendation; enforce mode applies it to the session.
  • Effort Shortcut · daanqq - Cycle Claude's reasoning effort through low, medium, high, and extra-high with one command. Bind that command to a key to change effort quickly while skipping the maximum setting.
  • Focus Band · chaoshengsc - Keep your five-hour and weekly plan usage visible above the prompt. Progress bars, percentages, and reset times help you see how much of each allowance you have used.
  • Jet Router · jetsongdev - Experiment with per-turn reasoning effort recommendations while keeping your chosen model. Use fixed test decisions or opt into Jev classification, with observation and optional application modes.
  • Jev Claude Router · Flam1ngFir3ball - Let Jev choose the model and reasoning effort for each turn while considering the cost of switching. It also provides Jev-guided context compaction.
  • Jev Model and Effort Selection · unclecode - Observe or pin model and effort choices, or opt into automatic selection with Jev. The experimental router lets you compare recommendations before allowing them to steer the session.
  • Jev Model Router · satviksinha - Choose a model and reasoning effort for each turn using TypeSafe's Jev, through its direct API or Vercel AI Gateway. A visible routing decision shows which model will handle the work.
  • Jev Route · drewpayment - Choose a Claude model for each turn using Jev's estimate of what the task needs. Toggle routing with /route to balance model capability against cost.
  • Limitpace · fstandhartinger - Track subscription limits and spending pace through live usage bars. An optional delegation adviser can suggest using another provider as part of managing your available capacity.
  • Model and Effort Router · moritalous - Choose reasoning effort for each prompt while keeping the model fixed, or enable a separate router that selects Haiku, Sonnet, or Opus using Jev. Both tools adjust resources per turn.
  • Model Shortcuts · richkuo - Use keyboard shortcuts to switch models and adjust reasoning effort, with the active choices displayed in the prompt footer.
  • Netrunner HUD · ccdwyer - Watch context use, token activity, tool calls, costs, and job status in an animated cyberpunk dashboard. Git and test widgets keep related session information alongside the live usage gauges.
  • Next Steps and Usage Band · pawandeepdhall - Keep five-hour and weekly usage limits visible with reset countdowns, and choose from suggested next steps after each reply. Buttons also provide shortcuts for starting a new chat or pushing work.
  • Oxide Jev Model Router · Kiy-K - Use Jev to choose subagent models and adjust the main conversation's model and reasoning effort as work progresses. A built-in classifier provides a fallback without an API key.
  • Plan and Context Usage Line · KhadeerBasha1232 - Track five-hour and weekly plan limits, reset times, context usage, the active model and session cost in one line above the Claude Code prompt.
  • Plan Usage and Reset Times · jumoog - Keep five-hour and weekly plan usage visible above the prompt, together with each reset time. Check the current allowance windows without opening a separate usage screen.
  • Power Bottom Status Line · brianSchanbacher - See the model, folder, branch, context fill, cost, elapsed time, cache hits and live agents in a cross-platform status line at the bottom of Claude Code.
  • Project Spend and Cache Status · Sma1lboy - Track project spending alongside context usage, cache hits and the cache countdown. Inspect why a turn missed the cache, and open the project ledger with /usage.
  • Prompt Enhancement and Telemetry · MiguelMachado-dev - Inspect per-turn duration, first-token latency, token throughput, requests and stalls in a live band. An additional command rewrites a draft prompt with Sonnet and returns it for your review.
  • Prompt Footer Usage · WoBok - Keep context usage and rate-limit consumption visible in Claude Code's prompt footer, so you can check those readings without opening a separate usage view.
  • Prompt Usage Timeline · augiefra - Compare five-hour and weekly usage with elapsed time, inspect token bars for recent prompts and watch context status above the prompt. Labels are available in English or French.
  • Quota and Cost Pills · Sh0ckWaveZero - Track five-hour and seven-day rate limits, session tokens and cost in compact indicators above the prompt. The band can sit alongside other status bands.
  • Quota Reserve · chrisns - Reserve part of your five-hour and weekly quota for later work. The mod pauses at configured thresholds, asks whether to continue, and releases the reserve shortly before the reset.
  • Rate-Limit History Band · mohammed-alsalhi - Follow rate-limit consumption since each window last reset, including five-hour, weekly, and per-model weekly allowances. A band above the prompt graphs how usage accumulates over time.
  • Retro Usage Band · iamkhalid2 - Keep your plan's five-hour and weekly usage limits in view through a slim retro-style band above the prompt. Check both allowances as you work.
  • Sessclone · NotTahaAli - Send Claude Code usage to a SessClone deployment so laptop, cloud and CI sessions can contribute to a shared team ledger of usage and cost.
  • Session and Weekly Usage Band · Gooner44 - See session, weekly and Fable 5.1 usage together in a band above the Claude Code prompt, keeping these separate usage readings visible while you work.
  • Session Cockpit · nvr0x5 - Track context, spending, usage limits, reset countdowns and burn rates above the prompt. Plan progress and active subagents share the same cockpit, with display options for terminal and Desktop sessions.
  • Session Cost and Remaining Usage · HydrowZer - See the current session cost and remaining usage in a band above the Claude Code prompt, keeping spending and available capacity visible beside your conversation.
  • Session Dashboard and Alerts · lucenity0 - Follow turn durations, token counts, files touched, and tool activity through a timer and dashboard. Companion mods notify you when long turns finish and block destructive commands or edits to secrets.
  • Session Internals Dashboard · tomstagl - Inspect context, tokens, cost, limits, tools and agents in a live side-pane dashboard. An accompanying insights interface lets the session answer questions using the displayed measurements.
  • Session Sidebar · richard-parayno - Toggle a sidebar showing usage limits, context size, prompt-cache expiry, current activity and touched files. It keeps the session's resource picture visible beside the conversation.
  • Stack and Wait Time · ShahriarBijoy - Follow tasks, subagents, rate limits and monthly cost in a stacked progress band. A companion vocabulary game uses German words related to the session's topic while Claude works.
  • Terminal Usage Monitor · ilkayGkbdk - Monitor context fill, rate limits, token speed, cost, and active subagents in a live terminal dashboard. The display brings the session's resource use together in one place.
  • Turn Receipts and Error Cues · roaringsoul404 - Receive a receipt after each turn listing touched files, commands, tokens and cost. A companion animation turns the spinner into a pixel-art failure cue when a tool call fails.
  • Usage and Active Agent Dashboard · quango2304 - Follow usage bars, limits, session cost, cache expiry and active time alongside the agents currently running. The combined display makes resource consumption visible while work is underway.
  • Usage and Cache Warmth Band · Vosssa - See five-hour and weekly usage, context fill and prompt-cache warmth together above the Claude Code prompt, keeping the main resource readings visible while you work.
  • Usage and Spend Chips · kreddevils18 - Track five-hour and weekly allowances, session cost, and spending history through colored chips above the prompt. Open /usage-mod for a fuller view of the usage details.
  • Usage Line · Steven-ODell - See five-hour, weekly and context usage in the prompt footer, including whether consumption is ahead of or behind an even pace through the usage window.
  • Usage Log · tanuu5 - Record five-hour and weekly usage after each turn, then compare consumption with your chosen pace in a graph. The separate Clawd Dance mod can also show that pacing above the prompt.
  • Usage Report · Schweem - Monitor context usage, five-hour and weekly quotas, and session cost in the status line, with warnings as you approach your limits.
  • Usage Wrapup · DaKev - Ask running agents to wrap up when your remaining plan allowance gets low, helping them finish their current work before the usage limit interrupts it.
  • Weekly Usage and Spend Board · aycandv - Compare spending by model with a weekly-limit pace gauge, using a sliding ticker in the terminal or a strip and details pane in the desktop app.

<a id="agents"></a>

Agent Mods

Browse Agent Mods

Coordinate agents and follow their actual work.

  • 5dive Telemetry Toolkit · 5dive-ai - Connect Claude Code sessions to 5dive's task and gate controls, with a panel showing assignments and token budgets. Optional telemetry, compaction and tool-call policies support coordinated agent work.
  • Agent Control and Document Desk · masahide - Send messages and interruption requests to Claude through the agentctl command line and collect acknowledgments. A companion document desk gathers questionnaire answers and detailed document-review comments in a browser, then delivers them to Claude.
  • Agent Dashboard · scasella - Monitor the main agent and its subagents in a live dashboard that brings together model usage, permission requests, task activity and session logs.
  • Agent Flow · Charlie0113-T - Open /flow to follow a live tree of the session's subagents and teammates beside the transcript. A text view is available where panes cannot be displayed.
  • Agent Models and Peek · alex2481kobe - Show each subagent's model and reasoning effort beside its task in the native agent list. A companion viewer displays images inline, using real pixels or a terminal-compatible block
Source 2 files
hooks/register.tsx 1522 lines
1import { 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 lines
1// 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