SLOPSHOPPER

canopy

Your project's file tree beside the chat, lit by what Claude reads, edits and commits.

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-13flaviorg/canopy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · canopy
│ ┃ Canopy ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ canopy · ○ 1⠋ │ ┃ ▾ app/ ⏺ Read(src/auth.ts) │ ┃ ├─ {} package.json ⎿ Read 6 lines │ ┃ ├─ M↓ README.md ⏺ Update(src/auth.ts) │ ┃ └─ src◐ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ✎ editing ● edited ✓ committed ⎿ 3 pass, 1 fail │ ┃ ◐ reading ○ read │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /canopy │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Canopy
◆ canopy · ○ 1⠋ ▾ app/ ├─ {} package.json ├─ M↓ README.md └─ src◐ ✎ editing ● edited ✓ committed ◐ reading ○ read
README

<img src="docs/media/banner.png" alt="canopy: a Claude Code mod that keeps your project's file tree beside the chat, lit by what Claude reads, edits and commits" width="100%">

◆ canopy

A Claude Code mod that keeps your project's file tree beside the chat, and lights each file as Claude reads, edits or commits it.

Claude Code mod Tested with License: MIT

<img src="docs/media/canopy.gif" alt="Claude reads the router and the todos module, edits them, writes a test and commits; in the canopy pane each file lights blue while read, orange while edited, yellow until the commit and green once committed, and the folders open as Claude works in them" width="900">

<sub>An animation of the pane, drawn with canopy's own layout; reads and edits are sped up.</sub>

Claude's transcript tells you what it did, one tool call at a time. Canopy shows you where: your project's tree sits beside the conversation, folders open as Claude works in them, and every file it touches lights up. A glance tells you what it's reading, what it changed, and what's already committed.

│ ◆ canopy · ⎇ main · ● 2 · ✓ 1 · ○ 3       ⠹
│ ▾ my-app/                                 •
│ ├─ ▸ docs/ ·1                           ○
│ ├─ ▾ src/                                 •
│ │  ├─ ▾ components/                       •
│ │  │  ├─ TS Button.tsx                  ✎ M
│ │  │  └─ TS Header.tsx                  ◐
│ │  ├─ TS index.ts                       ✓
│ │  └─ TS utils.ts                       ● M
│ ├─ M↓ notes.md                            U
│ ├─ {} package.json                      ○
│ └─ M↓ README.md
│
│ ✎ editing  ● edited  ✓ committed
│ ◐ reading  ○ read
│ M modified  A added  U untracked  D deleted
│ R renamed

(In the pane each name takes its state's color, and each badge sits on a tile in its language's color.)

In a real session

Install

Inside Claude Code:

/plugin marketplace add flaviorg/canopy
/plugin install canopy@canopy

Then start a new session, or run /reload-plugins. Canopy is also listed in my claude-mods marketplace with my other mods.

What lights up

Every file Claude touches keeps a state for the whole session, each in its own color, so a glance tells what is happening now, what changed and waits for a commit, and what landed.

StateWhenHow it shows
✎editingClaude is writing the file: Edit, Write, NotebookEdit, or a shell command that changed itThe name turns bold in Claude's orange, and the pencil pulses bright and dim, for 8 seconds
●editedClaude changed it and it isn't committed yetThe name stays yellow, like git's M, until a commit takes it. Put back (git restore, checkout, stash), it goes back to read
✓committedA commit took it: one Claude made (every file it took, new ones included) or one you made in your own terminal or editorThe name turns green, with a check, until Claude changes it again
◐readingClaude is reading it: the Read tool, or cat, head, tail, sed -n, grep, rg, wc… in the shellThe name turns blue, with a turning quarter moon, for 8 seconds. A Read lights from the moment Claude asks, so one waiting for your permission is already lit; a shell read lights once the command has run
○readClaude read it earlierA blue ring beside its plain name. Reading a changed file spins for a moment, then shows the change again
M A D R Ugitgit statusModified, added, deleted, renamed, untracked: right-aligned, in the theme's colors. A folder rolls everything inside it up into one small dot (•) in the color of the most serious change
·3inside a folderA closed folder holds files Claude touchedThe folder's name, its count and its glyph take the strongest state inside: what is happening now, then what waits for a commit, then what landed, then what was read
TS {} M↓file typeAlwaysA two-letter badge on a tile in the language's color (TS, JS, {}, M↓, CS, PY, $_…); special files get their own (package.json, Dockerfile, .gitignore, CLAUDE.md…)

The header counts the files changed and not committed (●), committed (✓) and only read (○) this session, and a spinner turns while Claude is reading or writing. Folders Claude works in open by themselves and close again a minute after it leaves, keeping their count. Press a folder (click it, or Tab to it and press Enter) to open or close it; a folder you close stays closed. With Follow on, the pane scrolls to the file Claude is on. In a narrow pane, rows nested deeper than half its width show their last levels after a dim ⋯, so file names stay readable.

While the pane is shown, canopy reads .gitignore again, rescans the open folders and asks git again every five seconds, so changes made outside Claude appear too.

Commands

CommandWhat it does
/canopyShows the tree, or hides it when it's showing. When another mod's pane is in front, brings canopy's back
/canopy refreshReads .gitignore again and rescans everything

Options

Set them with /plugin configure canopy@canopy:

OptionValuesDefaultWhat it does
starton, offonon opens the tree when a session starts, if the terminal is wide enough (otherwise a toast says to run /canopy)
activityall, writes, noneallWhich of Claude's file activity lights the tree. writes skips reads; none keeps a plain tree with git badges
ignorecomma-separated names, *.ext suffixes or folder/path paths(empty)More to hide, on top of .gitignore and the built-ins (node_modules, .git, dist, build, .next, coverage, __pycache__, .venv, target, .turbo, .cache, .DS_Store)
followon, offonScroll to the file Claude is touching
iconsbadges, swatchesbadgesbadges draws a two-letter tile in each file type's color; swatches a small colored square (◆ for the files that define a project)

Privacy

Canopy reads directory listings under your project root and the root's .gitignore. It never opens your files. It runs exactly three commands, all read-only, in the project root: git status --porcelain=v1 -b -z (with --untracked-files=all just before a commit, so it can tell which new files the commit took), git rev-parse --verify -q HEAD (so it can tell a commit you made from a change you put back) and git rev-parse --show-prefix. It makes no network requests, and nothing leaves your machine. You can confirm this before installing:

claude plugin validate ./canopy

The calls: line lists every mods API call it makes.

Compatibility

Tested with Claude Code 2.1.296 in the terminal and the Desktop app's Code tab. Mods need Claude Code 2.1.287 or later. In fullscreen from 110 columns the pane docks beside the transcript (44 columns wide); otherwise it sits above the prompt. Claude Code places a pane a mod opens on its own only from 144 columns, so on a narrower terminal canopy waits for you to run /canopy.

Claude Code shows one mod pane at a time, with a tab for each, docked or above the prompt: when another mod opens its pane (say crew as Claude delegates), click canopy's tab or run /canopy to bring the tree back. In fullscreen, Claude Code's own diff panel may open beside the transcript when a file changes and cover the dock; /diff closes it, and Claude Code remembers that.

Large projects stay responsive: a folder draws at most 500 entries and the tree at most 2,000 rows, and the rest fold into … N more rows, so one huge folder never hides the others. In a monorepo, start Claude in a package folder and the badges follow that folder.

Development

claude plugin validate --strict .   # manifest, hooks and calls
claude plugin test .                 # 138 tests, no session or sign-in needed
claude --plugin-dir .                # try it in a session; edits hot-reload

hooks/register.ts is the only file that touches the mods API. The rest are plain modules with one job each, tested on their own: tree.ts (ignore rules and guide lines), activity.ts (marks, heat and each file's state), git.ts (status parsing), bashpaths.ts (what a shell command reads or changes), view.ts (the look) and filetypes.ts (badges and swatches).

Credits

Inspired by the idea of a live file tree for coding agents; written from scratch against the public mods API.

License

MIT © flaviorg

Source 8 files
hooks/register.ts 518 lines
1// canopy — your project's file tree beside the chat, lit by what Claude reads, edits and commits.
2// This is the only file that touches the mods API ($). The modules beside it decide what to
3// show; this one lists folders, asks git, watches Claude's tool calls, keeps the state the
4// pane is drawn from, and draws it.
5import { atom, read, update } from 'claude-code'
6import { allows, anyHot, heat, litDirs, markAll, settle, unread, type Activity, type MarkKind, type Marks } from './activity.ts'
7import { changesTree, isCommit, readPaths, touchesGit } from './bashpaths.ts'
8import { badgeFor, COMMIT_STATUS_ARGV, committed, HEAD_ARGV, NO_GIT, parseStatus, STATUS_ARGV, withinPrefix, type GitState } from './git.ts'
9import {
10  anchorFor, ancestors, flatten, ignoreRules, isHidden, join, own, put, relativeTo, toEntries, type Entry, type Ignore, type Listing,
11} from './tree.ts'
12import { isPlain, paneLines, printable, rowKey, type Icons, type Line, type PaneStatus, type Seg } from './view.ts'
13
14type Options = { start: 'on' | 'off'; activity: Activity; ignore: string; follow: 'on' | 'off'; icons: Icons }
15
16const PANE = 'canopy'
17const DOCK_COLUMNS = 44
18const TICK_MS = 250
19const GIT_TIMEOUT_MS = 5_000
20// Claude's own file tools. Any other tool naming a file by an absolute path counts too: as a
21// write when its name says edit or write, else as a read.
22const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit'])
23
24// What the pane is drawn from. $.state survives a hot reload; a drawing that reads it redraws
25// when it changes.
26const marksA = atom({ plugin: 'canopy', key: 'marks' } as const, {} as Marks)
27const gitA = atom({ plugin: 'canopy', key: 'git' } as const, NO_GIT)
28const expandedA = atom({ plugin: 'canopy', key: 'expanded' } as const, [] as string[])
29const collapsedA = atom({ plugin: 'canopy', key: 'collapsed' } as const, [] as string[])
30
31// Module state: rebuilt by scanning, so losing it to a reload costs one rescan.
32let settings: Options = { start: 'on', activity: 'all', ignore: '', follow: 'on', icons: 'badges' }
33let root: string | null = null
34let booted = false // a first scan has finished (until then the pane says it is reading)
35let rootName = ''
36let gitPrefix = ''
37let listing: Listing = {}
38let listings = 0 // counts finished listings, so a scan knows which ones came after it began
39const listedAt = new Map<string, number>()
40const misses = new Map<string, number>() // listings in a row that failed, by folder
41let ig: Ignore = ignoreRules('', '')
42let gitignore: string | null = null // the .gitignore the rules were read from
43let frame = 0
44let tick = 0
45let ticker: { cancel(): void } | null = null
46let busy = false // a tick still running when the next one is due lets it pass
47let wasHot = false // one more frame once the last hot mark cools
48let wasLit = false // one more redraw once the last mark goes cold
49let gitDue = false // a file changed: ask git on the next check
50let head: string | null | undefined // the commit HEAD named at the last check: null before the first, undefined not known
51let followTo: string | null = null
52let toasted = false
53
54export function register(on: any, options: Partial<Options>) {
55  settings = { ...settings, ...options }
56
57  on('session.start', async ($: any, e: any, next: any) => {
58    await boot($)
59    try {
60      await $.command.register({ name: 'canopy', description: 'Show or hide the project tree', argumentHint: '[refresh]', immediate: true })
61    } catch {}
62    if (settings.start === 'on' && !(await openPane($)) && !toasted) {
63      toasted = true
64      $.ui.toast('canopy: run /canopy to open the tree')
65    }
66    if (root !== null) ensureTicker($)
67    return next(e)
68  }).catch(($: any, e: any, next: any) => next(e))
69
70  // /clear, /resume and /branch reset $.state: start from a fresh scan.
71  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($: any, e: any, next: any) => {
72    await boot($)
73    return next(e)
74  }).catch(($: any, e: any, next: any) => next(e))
75
76  // Observes every tool call and always returns what the tool gave.
77  on('tool.call', async ($: any, e: any, next: any) => {
78    if (root === null) return next(e)
79    const path = pathOf(e)
80    const command = e.tool === 'Bash' && typeof e.command === 'string' ? (e.command as string) : null
81    // A Read lights its file while it runs, and while it waits for permission.
82    const earlier = e.tool === 'Read' && path ? await read($, marksA) : null
83    const reading = earlier ? await touch($, [path!], 'read') : {}
84    // A commit is judged against the status just before it (null: git could not say).
85    const before = command !== null && isCommit(command) ? await readStatus($, COMMIT_STATUS_ARGV) : undefined
86    const r = await next(e)
87    try {
88      if (!r?.deny && !r?.isError) await afterTool($, e.tool, r.result, path, command, before)
89      else if (earlier) await update($, marksA, (marks: Marks) => unread(marks, reading, earlier))
90    } catch {}
91    return r
92  }).catch(($: any, e: any, next: any) => next(e))
93
94  on('command.run', { command: 'canopy' }, async ($: any, e: any) => {
95    if ((e.args ?? '').trim() === 'refresh') {
96      await boot($)
97      return { text: paneStatus() === 'unreadable' ? `canopy: can't read ${root ?? 'the project folder'}` : 'canopy: rescanned' }
98    }
99    const pane = (await $.ui.panes()).find((p: any) => p.id === PANE)
100    if (pane?.isShown) {
101      await $.ui.close({ id: PANE })
102      return {}
103    }
104    // Open behind another pane's tab (only one pane shows at a time, docked or inline): an open
105    // only retitles it, so close it and open it again to bring it to the front.
106    if (pane) await $.ui.close({ id: PANE })
107    if (root === null) await boot($)
108    else await freshen($)
109    await openPane($)
110    ensureTicker($)
111    return {}
112  }).catch(($: any, e: any, next: any) => (next.called ? next(e) : { text: 'canopy could not open: ' + next.error.kind }))
113
114  on('ui.render', { component: 'Pane', requestId: PANE }, async ($: any, e: any) => {
115    // Drawn again after the ticker stopped (the pane was hidden): keep up from here.
116    ensureTicker($)
117    const marks = await read($, marksA)
118    const git = await read($, gitA)
119    const now = await $.clock.now()
120    const open = openSet(marks, await read($, expandedA), await read($, collapsedA), now)
121    const rows = flatten(rootName, listing, (rel) => open.has(rel))
122    // A dock draws the body flush against its edge: one cell of air on the left.
123    const dock = e.props?.placement === 'dock'
124    const width = Math.max(0, (e.props?.bodyColumns ?? DOCK_COLUMNS) - (dock ? 1 : 0))
125    const lines = paneLines({ rows, marks, git, now, frame, width, activity: settings.activity, icons: settings.icons }, paneStatus(), root ?? 'the project folder')
126    const elements = $.ui.resolve(e)
127    const toggle = (dir: string) => toggleDir($, dir)
128    return elements.Box({ flexDirection: 'column', ...(dock ? { paddingLeft: 1 } : {}), children: lines.map((line) => drawLine(elements, line, toggle)) })
129  })
130}
131
132// Finds the root, reads its .gitignore, lists the open folders and asks git. Safe to repeat.
133async function boot($: any): Promise<void> {
134  try {
135    root = normalRoot(await $.session.root())
136  } catch {
137    root = null
138  }
139  try {
140    if (root === null) return
141    rootName = root.split('/').filter(Boolean).pop() ?? root
142    gitPrefix = await readGitPrefix($)
143    // Cached listings are replaced folder by folder, so the pane never blinks empty.
144    await freshen($)
145  } finally {
146    booted = true
147    $.ui.invalidate('ui.render')
148  }
149}
150
151// Catches up with the disk and the repository: the ignore rules, the open folders, git.
152async function freshen($: any): Promise<void> {
153  await readIgnore($)
154  await scanVisible($)
155  await refreshGit($)
156}
157
158// Reads the root's .gitignore into the rules. A missing file means none; a read that fails
159// otherwise keeps the rules already read. The next scan applies a change.
160async function readIgnore($: any): Promise<void> {
161  let text = ''
162  try {
163    text = await $.fs.read(absolute('.gitignore'))
164  } catch (err) {
165    if (gitignore !== null && !/ENOENT|no such file/i.test(String(err))) return
166  }
167  if (text === gitignore) return
168  gitignore = text
169  ig = ignoreRules(text, settings.ignore)
170}
171
172async function openPane($: any): Promise<boolean> {
173  try {
174    const opened = await $.ui.open({ id: PANE, title: 'Canopy', columns: DOCK_COLUMNS })
175    return opened?.isPlaced === true
176  } catch {
177    return false
178  }
179}
180
181// Lists one folder into the cache, redrawing when it changed. One failed listing keeps the
182// last (a passing hiccup); a folder that fails twice in a row is forgotten, and so drawn closed.
183async function scanDir($: any, rel: string): Promise<void> {
184  if (root === null) return
185  const was = JSON.stringify(own(listing, rel))
186  try {
187    put(listing, rel, toEntries(await $.fs.list(absolute(rel)), rel, ig))
188    misses.delete(rel)
189  } catch {
190    const missed = (misses.get(rel) ?? 0) + 1
191    misses.set(rel, missed)
192    if (missed > 1) delete listing[rel]
193  }
194  listedAt.set(rel, ++listings)
195  if (JSON.stringify(own(listing, rel)) !== was) $.ui.invalidate('ui.render')
196}
197
198// Lists every folder the tree shows open, parents first (a folder is listed only while its
199// parent shows it), then forgets the listings of folders that closed. Scans overlap (a poll,
200// a press, a shell command), so a folder open by the end, or listed by another since this
201// one began, is kept.
202async function scanVisible($: any): Promise<void> {
203  const began = listings
204  const open = await openDirs($)
205  const listed = new Set<string>()
206  for (const dir of [...open].sort((a, b) => depth(a) - depth(b))) {
207    if (dir !== '' && !hasEntry(dir, 'dir')) continue
208    await scanDir($, dir)
209    listed.add(dir)
210  }
211  const openNow = await openDirs($)
212  for (const dir of Object.keys(listing)) {
213    if (listed.has(dir) || openNow.has(dir) || (listedAt.get(dir) ?? 0) > began) continue
214    delete listing[dir]
215    listedAt.delete(dir)
216    misses.delete(dir)
217  }
218}
219
220// The folders drawn open: the root, the ones the person opened and the ones Claude is working
221// in, less the ones the person closed.
222function openSet(marks: Marks, expanded: string[], collapsed: string[], now: number): Set<string> {
223  const open = new Set([...litDirs(marks, now), ...expanded])
224  for (const dir of collapsed) open.delete(dir)
225  return open.add('')
226}
227
228async function openDirs($: any): Promise<Set<string>> {
229  return openSet(await read($, marksA), await read($, expandedA), await read($, collapsedA), await $.clock.now())
230}
231
232// Asks git for the branch and badges and keeps them. When git can't say (it timed out, failed
233// to start or broke), the last answer stands and this returns null.
234async function refreshGit($: any): Promise<GitState | null> {
235  gitDue = false
236  const state = await readStatus($)
237  if (state !== null && JSON.stringify(await read($, gitA)) !== JSON.stringify(state)) await update($, gitA, () => state)
238  if (state !== null && state.branch !== null) await settleClean($, state)
239  return state
240}
241
242// Files Claude changed that git now calls clean: committed outside Claude when HEAD moved since
243// the last check, else put back. Each check notes HEAD, so the next one can tell.
244async function settleClean($: any, git: GitState): Promise<void> {
245  const now = await readHead($)
246  if (now === undefined) return
247  const before = head
248  head = now
249  if (before === undefined) return
250  const marks = await read($, marksA)
251  const clean = Object.keys(marks).filter((rel) => own(marks, rel)?.change === 'write' && badgeFor(git.files, rel) === null)
252  if (clean.length === 0) return
253  await update($, marksA, (marks: Marks) => settle(marks, clean, now !== before ? 'committed' : 'reverted'))
254}
255
256// The commit HEAD names: null before the first commit, undefined when git can't say.
257async function readHead($: any): Promise<string | null | undefined> {
258  try {
259    const r = await $.process.run([...HEAD_ARGV], { cwd: root, timeoutMs: GIT_TIMEOUT_MS, env: { GIT_OPTIONAL_LOCKS: '0' } })
260    return r.exitCode === 0 ? r.stdout.trim() || null : null
261  } catch {
262    return undefined
263  }
264}
265
266// One `git status`: the branch and badges; none outside a repository; null when git can't say.
267async function readStatus($: any, argv: readonly string[] = STATUS_ARGV): Promise<GitState | null> {
268  if (root === null) return null
269  try {
270    // GIT_OPTIONAL_LOCKS=0: a status never takes the index lock from Claude's own git commands.
271    const r = await $.process.run([...argv], { cwd: root, timeoutMs: GIT_TIMEOUT_MS, env: { GIT_OPTIONAL_LOCKS: '0' } })
272    if (r.exitCode === 0) return withinPrefix(parseStatus(r.stdout), gitPrefix)
273    return /not a git repository/i.test(r.stderr) ? NO_GIT : null
274  } catch {
275    return null
276  }
277}
278
279// Where the root sits inside its repository ('web/'), since git names paths from the top.
280async function readGitPrefix($: any): Promise<string> {
281  try {
282    const r = await $.process.run(['git', 'rev-parse', '--show-prefix'], { cwd: root, timeoutMs: GIT_TIMEOUT_MS })
283    return r.exitCode === 0 ? r.stdout.trim() : ''
284  } catch {
285    return ''
286  }
287}
288
289// Lights files Claude touched and returns the marks it left, by relative path. Only what the
290// tree can show counts: a path outside the root, hidden by the ignore rules, missing or a
291// folder is left alone.
292async function touch($: any, paths: readonly string[], kind: MarkKind, cwd?: string): Promise<Marks> {
293  if (root === null || !allows(settings.activity, kind)) return {}
294  const lit: string[] = []
295  const listed = new Set<string>()
296  for (const path of paths) {
297    const rel = relativeTo(root, path, cwd ?? root)
298    if (rel && !isHidden(rel, ig) && (await showsFile($, rel, kind !== 'read', listed))) lit.push(rel)
299  }
300  if (lit.length === 0) return {}
301  const now = await $.clock.now()
302  const marks = await update($, marksA, (marks: Marks) => markAll(marks, lit, kind, now))
303  // The folders above now open; one a scan forgot meanwhile is listed again.
304  for (const dir of new Set(lit.flatMap(ancestors))) if (!Object.hasOwn(listing, dir)) await scanDir($, dir)
305  if (settings.follow === 'on' && kind !== 'commit') followTo = lit[lit.length - 1]!
306  ensureTicker($)
307  const left: Marks = {}
308  for (const rel of lit) {
309    const m = own(marks, rel)
310    if (m) put(left, rel, m)
311  }
312  return left
313}
314
315// Whether the tree holds `rel` as a file. Lists the folders above it first, root down (they
316// open as it lights). A write may have just made the file and folders above it, so a folder
317// whose listing lacks the next step is listed again; `listed` keeps that to once per touch.
318async function showsFile($: any, rel: string, mayBeNew: boolean, listed: Set<string>): Promise<boolean> {
319  const dirs = ancestors(rel)
320  for (let i = 0; i < dirs.length; i++) {
321    const dir = dirs[i]!
322    const holds = () => (i + 1 < dirs.length ? hasEntry(dirs[i + 1]!, 'dir') : hasEntry(rel, 'file'))
323    if (!Object.hasOwn(listing, dir) || (mayBeNew && !listed.has(dir) && !holds())) {
324      listed.add(dir)
325      await scanDir($, dir)
326    }
327    if (!holds()) return false
328  }
329  return true
330}
331
332// What a finished tool call changed: the file it wrote or read, or what a shell command did.
333async function afterTool($: any, tool: string, result: any, path: string | undefined, command: string | null, before: GitState | null | undefined): Promise<void> {
334  if (command !== null) return afterBash($, command, result, before)
335  if (!path || tool === 'Read') return
336  if (/edit|write/i.test(tool)) {
337    await touch($, [path], 'write')
338    await rulesChanged($, [path])
339    gitDue = true
340  } else {
341    await touch($, [path], 'read')
342  }
343}
344
345// `before`: the status just before a `git commit` (null when git couldn't say), undefined for
346// any other command.
347async function afterBash($: any, command: string, result: any, before: GitState | null | undefined): Promise<void> {
348  if (changesTree(command)) await scanVisible($)
349  const changed: string[] = result?.bashEditDiff?.changedFiles ?? []
350  if (changed.length) {
351    await touch($, changed, 'write')
352    await rulesChanged($, changed)
353    gitDue = true
354  }
355  if (allows(settings.activity, 'read')) await touch($, readPaths(command), 'read', await cwdOf($))
356  if (result?.gitOperation?.commit || before !== undefined) {
357    const was = before === undefined ? await read($, gitA) : before
358    const now = await refreshGit($)
359    // Only two answers git gave can say what a commit took.
360    if (was !== null && now !== null) await touch($, committed(was, now), 'commit')
361  } else if (touchesGit(command)) {
362    await refreshGit($)
363  }
364}
365
366// A write to the root's .gitignore changes what the tree hides: read it again and rescan.
367async function rulesChanged($: any, paths: readonly string[]): Promise<void> {
368  if (root === null || !paths.some((path) => relativeTo(root!, path) === '.gitignore')) return
369  await readIgnore($)
370  await scanVisible($)
371}
372
373// Opens or closes a folder the person pressed. Their choice outlasts Claude's activity there.
374async function toggleDir($: any, dir: string): Promise<void> {
375  const without = (dirs: string[]) => dirs.filter((d) => d !== dir)
376  if ((await openDirs($)).has(dir)) {
377    await update($, expandedA, without)
378    await update($, collapsedA, (dirs: string[]) => [...without(dirs), dir])
379  } else {
380    await update($, collapsedA, without)
381    await update($, expandedA, (dirs: string[]) => [...without(dirs), dir])
382    await scanVisible($)
383  }
384  $.ui.invalidate('ui.render')
385}
386
387function ensureTicker($: any): void {
388  if (ticker === null) ticker = $.clock.every(TICK_MS, () => onTick($))
389}
390
391function stopTicker(): void {
392  ticker?.cancel()
393  ticker = null
394}
395
396// Four frames a second while a mark is hot. Every second: follow up on edits (git), let heat
397// fade, and stop once the pane is hidden (heat is worked out again when it is drawn). Every
398// five seconds while shown, catch what changed outside Claude (a rescan and a git status).
399async function onTick($: any): Promise<void> {
400  if (busy) return
401  busy = true
402  try {
403    frame++
404    tick++
405    const now = await $.clock.now()
406    const marks = await read($, marksA)
407    const hot = anyHot(marks, now)
408    if (hot || wasHot) $.ui.invalidate('ui.render')
409    wasHot = hot
410    if (followTo !== null) await follow($)
411    if (tick % 4 !== 0) return
412    if (gitDue) await refreshGit($)
413    // A pane that can't be asked about counts as hidden. The next drawing or touch restarts.
414    const panes = await $.ui.panes().catch(() => [])
415    if (root === null || !panes.some((p: any) => p.id === PANE && p.isShown)) return stopTicker()
416    const lit = Object.values(marks).some((m) => heat(m, now) !== 'cold')
417    if (lit || wasLit) $.ui.invalidate('ui.render')
418    wasLit = lit
419    if (tick % 20 === 0) await freshen($)
420  } catch {
421    stopTicker()
422  } finally {
423    busy = false
424  }
425}
426
427// Scrolls the pane to the file Claude touched last, or to the closed folder holding it. It
428// runs on the tick after the touch, once the row is drawn; a hidden pane just says no.
429async function follow($: any): Promise<void> {
430  const rel = followTo!
431  followTo = null
432  const open = await openDirs($)
433  try {
434    await $.ui.scroll({ in: PANE, to: { key: rowKey(anchorFor(rel, (dir) => open.has(dir))) }, block: 'nearest' })
435  } catch {}
436}
437
438// One line as one row Box: the guides, then the left side, in a Button when it opens or closes
439// a folder (none when a narrow pane cut it away: an empty Button is refused), then the right.
440function drawLine(elements: any, line: Line, toggle: (dir: string) => void): any {
441  const { Box, Button } = elements
442  const children = (line.lead ?? []).map((seg) => drawSeg(elements, seg))
443  const left = line.left.map((seg) => drawSeg(elements, seg))
444  const dir = line.toggle
445  if (dir !== undefined && left.length > 0) children.push(Button({ key: 'dir:' + printable(dir), plain: true, onPress: () => toggle(dir), children: left }))
446  else children.push(...left)
447  if (line.right.length) children.push(Box({ flexGrow: 1 }), ...line.right.map((seg) => drawSeg(elements, seg)))
448  return Box({ key: line.key, flexDirection: 'row', children })
449}
450
451// A plain segment is a bare string (one element fewer); a styled one is a Text with only the
452// props it sets.
453function drawSeg(elements: any, seg: Seg): any {
454  if (isPlain(seg)) return seg.text
455  return elements.Text({
456    ...(seg.color !== undefined ? { color: seg.color } : {}),
457    ...(seg.bg !== undefined ? { backgroundColor: seg.bg } : {}),
458    ...(seg.bold ? { bold: true } : {}),
459    ...(seg.dim ? { dimColor: true } : {}),
460    children: [seg.text],
461  })
462}
463
464function paneStatus(): PaneStatus {
465  if (!booted) return 'loading'
466  const top = listing['']
467  if (root === null || top === undefined) return 'unreadable'
468  return top.length === 0 ? 'empty' : 'ready'
469}
470
471// The file a tool names: Read/Edit/Write's file_path, NotebookEdit's notebook_path, or another
472// tool's file_path or path when absolute (a remote tool's repository path is not on this disk).
473function pathOf(e: any): string | undefined {
474  const named = [e.file_path, e.notebook_path, e.path].find((p) => typeof p === 'string') as string | undefined
475  if (named === undefined) return undefined
476  return FILE_TOOLS.has(e.tool) || named.startsWith('/') ? named : undefined
477}
478
479async function cwdOf($: any): Promise<string | undefined> {
480  try {
481    return await $.session.cwd()
482  } catch {
483    return undefined
484  }
485}
486
487// Each listing's names, built on first use: a commit of thousands of files asks once per file.
488const indexes = new WeakMap<readonly Entry[], Map<string, Entry['kind']>>()
489
490function hasEntry(rel: string, kind: Entry['kind']): boolean {
491  const entries = own(listing, parentOf(rel))
492  if (entries === undefined) return false
493  let index = indexes.get(entries)
494  if (index === undefined) {
495    index = new Map(entries.map((entry) => [entry.name, entry.kind]))
496    indexes.set(entries, index)
497  }
498  return index.get(rel.slice(rel.lastIndexOf('/') + 1)) === kind
499}
500
501function absolute(rel: string): string {
502  if (!rel) return root!
503  return root === '/' ? '/' + rel : join(root!, rel)
504}
505
506function normalRoot(path: string): string {
507  return path.length > 1 ? path.replace(/\/+$/, '') : path
508}
509
510function parentOf(rel: string): string {
511  const slash = rel.lastIndexOf('/')
512  return slash < 0 ? '' : rel.slice(0, slash)
513}
514
515function depth(rel: string): number {
516  return rel === '' ? 0 : rel.split('/').length
517}
518
hooks/activity.ts 216 lines
1// canopy's activity model: what Claude did to each file and how recently. A mark is hot for
2// 8 seconds (it moves on screen) and warm for a minute after (its folders stay open). What a
3// file went through stays for the whole session: edited, committed or read (see stateOf).
4// Pure, no mods API.
5import { ancestors, own, put } from './tree.ts'
6
7export type MarkKind = 'read' | 'write' | 'commit'
8/**
9 * `kind` and `at`: Claude's latest touch. `change`: its latest write or commit, which a later
10 * read leaves in place, so an edited file still reads as edited after Claude looks at it again.
11 */
12export type Mark = { kind: MarkKind; at: number; reads: number; writes: number; change?: 'write' | 'commit' }
13export type Marks = Record<string, Mark>
14export type Heat = 'hot' | 'warm' | 'cold'
15export type Activity = 'all' | 'writes' | 'none'
16
17/**
18 * Where a file stands this session, strongest first: Claude is writing it now, changed it and
19 * hasn't committed, committed it, is reading it now, or read it.
20 */
21export type FileState = 'editing' | 'edited' | 'committed' | 'reading' | 'read'
22
23export const HOT_MS = 8_000
24export const WARM_MS = 60_000
25
26/** The most marks kept; past it the oldest are dropped, cold ones first. */
27export const MAX_MARKS = 2000
28
29/**
30 * Records one touch, returning a new object. A read keeps the counts of earlier touches; a read
31 * that lands while a write is still hot leaves the write showing (a write outranks a read).
32 */
33export function mark(marks: Marks, path: string, kind: MarkKind, now: number): Marks {
34  return markAll(marks, [path], kind, now)
35}
36
37/** mark() for many paths at once (a commit of thousands of files): one copy, one prune. */
38export function markAll(marks: Marks, paths: readonly string[], kind: MarkKind, now: number): Marks {
39  const out = { ...marks }
40  for (const path of paths) put(out, path, touched(own(out, path), kind, now))
41  return prune(out, now)
42}
43
44function touched(prev: Mark | undefined, kind: MarkKind, now: number): Mark {
45  const reads = (prev?.reads ?? 0) + (kind === 'read' ? 1 : 0)
46  const writes = (prev?.writes ?? 0) + (kind === 'write' ? 1 : 0)
47  const keepWrite = kind === 'read' && prev?.kind === 'write' && heat(prev, now) === 'hot'
48  if (keepWrite) return { ...prev!, reads }
49  const change = kind === 'read' ? prev?.change : kind
50  return change ? { kind, at: now, reads, writes, change } : { kind, at: now, reads, writes }
51}
52
53/**
54 * Files Claude changed that git now calls clean. A commit took them (one made outside Claude:
55 * in a terminal, an editor), so they read as committed; or they were put back (checkout,
56 * restore, stash), so each keeps only what Claude read of it, and goes if nothing.
57 */
58export function settle(marks: Marks, paths: readonly string[], how: 'committed' | 'reverted'): Marks {
59  const out = { ...marks }
60  for (const path of paths) {
61    const m = own(out, path)
62    if (m?.change !== 'write') continue
63    if (how === 'committed') put(out, path, { ...m, change: 'commit' })
64    else if (m.reads > 0) put(out, path, { kind: m.kind, at: m.at, reads: m.reads, writes: m.writes })
65    else delete out[path]
66  }
67  return out
68}
69
70/**
71 * Takes back reads that never happened (denied or failed). `left` holds the marks the reads
72 * left, `earlier` the marks before them. A mark still as its read left it returns to what
73 * `earlier` had, or to nothing; one touched again since keeps that touch and loses only the
74 * read's count (and goes when nothing is left of it).
75 */
76export function unread(marks: Marks, left: Marks, earlier: Marks): Marks {
77  const out = { ...marks }
78  for (const [path, mine] of Object.entries(left)) {
79    const now = own(out, path)
80    if (now === undefined) continue
81    if (sameMark(now, mine)) {
82      const before = own(earlier, path)
83      if (before) put(out, path, before)
84      else delete out[path]
85      continue
86    }
87    const reads = Math.max(0, now.reads - 1)
88    if (now.kind === 'read' && reads === 0 && !now.change) delete out[path]
89    else put(out, path, { ...now, reads })
90  }
91  return out
92}
93
94function sameMark(a: Mark, b: Mark): boolean {
95  return a.kind === b.kind && a.at === b.at && a.reads === b.reads && a.writes === b.writes && a.change === b.change
96}
97
98// Keeps MAX_MARKS: past it the oldest go, cold reads first, then cold changes, then the rest.
99function prune(marks: Marks, now: number): Marks {
100  const extra = Object.keys(marks).length - MAX_MARKS
101  if (extra <= 0) return marks
102  const keep = (m: Mark) => (heat(m, now) !== 'cold' ? 2 : m.change ? 1 : 0)
103  const oldest = Object.entries(marks)
104    .sort((a, b) => keep(a[1]) - keep(b[1]) || a[1].at - b[1].at)
105    .slice(0, extra)
106  for (const [path] of oldest) delete marks[path]
107  return marks
108}
109
110export function heat(m: Mark | undefined, now: number): Heat {
111  if (!m) return 'cold'
112  const age = now - m.at
113  if (age < HOT_MS) return 'hot'
114  return age < HOT_MS + WARM_MS ? 'warm' : 'cold'
115}
116
117/**
118 * Where a file stands, as the `activity` option lets it show: 'writes' leaves reads out (a
119 * file Claude is reading shows what it changed there, if anything), 'none' shows nothing.
120 */
121export function stateOf(m: Mark | undefined, now: number, activity: Activity = 'all'): FileState | null {
122  if (!m || activity === 'none') return null
123  const hot = heat(m, now) === 'hot'
124  if (hot && m.kind === 'write') return 'editing'
125  if (hot && m.kind === 'read' && activity === 'all') return 'reading'
126  if (m.change === 'commit') return 'committed'
127  if (m.change === 'write') return 'edited'
128  return activity === 'all' && m.reads > 0 ? 'read' : null
129}
130
131/** Strongest first: what is happening now, then what waits for a commit, then the rest. */
132export const STATE_RANK: Readonly<Record<FileState, number>> = { editing: 5, reading: 4, edited: 3, committed: 2, read: 1 }
133
134/**
135 * Files Claude changed and hasn't committed, committed, and only read. A file counts by what it
136 * went through, not by what it shows this moment: one Claude reads again after changing it
137 * still counts as changed.
138 */
139export function tally(marks: Marks, now: number, activity: Activity = 'all'): { edited: number; committed: number; read: number } {
140  const out = { edited: 0, committed: 0, read: 0 }
141  for (const m of Object.values(marks)) {
142    const state = stateOf(m, now, activity)
143    if (state === null) continue
144    if (state === 'editing' || m.change === 'write') out.edited++
145    else if (m.change === 'commit') out.committed++
146    else out.read++
147  }
148  return out
149}
150
151/**
152 * Every folder's strongest state inside it, and how many files inside share it, in one pass
153 * ('' is the whole tree). A closed folder shows it, so the work inside stays in sight.
154 */
155export function rollup(marks: Marks, now: number, activity: Activity = 'all'): Map<string, { state: FileState; count: number }> {
156  const out = new Map<string, { state: FileState; count: number }>()
157  for (const [path, m] of Object.entries(marks)) {
158    const state = stateOf(m, now, activity)
159    if (state === null) continue
160    for (const dir of ancestors(path)) {
161      const was = out.get(dir)
162      if (!was || STATE_RANK[state] > STATE_RANK[was.state]) out.set(dir, { state, count: 1 })
163      else if (was.state === state) was.count++
164    }
165  }
166  return out
167}
168
169export function anyHot(marks: Marks, now: number): boolean {
170  return Object.values(marks).some((m) => heat(m, now) === 'hot')
171}
172
173/** The most recently touched hot file, or null. */
174export function newestHot(marks: Marks, now: number): string | null {
175  let best: string | null = null
176  let at = -Infinity
177  for (const [path, m] of Object.entries(marks)) {
178    if (heat(m, now) === 'hot' && m.at > at) {
179      best = path
180      at = m.at
181    }
182  }
183  return best
184}
185
186/** How many hot or warm marks lie strictly inside a folder ('' is the whole tree). */
187export function hotUnder(marks: Marks, dirRel: string, now: number): number {
188  const prefix = dirRel ? dirRel + '/' : ''
189  let n = 0
190  for (const [path, m] of Object.entries(marks)) {
191    if (path.startsWith(prefix) && heat(m, now) !== 'cold') n++
192  }
193  return n
194}
195
196/** hotUnder for every folder at once, in one pass: folder → hot or warm marks inside it. */
197export function hotCounts(marks: Marks, now: number): Map<string, number> {
198  const counts = new Map<string, number>()
199  for (const [path, m] of Object.entries(marks)) {
200    if (heat(m, now) === 'cold') continue
201    for (const dir of ancestors(path)) counts.set(dir, (counts.get(dir) ?? 0) + 1)
202  }
203  return counts
204}
205
206/** The folders above every hot or warm mark: the tree opens them so the work shows. */
207export function litDirs(marks: Marks, now: number): Set<string> {
208  return new Set(hotCounts(marks, now).keys())
209}
210
211/** Whether the `activity` option lets a kind of touch light the tree. */
212export function allows(activity: Activity, kind: MarkKind): boolean {
213  if (activity === 'none') return false
214  return activity === 'all' || kind !== 'read'
215}
216
hooks/bashpaths.ts 211 lines
1// canopy reads Claude's shell commands for three things: the files a command reads (so they
2// light up), whether it changes the tree (so canopy rescans), and whether it changes git
3// (so the badges refresh). A light reading, not a shell: anything it can't be sure of is left
4// out. Pure, no mods API.
5
6type Token = { text: string; quoted: boolean }
7
8const READ_VERBS = new Set(['cat', 'head', 'tail', 'less', 'more', 'bat', 'nl', 'wc', 'sed', 'awk', 'grep', 'rg'])
9
10// Flags whose value is the next word, per verb.
11const VALUE_FLAGS: Record<string, Set<string>> = {
12  head: new Set(['-n', '-c']),
13  tail: new Set(['-n', '-c']),
14  grep: new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-d', '-D', '--regexp', '--file', '--include', '--exclude', '--exclude-dir']),
15  rg: new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-g', '-t', '-T', '-r', '-M', '-j', '--regexp', '--file', '--glob', '--iglob', '--type', '--type-not']),
16  sed: new Set(['-e', '-f', '-l', '--expression', '--file']),
17  awk: new Set(['-F', '-v', '-f']),
18  bat: new Set(['-l', '-r', '-H', '-m', '--language', '--line-range', '--highlight-line', '--map-syntax']),
19}
20
21// Verbs whose first argument is a pattern or a script, unless a flag already gave one.
22const SCRIPT_FLAGS: Record<string, Set<string>> = {
23  grep: new Set(['-e', '-f', '--regexp', '--file']),
24  rg: new Set(['-e', '-f', '--regexp', '--file']),
25  sed: new Set(['-e', '-f', '--expression', '--file']),
26  awk: new Set(['-f']),
27}
28
29const TREE_VERBS = new Set(['rm', 'mv', 'cp', 'mkdir', 'rmdir', 'touch', 'ln', 'unzip', 'tar', 'tee', 'rsync'])
30const GIT_TREE = new Set(['checkout', 'switch', 'stash', 'merge', 'pull', 'rebase', 'reset', 'restore', 'clean', 'rm', 'mv', 'clone', 'apply', 'cherry-pick', 'revert', 'am'])
31const GIT_STATUS = new Set(['commit', 'checkout', 'switch', 'stash', 'merge', 'pull', 'rebase', 'reset', 'restore', 'add', 'rm', 'mv', 'cherry-pick', 'revert', 'am', 'apply', 'clean', 'init', 'branch'])
32const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env'])
33const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
34const PACKAGE_CHANGES = new Set(['install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un', 'ci'])
35const PREFIX_WORDS = new Set(['sudo', 'env', 'time', 'nohup', 'command'])
36
37const MAX_READ_PATHS = 8
38// Words a path can't be sure of: globs, expansions, substitutions, redirects.
39const UNSURE = /[*$`()<>]/
40
41/** The paths a command reads, as written (relative or absolute), at most 8. */
42export function readPaths(command: string): string[] {
43  const found = new Set<string>()
44  for (const words of commands(command)) {
45    const verb = verbOf(words)
46    if (!READ_VERBS.has(verb)) continue
47    for (const path of argumentsOf(verb, words)) found.add(path)
48  }
49  return [...found].slice(0, MAX_READ_PATHS)
50}
51
52/** Whether a command adds, removes or moves files (a rescan is due). */
53export function changesTree(command: string): boolean {
54  return segments(command).some((tokens) => {
55    if (writesFile(tokens)) return true
56    const words = stripPrefix(tokens)
57    const verb = verbOf(words)
58    if (TREE_VERBS.has(verb)) return true
59    if (verb === 'git') return GIT_TREE.has(gitSubcommand(words))
60    return PACKAGE_MANAGERS.has(verb) && PACKAGE_CHANGES.has(words[1]?.text ?? '')
61  })
62}
63
64/** Whether a command changes what `git status` reports (or the branch). */
65export function touchesGit(command: string): boolean {
66  return commands(command).some((words) => verbOf(words) === 'git' && GIT_STATUS.has(gitSubcommand(words)))
67}
68
69/** Whether a command makes a commit. */
70export function isCommit(command: string): boolean {
71  return commands(command).some((words) => verbOf(words) === 'git' && gitSubcommand(words) === 'commit')
72}
73
74function commands(command: string): Token[][] {
75  return segments(command).map(stripPrefix).filter((words) => words.length > 0)
76}
77
78// Splits on && || ; | & and newlines outside quotes, then into words; quotes are removed and
79// a word remembers whether any of it was quoted. A '#' at the start of a word ends the line.
80function segments(command: string): Token[][] {
81  const out: Token[][] = []
82  let tokens: Token[] = []
83  let text = ''
84  let quoted = false
85  let inWord = false
86  let quote: string | null = null
87  const endWord = () => {
88    if (inWord) tokens.push({ text, quoted })
89    text = ''
90    quoted = false
91    inWord = false
92  }
93  const endSegment = () => {
94    endWord()
95    if (tokens.length) out.push(tokens)
96    tokens = []
97  }
98  for (let i = 0; i < command.length; i++) {
99    const c = command[i]!
100    const next = command[i + 1]
101    if (quote) {
102      if (c === quote) quote = null
103      else if (c === '\\' && quote === '"' && next !== undefined) text += command[++i]
104      else text += c
105      continue
106    }
107    if (c === '"' || c === "'") {
108      quote = c
109      quoted = true
110      inWord = true
111    } else if (c === '\\' && next !== undefined) {
112      i++
113      if (next !== '\n') {
114        text += next
115        inWord = true
116      }
117    } else if (c === '#' && !inWord) {
118      while (i + 1 < command.length && command[i + 1] !== '\n') i++
119    } else if (c === ' ' || c === '\t') {
120      endWord()
121    } else if (c === '\n' || c === ';') {
122      endSegment()
123    } else if (c === '|') {
124      endSegment()
125      if (next === '|' || next === '&') i++
126    } else if (c === '&' && next === '&') {
127      endSegment()
128      i++
129    } else if (c === '&' && !(text.endsWith('>') || text.endsWith('<') || next === '>')) {
130      endSegment() // a background job
131    } else {
132      text += c
133      inWord = true
134    }
135  }
136  endSegment()
137  return out
138}
139
140// Drops `sudo` (and its flags), `env`, `time`, `nohup`, `command` and VAR=value words.
141function stripPrefix(tokens: Token[]): Token[] {
142  let i = 0
143  while (i < tokens.length) {
144    const t = tokens[i]!.text
145    if (PREFIX_WORDS.has(t) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) i++
146    else if (i > 0 && t.startsWith('-')) i += t === '-u' || t === '-g' ? 2 : 1
147    else break
148  }
149  return tokens.slice(i)
150}
151
152function verbOf(words: Token[]): string {
153  const first = words[0]?.text ?? ''
154  return first.slice(first.lastIndexOf('/') + 1)
155}
156
157// The arguments of a read verb that name files: flags and their values, the pattern or script,
158// redirects and anything unsure are left out.
159function argumentsOf(verb: string, words: Token[]): string[] {
160  const paths: string[] = []
161  let needsScript = verb in SCRIPT_FLAGS
162  let flagsOver = false
163  for (let i = 1; i < words.length; i++) {
164    const { text, quoted } = words[i]!
165    const redirect = quoted ? null : redirectOf(text)
166    if (redirect) {
167      if (redirect.target === '') i++
168      continue
169    }
170    if (!flagsOver && text === '--') {
171      flagsOver = true
172    } else if (!flagsOver && text.startsWith('-') && text.length > 1) {
173      if (SCRIPT_FLAGS[verb]?.has(text)) needsScript = false
174      if (VALUE_FLAGS[verb]?.has(text)) i++
175    } else if (needsScript) {
176      needsScript = false
177    } else if (text && text !== '-' && !UNSURE.test(text)) {
178      paths.push(text)
179    }
180  }
181  return paths
182}
183
184// A redirect word: `>`, `2>>`, `&>`, `<`, `<<` (bare: the target is the next word) or with the
185// target attached (`>out.txt`, `2>&1`).
186function redirectOf(text: string): { op: string; target: string } | null {
187  const m = /^(?:\d+|&)?(>>?|<<?<?)(.*)$/.exec(text)
188  return m ? { op: m[1]!, target: m[2]! } : null
189}
190
191// Whether a segment sends output to a file (not /dev/null, not another descriptor).
192function writesFile(tokens: Token[]): boolean {
193  for (let i = 0; i < tokens.length; i++) {
194    const redirect = tokens[i]!.quoted ? null : redirectOf(tokens[i]!.text)
195    if (!redirect || !redirect.op.startsWith('>')) continue
196    const target = redirect.target || tokens[i + 1]?.text || ''
197    if (target && target !== '/dev/null' && !target.startsWith('&')) return true
198  }
199  return false
200}
201
202// The git sub-command after global options: `git -C app commit` → 'commit'.
203function gitSubcommand(words: Token[]): string {
204  for (let i = 1; i < words.length; i++) {
205    const text = words[i]!.text
206    if (GIT_VALUE_OPTIONS.has(text)) i++
207    else if (!text.startsWith('-')) return text
208  }
209  return ''
210}
211
hooks/git.ts 123 lines
1// canopy's view of git: the branch and a badge per changed path, read from
2// `git status --porcelain=v1 -b -z`, and which files a commit took. Pure, no mods API.
3import { ancestors, own, put } from './tree.ts'
4
5export type Badge = 'M' | 'A' | 'D' | 'R' | 'U'
6export type GitState = { branch: string | null; files: Record<string, Badge> }
7
8/** The one git command canopy runs: machine-readable, with the branch, NUL-separated. */
9export const STATUS_ARGV: readonly string[] = ['git', 'status', '--porcelain=v1', '-b', '-z']
10
11/**
12 * The same, just before a commit: every untracked file by name rather than its new folder as
13 * one entry, so committing a new folder can mark each file it took.
14 */
15export const COMMIT_STATUS_ARGV: readonly string[] = [...STATUS_ARGV, '--untracked-files=all']
16
17/** Outside a repository (or before the first status): no branch, no badges. */
18export const NO_GIT: GitState = { branch: null, files: {} }
19
20/** The commit HEAD names; it fails, printing nothing, before the first commit. */
21export const HEAD_ARGV: readonly string[] = ['git', 'rev-parse', '--verify', '-q', 'HEAD']
22
23// Most severe first: a folder shows the worst thing inside it.
24const SEVERITY: readonly Badge[] = ['D', 'M', 'A', 'R', 'U']
25
26export function parseStatus(out: string): GitState {
27  const fields = out.split('\0')
28  const files: Record<string, Badge> = {}
29  let branch: string | null = null
30  for (let i = 0; i < fields.length; i++) {
31    const field = fields[i]!
32    if (field.startsWith('## ')) {
33      branch = branchOf(field.slice(3))
34      continue
35    }
36    if (field.length < 4) continue
37    const x = field[0]!
38    const y = field[1]!
39    // A rename or copy is followed by the path it came from; that field is not an entry.
40    if (x === 'R' || x === 'C') i++
41    const badge = badgeOf(x, y)
42    if (badge) put(files, field.slice(3).replace(/\/$/, ''), badge)
43  }
44  return { branch, files }
45}
46
47function branchOf(line: string): string {
48  const unborn = /^(?:No commits yet on|Initial commit on) (.+)$/.exec(line)
49  if (unborn) return unborn[1]!
50  return line.split('...')[0]!.split(' ')[0]!
51}
52
53function badgeOf(x: string, y: string): Badge | null {
54  if (x === '!') return null
55  if (x === '?') return 'U'
56  if (x === 'D' || y === 'D') return 'D'
57  if (x === 'R') return 'R'
58  if (x === 'A' || x === 'C') return 'A'
59  return x !== ' ' || y !== ' ' ? 'M' : null
60}
61
62/**
63 * A path's own badge; a file inside an untracked folder is untracked too (git reports only
64 * the folder).
65 */
66export function badgeFor(files: Record<string, Badge>, rel: string): Badge | null {
67  const mine = own(files, rel)
68  if (mine) return mine
69  const parts = rel.split('/')
70  for (let i = parts.length - 1; i > 0; i--) {
71    if (own(files, parts.slice(0, i).join('/')) === 'U') return 'U'
72  }
73  return null
74}
75
76/** The most severe badge inside a folder ('' is the whole tree), or the folder's own. */
77export function rollup(files: Record<string, Badge>, dirRel: string): Badge | null {
78  const prefix = dirRel + '/'
79  let worst = SEVERITY.length
80  for (const [path, badge] of Object.entries(files)) {
81    if (dirRel !== '' && path !== dirRel && !path.startsWith(prefix)) continue
82    worst = Math.min(worst, SEVERITY.indexOf(badge))
83  }
84  return SEVERITY[worst] ?? (dirRel ? badgeFor(files, dirRel) : null)
85}
86
87/** rollup for every folder at once: a lookup built in one pass over the badges. */
88export function rollupAll(files: Record<string, Badge>): (dirRel: string) => Badge | null {
89  const worst = new Map<string, number>()
90  const note = (dir: string, rank: number) => {
91    const known = worst.get(dir)
92    if (known === undefined || rank < known) worst.set(dir, rank)
93  }
94  for (const [path, badge] of Object.entries(files)) {
95    const rank = SEVERITY.indexOf(badge)
96    note(path, rank)
97    for (const dir of ancestors(path)) note(dir, rank)
98  }
99  return (dirRel) => {
100    const rank = worst.get(dirRel)
101    if (rank !== undefined) return SEVERITY[rank]!
102    return dirRel ? badgeFor(files, dirRel) : null
103  }
104}
105
106/**
107 * Porcelain paths are relative to the repository's root. A session rooted in a subfolder
108 * (`git rev-parse --show-prefix` says `web/`) keeps the paths under it, relative to it.
109 */
110export function withinPrefix(state: GitState, prefix: string): GitState {
111  if (!prefix) return state
112  const files: Record<string, Badge> = {}
113  for (const [path, badge] of Object.entries(state.files)) {
114    if (path.startsWith(prefix)) put(files, path.slice(prefix.length), badge)
115  }
116  return { branch: state.branch, files }
117}
118
119/** The paths a commit took: changed before, clean after (not even inside an untracked folder). */
120export function committed(before: GitState, after: GitState): string[] {
121  return Object.keys(before.files).filter((path) => badgeFor(after.files, path) === null).sort()
122}
123
hooks/tree.ts 200 lines
1// canopy's tree model: which names to hide, how a folder's entries are ordered, and how the
2// expanded folders flatten into rows with guide lines. Pure, no mods API.
3
4export type Entry = { name: string; kind: 'file' | 'dir' }
5
6/** Folder listings by path relative to the root ('' is the root); no trailing '/'. */
7export type Listing = Record<string, Entry[]>
8
9/** Names hidden anywhere, path prefixes hidden from the root, and name suffixes (`.log`). */
10export type Ignore = { names: Set<string>; prefixes: string[]; suffixes: string[] }
11
12/**
13 * A record's own value for a key, never one inherited from Object.prototype: a file may well be
14 * named `constructor`.
15 */
16export function own<T>(record: Readonly<Record<string, T>>, key: string): T | undefined {
17  return Object.hasOwn(record, key) ? record[key] : undefined
18}
19
20/** Sets a record's own value, even for the key `__proto__`. */
21export function put<T>(record: Record<string, T>, key: string, value: T): void {
22  Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true })
23}
24
25export type Row =
26  | { type: 'root'; path: ''; name: string }
27  | { type: 'dir' | 'file'; path: string; name: string; depth: number; guides: string; expanded: boolean }
28  | { type: 'more'; path: string; depth: number; guides: string; count: number }
29
30/** Hidden in every project, whatever its .gitignore says. */
31export const BUILTIN_IGNORE: readonly string[] = [
32  '.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '__pycache__', '.venv', 'target', '.DS_Store', '.turbo', '.cache',
33]
34
35/**
36 * The most rows drawn after the root. Past it, the folder being walked and each open folder
37 * above it end in a "… N more" row.
38 */
39export const ROW_CAP = 2000
40
41/** The most entries one folder draws; the rest become its own "… N more" row. */
42export const FOLDER_CAP = 500
43
44// Guide pieces, three cells each: what an ancestor leaves behind, and the row's own connector.
45const PIPE = '│  '
46const GAP = '   '
47const TEE = '├─ '
48const ELBOW = '└─ '
49
50/**
51 * Reads the simple shapes of .gitignore lines: `name` hides that name anywhere, `*.ext` hides a
52 * suffix, `/x` and `x/y` hide a path from the root. Comments, blank lines and `!` negations are
53 * skipped, and so are globs this model can't express. `extra` is a comma-separated list read the
54 * same way. The built-ins always apply.
55 */
56export function ignoreRules(gitignore: string, extra: string): Ignore {
57  const ig: Ignore = { names: new Set(BUILTIN_IGNORE), prefixes: [], suffixes: [] }
58  const patterns = [...gitignore.split(/\r?\n/), ...extra.split(',')]
59  for (const raw of patterns) {
60    let p = raw.trim()
61    if (!p || p.startsWith('#') || p.startsWith('!')) continue
62    p = p.replace(/\/+$/, '').replace(/^\*\*\//, '')
63    if (!p) continue
64    if (/^\*\.[^*?[\]/]+$/.test(p)) ig.suffixes.push(p.slice(1))
65    else if (/[*?[\]]/.test(p)) continue
66    else if (p.includes('/')) ig.prefixes.push(p.replace(/^\/+/, ''))
67    else ig.names.add(p)
68  }
69  return ig
70}
71
72/** Whether the entry `name` at relative path `rel` is hidden by the rules. */
73export function isIgnored(rel: string, name: string, ig: Ignore): boolean {
74  if (ig.names.has(name)) return true
75  if (ig.suffixes.some((s) => name.endsWith(s))) return true
76  return ig.prefixes.some((p) => rel === p || rel.startsWith(p + '/'))
77}
78
79/** Whether a path is hidden because it, or any folder above it, is ignored. */
80export function isHidden(rel: string, ig: Ignore): boolean {
81  let at = ''
82  for (const name of rel.split('/')) {
83    at = join(at, name)
84    if (isIgnored(at, name, ig)) return true
85  }
86  return false
87}
88
89/** One folder's listing as the tree shows it: ignored names dropped, folders first, natural order. */
90export function toEntries(raw: ReadonlyArray<{ name: string; kind: string }>, dirRel: string, ig: Ignore): Entry[] {
91  const entries: Entry[] = []
92  for (const r of raw) {
93    if (isIgnored(join(dirRel, r.name), r.name, ig)) continue
94    // A link ('other') is shown as a file: following it could loop.
95    entries.push({ name: r.name, kind: r.kind === 'dir' ? 'dir' : 'file' })
96  }
97  return entries.sort((a, b) => {
98    if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
99    return byName(a.name, b.name)
100  })
101}
102
103function byName(a: string, b: string): number {
104  const folded = a.toLowerCase().localeCompare(b.toLowerCase(), undefined, { numeric: true })
105  return folded !== 0 ? folded : a.localeCompare(b, undefined, { numeric: true })
106}
107
108/**
109 * The rows to draw: the root, then every entry of each expanded folder under its parent. A row's
110 * `guides` is drawn before its glyph: `│  ` or blank for each ancestor (blank when that ancestor
111 * was the last of its folder), then `├─ ` or `└─ ` for the row itself. A folder past `folderCap`
112 * entries ends in a "… N more" row and the walk goes on, so one huge folder hides nothing else.
113 */
114export function flatten(
115  rootName: string, listing: Listing, isExpanded: (rel: string) => boolean, cap = ROW_CAP, folderCap = FOLDER_CAP,
116): Row[] {
117  const rows: Row[] = [{ type: 'root', path: '', name: rootName }]
118  let full = false
119
120  const walk = (dirRel: string, depth: number, prefix: string): void => {
121    const entries = own(listing, dirRel) ?? []
122    const more = (from: number) => rows.push({ type: 'more', path: dirRel, depth, guides: prefix + ELBOW, count: entries.length - from })
123    for (let i = 0; i < entries.length; i++) {
124      if (rows.length - 1 >= cap) full = true
125      if (full || i >= folderCap) {
126        more(i)
127        return
128      }
129      const entry = entries[i]!
130      const isLast = i === entries.length - 1
131      const path = join(dirRel, entry.name)
132      const guides = prefix + (isLast ? ELBOW : TEE)
133      if (entry.kind === 'file') {
134        rows.push({ type: 'file', path, name: entry.name, depth, guides, expanded: false })
135        continue
136      }
137      const expanded = own(listing, path) !== undefined && isExpanded(path)
138      rows.push({ type: 'dir', path, name: entry.name, depth, guides, expanded })
139      if (expanded) walk(path, depth + 1, prefix + (isLast ? GAP : PIPE))
140      if (full) {
141        if (!isLast) more(i + 1)
142        return
143      }
144    }
145  }
146
147  walk('', 0, '')
148  return rows
149}
150
151/** The folders above a path, root first: 'a/b/c.ts' → ['', 'a', 'a/b']. */
152export function ancestors(rel: string): string[] {
153  if (!rel) return []
154  const parts = rel.split('/')
155  const out = ['']
156  for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join('/'))
157  return out
158}
159
160/**
161 * A path relative to `root`, or null when it lies outside (or can't be read as a path).
162 * Relative paths resolve against `cwd` (default: the root); `~` is the shell's, so it is outside.
163 */
164export function relativeTo(root: string, path: string, cwd?: string): string | null {
165  if (!path || path.startsWith('~')) return null
166  const abs = normalize(path.startsWith('/') ? path : (cwd ?? root) + '/' + path)
167  const top = normalize(root)
168  if (abs === null || top === null) return null
169  if (top === '/') return abs.slice(1)
170  if (abs === top) return ''
171  return abs.startsWith(top + '/') ? abs.slice(top.length + 1) : null
172}
173
174// Folds '.', '..' and repeated or trailing slashes; null when '..' climbs above '/'.
175function normalize(path: string): string | null {
176  const out: string[] = []
177  for (const part of path.split('/')) {
178    if (!part || part === '.') continue
179    if (part !== '..') out.push(part)
180    else if (out.length === 0) return null
181    else out.pop()
182  }
183  return '/' + out.join('/')
184}
185
186export function join(dirRel: string, name: string): string {
187  return dirRel ? dirRel + '/' + name : name
188}
189
190/** The row that shows a path: the path itself, or the first closed folder above it. */
191export function anchorFor(rel: string, isOpen: (dirRel: string) => boolean): string {
192  const parts = rel.split('/')
193  let dir = ''
194  for (let i = 0; i < parts.length - 1; i++) {
195    dir = join(dir, parts[i]!)
196    if (!isOpen(dir)) return dir
197  }
198  return rel
199}
200
hooks/view.ts 424 lines
1// canopy's look: every line of the pane as styled segments, decided here so register.ts only
2// turns them into elements. Pure, no mods API.
3//
4// A line is drawn as one row Box: its lead (the tree's guide lines), its left segments (wrapped
5// in a Button when it toggles a folder, so focus and the pointer light the folder, not the
6// guides), then, when it has a right side, a growing spacer and the right segments. A segment
7// with no style is drawn as a bare string; a styled one as a Text.
8import { own, type Row } from './tree.ts'
9import { rollup, stateOf, tally, type Activity, type FileState, type Marks } from './activity.ts'
10import { badgeFor, rollupAll, type Badge, type GitState } from './git.ts'
11import { iconFor, swatchFor } from './filetypes.ts'
12
13/**
14 * `keep`: the end of a left side a cut leaves whole while it can (a closed folder's count).
15 * `bg`: a tile behind the text (a file's badge).
16 */
17export type Seg = { text: string; color?: string; bg?: string; bold?: true; dim?: true; keep?: true }
18/**
19 * One row of the pane: `lead`, then `left`, then `right` at the edge. `toggle` is the folder a
20 * press on `left` opens or closes.
21 */
22export type Line = { key: string; lead?: Seg[]; left: Seg[]; right: Seg[]; toggle?: string }
23export type ViewInput = {
24  rows: Row[]
25  marks: Marks
26  git: GitState
27  now: number
28  frame: number
29  width: number
30  activity: Activity
31  /** How a file's type shows: a two-letter badge (the default) or a small colored glyph. */
32  icons?: Icons
33}
34export type Icons = 'badges' | 'swatches'
35export type PaneStatus = 'loading' | 'ready' | 'empty' | 'unreadable'
36
37export const SPIN: readonly string[] = ['◐', '◓', '◑', '◒']
38export const BRAILLE: readonly string[] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
39
40/**
41 * Elements a pane may hold, with room to spare under the engine's 20,000 (every element and
42 * every string counts): a tree past it is refused whole. A tree that would pass it ends in a
43 * "… N more" line instead.
44 */
45export const NODE_BUDGET = 19_000
46
47/** Characters a pane draws: the engine draws 100,000 in all, so the end stays in sight. */
48export const CHAR_BUDGET = 95_000
49
50const BADGE_COLOR: Record<Badge, string> = { M: 'warning', A: 'success', D: 'error', R: 'suggestion', U: 'success' }
51
52/**
53 * Each state's color: Claude's own while it writes, then git's for what waits for a commit
54 * (yellow, like M) and what landed (green); blue for reading. Theme keys, so they follow the
55 * person's theme.
56 */
57export const STATE_COLOR: Readonly<Record<FileState, string>> = {
58  editing: 'claude', edited: 'warning', committed: 'success', reading: 'suggestion', read: 'suggestion',
59}
60
61/**
62 * The glyph a state draws in a row's activity column: a pencil pulsing bright and dim while
63 * Claude writes, a dot for a change not yet committed, a check once committed, a quarter moon
64 * turning while Claude reads, a ring once read.
65 */
66export function stateGlyph(state: FileState, frame: number): Seg {
67  switch (state) {
68    case 'editing':
69      return { text: '✎', color: STATE_COLOR.editing, ...(frame % 2 === 0 ? {} : { dim: true as const }) }
70    case 'edited':
71      return { text: '●', color: STATE_COLOR.edited }
72    case 'committed':
73      return { text: '✓', color: STATE_COLOR.committed }
74    case 'reading':
75      return { text: pick(SPIN, frame), color: STATE_COLOR.reading }
76    case 'read':
77      return { text: '○', color: STATE_COLOR.read }
78  }
79}
80
81/** A folder's roll-up of the badges inside it: a small dot in the worst one's color, never ● (editing). */
82export const ROLLUP = '•'
83
84/** The fewest cells a cut branch name keeps; below it the branch is left out. */
85const BRANCH_MIN = 6
86
87/**
88 * `◆ canopy · ⎇ main · ● 3 · ✓ 1 · ○ 2` (changed, committed, read), and a spinner on the right
89 * while Claude is reading or writing a file. Only the branch is cut to fit, since the counts
90 * are what change; and the spinner's two cells are always left free, so the line never moves
91 * when it comes and goes.
92 */
93export function headerLine(input: ViewInput): Line {
94  const { edited, committed, read } = tally(input.marks, input.now, input.activity)
95  const title: Seg = { text: '◆ canopy', bold: true }
96  const dot: Seg = { text: ' · ', dim: true }
97  const counts: Seg[] = []
98  if (edited > 0) counts.push(dot, { text: '● ' + edited, color: STATE_COLOR.edited })
99  if (committed > 0) counts.push(dot, { text: '✓ ' + committed, color: STATE_COLOR.committed })
100  if (read > 0) counts.push(dot, { text: '○ ' + read, color: STATE_COLOR.read })
101  const left: Seg[] = [title]
102  if (input.git.branch !== null) {
103    const room = input.width - 2 - cells(title.text + dot.text + '⎇ ') - cellsIn(counts)
104    const branch = printable(input.git.branch)
105    if (cells(branch) <= room) left.push(dot, { text: '⎇ ' + branch, dim: true })
106    else if (room >= BRANCH_MIN) left.push(dot, { text: '⎇ ' + take(branch, room - 1) + '…', dim: true })
107  }
108  left.push(...counts)
109  const right: Seg[] = working(input) ? [{ text: pick(BRAILLE, input.frame), color: STATE_COLOR.editing }] : []
110  return { key: 'header', left: merge(left), right }
111}
112
113// Whether Claude is reading or writing a file right now, as the activity option shows it.
114function working(input: ViewInput): boolean {
115  return Object.values(input.marks).some((m) => {
116    const state = stateOf(m, input.now, input.activity)
117    return state === 'editing' || state === 'reading'
118  })
119}
120
121/** A row's key: 'row:' + its path ('row:' is the root), drawable as any text is. */
122export function rowKey(path: string): string {
123  return 'row:' + printable(path)
124}
125
126/**
127 * A name as the pane can draw it: the engine refuses a text holding a control character (and
128 * a newline would break the row), so each becomes '?', as `ls` shows them. Paths keep the name.
129 */
130export function printable(text: string): string {
131  return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?')
132}
133
134/** One line per row, keyed by rowKey(). */
135export function treeLines(input: ViewInput): Line[] {
136  const slots = hasGit(input.git)
137  // Folder facts come from one pass each, not one scan per folder row.
138  const inside = rollup(input.marks, input.now, input.activity)
139  const badgeOf = rollupAll(input.git.files)
140  const guides = guidesFor(input.width, slots)
141  return input.rows.map((row): Line => {
142    switch (row.type) {
143      case 'root':
144        return {
145          key: rowKey(''),
146          left: [{ text: '▾ ', dim: true }, { text: printable(row.name) + '/', bold: true }],
147          right: column(null, badgeOf(''), slots, ROLLUP),
148        }
149      case 'dir':
150        return dirLine({ ...row, guides: guides(row.guides) }, row.expanded ? null : inside.get(row.path) ?? null, badgeOf(row.path), slots, input.frame)
151      case 'file':
152        return fileLine({ ...row, guides: guides(row.guides) }, stateOf(own(input.marks, row.path), input.now, input.activity), input, slots)
153      case 'more':
154        return { key: 'more:' + printable(row.path), left: [{ text: guides(row.guides) + `… ${row.count} more`, dim: true }], right: [] }
155    }
156  })
157}
158
159/** What stands for the levels a deep row's guides leave out. */
160export const DEEPER = '⋯  '
161
162// The guides a row draws. They take at most half of what a row has beside its right-hand
163// column, so a deep row keeps its name: past that, a row shows its last levels after a dim '⋯',
164// and every row that deep lines up at the same indent. Each guide piece is three cells.
165function guidesFor(width: number, slots: boolean): (guides: string) => string {
166  const room = width - (slots ? 4 : 2) - 2
167  const pieces = Math.max(2, Math.floor(room / 6))
168  return (guides) => (guides.length <= pieces * 3 ? guides : DEEPER + guides.slice(guides.length - (pieces - 1) * 3))
169}
170
171// `inside`: a closed folder's strongest state and how many files inside share it, so the work
172// in there stays in sight; null for an open folder (its rows show it) or a quiet one.
173function dirLine(row: Extract<Row, { type: 'dir' }>, inside: { state: FileState; count: number } | null, badge: Badge | null, slots: boolean, frame: number): Line {
174  const color = inside ? STATE_COLOR[inside.state] : undefined
175  const left: Seg[] = [{ text: row.expanded ? '▾ ' : '▸ ', dim: true }, { text: printable(row.name) + '/', bold: true, ...(color ? { color } : {}) }]
176  if (inside) left.push({ text: ' ·' + inside.count, color, keep: true })
177  const glyph = inside ? stateGlyph(inside.state, frame) : null
178  return { key: rowKey(row.path), lead: [{ text: row.guides, dim: true }], left, right: column(glyph, badge, slots, ROLLUP), toggle: row.path }
179}
180
181function fileLine(row: Extract<Row, { type: 'file' }>, state: FileState | null, input: ViewInput, slots: boolean): Line {
182  const badge = badgeFor(input.git.files, row.path)
183  const name: Seg = { text: printable(row.name), ...nameLook(state), ...(badge === 'D' ? { color: 'error' } : {}) }
184  return {
185    key: rowKey(row.path),
186    lead: [{ text: row.guides, dim: true }],
187    left: merge([...iconSegs(row.name, input.icons ?? 'badges'), name]),
188    right: column(state ? stateGlyph(state, input.frame) : null, badge, slots),
189  }
190}
191
192// A file's name in its state's color, bold while Claude writes it. A file only read keeps its
193// plain name: the ring beside it says enough.
194function nameLook(state: FileState | null): Omit<Seg, 'text'> {
195  if (state === null || state === 'read') return {}
196  return state === 'editing' ? { color: STATE_COLOR.editing, bold: true } : { color: STATE_COLOR[state] }
197}
198
199// The icon before a name: a bold two-letter badge on its tile and a space, or a swatch.
200function iconSegs(name: string, icons: Icons): Seg[] {
201  if (icons === 'swatches') {
202    const swatch = swatchFor(name)
203    return [{ text: swatch.glyph + ' ', color: swatch.color }]
204  }
205  const icon = iconFor(name)
206  return [{ text: icon.label, color: icon.fg, bg: icon.bg, bold: true }, { text: ' ' }]
207}
208
209// The right side. In a repository it is two columns, activity then badge, so badges line up
210// at the edge and activity two cells before them; elsewhere just the activity glyph.
211function column(glyph: Seg | null, badge: Badge | null, slots: boolean, badgeGlyph?: string): Seg[] {
212  if (!slots || badge === null) {
213    if (!glyph) return []
214    return [slots ? { ...glyph, text: glyph.text + '  ' } : glyph]
215  }
216  const mark: Seg = { text: badgeGlyph ?? badge, color: BADGE_COLOR[badge], ...(badge === 'U' ? { dim: true as const } : {}) }
217  return merge([glyph ? { ...glyph, text: glyph.text + ' ' } : { text: '  ' }, mark])
218}
219
220/**
221 * The key to the glyphs: what Claude changed, what it read (as the activity option allows),
222 * then git's letters in a repository. Each group is packed into as many lines as `width`
223 * needs; an entry is never cut in two.
224 */
225export function legendLines(activity: Activity, git: boolean, width = Infinity): Line[] {
226  const lines: Line[] = []
227  const entry = (state: FileState): [Seg, string] => [stateGlyph(state, 0), state]
228  if (activity !== 'none') lines.push(...pack('legend', [entry('editing'), entry('edited'), entry('committed')], width))
229  if (activity === 'all') lines.push(...pack('legend:read', [entry('reading'), entry('read')], width))
230  if (git) {
231    const label: Record<Badge, string> = { M: 'modified', A: 'added', U: 'untracked', D: 'deleted', R: 'renamed' }
232    const items = (['M', 'A', 'U', 'D', 'R'] as const).map((b): [Seg, string] => [
233      { text: b, color: BADGE_COLOR[b], ...(b === 'U' ? { dim: true as const } : {}) },
234      label[b],
235    ])
236    lines.push(...pack('legend:git', items, width))
237  }
238  return lines
239}
240
241// Legend entries (a glyph, then its label in dim) two cells apart, a new line when the next
242// one would pass `width`. Lines after the first are keyed `<key>:2`, `<key>:3`…
243function pack(key: string, items: Array<[Seg, string]>, width: number): Line[] {
244  const rows: Seg[][] = []
245  let used = 0
246  for (const [glyph, label] of items) {
247    const entry: Seg[] = [glyph, { text: ' ' + label, dim: true }]
248    const w = cellsIn(entry)
249    const row = rows[rows.length - 1]
250    if (row && used + 2 + w <= width) {
251      row.push({ text: '  ', dim: true }, ...entry)
252      used += 2 + w
253    } else {
254      rows.push(entry)
255      used = w
256    }
257  }
258  return rows.map((segs, i) => ({ key: i === 0 ? key : `${key}:${i + 1}`, left: merge(segs), right: [] }))
259}
260
261/** What the pane says instead of a tree: before the first scan, when it fails, or when empty. */
262export function emptyLine(reason: Exclude<PaneStatus, 'ready'>, root: string): Line {
263  const where = printable(root)
264  const say: Record<typeof reason, Seg> = {
265    loading: { text: `Reading ${where}…`, dim: true },
266    unreadable: { text: `canopy can't read ${where} — /canopy refresh tries again.` },
267    empty: { text: 'Empty folder — files appear here as Claude creates them.', dim: true },
268  }
269  return { key: 'empty', left: [say[reason]], right: [] }
270}
271
272/**
273 * Every line of the pane, fitted to its width and kept under the node budget. The empty-state
274 * sentence is left whole: it wraps rather than being cut.
275 */
276export function paneLines(input: ViewInput, status: PaneStatus, root: string): Line[] {
277  const header = fit(headerLine(input), input.width)
278  if (status === 'loading' || status === 'unreadable') return [header, emptyLine(status, root)]
279  const tree = treeLines(input)
280  if (status === 'empty') return [header, ...tree.map((l) => fit(l, input.width)), emptyLine('empty', root)]
281  const legend = legendLines(input.activity, hasGit(input.git), input.width)
282  const tail = [...(legend.length ? [{ key: 'spacer', left: [{ text: ' ' }], right: [] }] : []), ...legend].map((l) => fit(l, input.width))
283  const fixed = [header, ...tail]
284  const nodes = NODE_BUDGET - 1 - fixed.reduce((n, l) => n + nodesOf(l), 0)
285  const chars = CHAR_BUDGET - fixed.reduce((n, l) => n + charsOf(l), 0)
286  return [header, ...budgeted(tree.map((l) => fit(l, input.width)), nodes, chars), ...tail]
287}
288
289// Keeps lines while they fit both budgets; the rest become one "… N more" line.
290function budgeted(lines: Line[], nodes: number, chars: number): Line[] {
291  const more = (n: number): Line => ({ key: 'overflow', left: [{ text: `… ${n} more`, dim: true }], right: [] })
292  const reserve = more(lines.length)
293  let usedNodes = nodesOf(reserve)
294  let usedChars = charsOf(reserve)
295  for (let i = 0; i < lines.length; i++) {
296    usedNodes += nodesOf(lines[i]!)
297    usedChars += charsOf(lines[i]!)
298    if (usedNodes > nodes || usedChars > chars) return [...lines.slice(0, i), more(lines.length - i)]
299  }
300  return lines
301}
302
303function charsOf(line: Line): number {
304  return [...(line.lead ?? []), ...line.left, ...line.right].reduce((n, s) => n + s.text.length, 0)
305}
306
307/** The elements a line draws as (see the note at the top): a Button only around a left side. */
308export function nodesOf(line: Line): number {
309  const segs = (list: Seg[]) => list.reduce((n, s) => n + (isPlain(s) ? 1 : 2), 0)
310  const button = line.toggle !== undefined && line.left.length > 0 ? 1 : 0
311  return 1 + segs(line.lead ?? []) + button + segs(line.left) + (line.right.length ? 1 + segs(line.right) : 0)
312}
313
314/**
315 * Cuts the lead and left side with '…' so the line fits `width` cells; the right side is never
316 * cut. A kept end stays whole after the cut while the rest still has a few cells to show.
317 */
318export function fit(line: Line, width: number): Line {
319  const lead = line.lead ?? []
320  const rightCells = cellsIn(line.right)
321  const room = width - rightCells - (rightCells > 0 ? 1 : 0)
322  if (cellsIn(lead) + cellsIn(line.left) <= room) return line
323  let end = line.left.length
324  while (end > 0 && line.left[end - 1]!.keep) end--
325  const tail = line.left.slice(end)
326  const keep = tail.length > 0 && room - cellsIn(tail) >= cellsIn(lead) + 4
327  const kept = keep ? cut([...lead, ...line.left.slice(0, end)], room - cellsIn(tail)) : cut([...lead, ...line.left], room)
328  const left = [...kept.slice(lead.length), ...(keep ? tail : [])]
329  return line.lead === undefined ? { ...line, left } : { ...line, lead: kept.slice(0, lead.length), left }
330}
331
332// The segments that fit `room` cells, the last one cut and ended with '…'; none below one cell.
333function cut(segs: Seg[], room: number): Seg[] {
334  const out: Seg[] = []
335  let used = 0
336  for (const seg of segs) {
337    const w = cells(seg.text)
338    if (used + w <= room - 1) {
339      out.push(seg)
340      used += w
341      continue
342    }
343    if (room >= 1) out.push({ ...seg, text: take(seg.text, room - 1 - used) + '…' })
344    break
345  }
346  return out
347}
348
349function cellsIn(segs: Seg[]): number {
350  return segs.reduce((n, s) => n + cells(s.text), 0)
351}
352
353/**
354 * Terminal cells a string takes: wide characters (East Asian, emoji) take two, combining marks
355 * and joiners none. A guess that errs wide, so a row never runs past its width.
356 */
357export function cells(text: string): number {
358  let n = 0
359  let before = 0
360  for (const ch of text) {
361    const w = widthOf(ch, before)
362    n += w
363    if (w > 0) before = ch === EMOJI_LOOK ? 2 : w
364  }
365  return n
366}
367
368function take(text: string, room: number): string {
369  let out = ''
370  let used = 0
371  let before = 0
372  for (const ch of text) {
373    const w = widthOf(ch, before)
374    if (used + w > room) break
375    out += ch
376    used += w
377    if (w > 0) before = ch === EMOJI_LOOK ? 2 : w
378  }
379  return out
380}
381
382/** U+FE0F asks for a character's emoji look, two cells wide. */
383const EMOJI_LOOK = '\ufe0f'
384const WIDE_EMOJI = /\p{Emoji_Presentation}/u
385const NO_WIDTH = /[\p{Mn}\p{Me}\u200b-\u200f\u2060\ufe00-\ufe0e]/u
386
387// The cells `ch` adds after a character `before` cells wide.
388function widthOf(ch: string, before: number): number {
389  const cp = ch.codePointAt(0)!
390  if (cp < 0x300) return 1
391  if (ch === EMOJI_LOOK) return before === 1 ? 1 : 0
392  if (NO_WIDTH.test(ch)) return 0
393  const wide =
394    (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
395    (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
396    (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd) || WIDE_EMOJI.test(ch)
397  return wide ? 2 : 1
398}
399
400// Joins neighbours that look the same (fewer elements to draw) and drops empty ones.
401function merge(segs: Seg[]): Seg[] {
402  const out: Seg[] = []
403  for (const seg of segs) {
404    if (!seg.text) continue
405    const last = out[out.length - 1]
406    const same = last && last.color === seg.color && last.bg === seg.bg && last.bold === seg.bold && last.dim === seg.dim && last.keep === seg.keep
407    if (same) out[out.length - 1] = { ...last, text: last.text + seg.text }
408    else out.push(seg)
409  }
410  return out
411}
412
413export function isPlain(seg: Seg): boolean {
414  return seg.color === undefined && seg.bg === undefined && !seg.bold && !seg.dim
415}
416
417function hasGit(git: GitState): boolean {
418  return git.branch !== null || Object.keys(git.files).length > 0
419}
420
421function pick(frames: readonly string[], frame: number): string {
422  return frames[((frame % frames.length) + frames.length) % frames.length]!
423}
424
hooks/filetypes.ts 151 lines
1// canopy's file icons: a colored badge (or, with icons: swatches, a colored glyph) before each
2// file name, so a glance tells TypeScript from Markdown from config. Pure data, no mods API.
3import { own } from './tree.ts'
4
5export type Swatch = { glyph: string; color: string }
6
7const DEFAULT: Swatch = { glyph: '▪', color: 'subtle' }
8
9// Extension → color. Colors follow each language's widely used brand color, moved to a mid
10// tone where needed: a swatch must read on light and dark themes alike (3:1 against both).
11export const BY_EXT: Readonly<Record<string, string>> = {
12  ts: '#3178C6', tsx: '#3178C6', mts: '#3178C6', cts: '#3178C6',
13  js: '#A6950E', jsx: '#A6950E', mjs: '#A6950E', cjs: '#A6950E',
14  json: '#B5902F', jsonc: '#B5902F', json5: '#B5902F',
15  md: '#7698B4', mdx: '#7698B4', txt: '#949494', rst: '#949494',
16  css: '#A56EDC', scss: '#C6538C', sass: '#C6538C', less: '#4C73B6',
17  html: '#E34C26', htm: '#E34C26', vue: '#3BA777', svelte: '#FF3E00', astro: '#FF5D01',
18  py: '#3572A5', ipynb: '#DA5B0B', rb: '#CC342D', go: '#00A1C9', rs: '#D18052',
19  java: '#B07219', kt: '#A97BFF', kts: '#A97BFF', swift: '#F05138', dart: '#00A59C',
20  c: '#8E8E8E', h: '#8E8E8E', cpp: '#F34B7D', hpp: '#F34B7D', cs: '#178600',
21  php: '#5766A4', lua: '#5E7CE2', ex: '#835896', exs: '#835896', zig: '#E77533',
22  sh: '#54A81E', bash: '#54A81E', zsh: '#54A81E', fish: '#54A81E', ps1: '#5391FE',
23  yml: '#CB4B4B', yaml: '#CB4B4B', toml: '#AF4A25', ini: '#AF4A25', env: '#C08A26',
24  sql: '#D18100', prisma: '#5A67D8', graphql: '#E10098', gql: '#E10098', proto: '#7A9A4A',
25  png: '#B088A9', jpg: '#B088A9', jpeg: '#B088A9', gif: '#B088A9', svg: '#D48000', webp: '#B088A9', ico: '#B088A9',
26  mp4: '#B088A9', mov: '#B088A9', mp3: '#B088A9', wav: '#B088A9', pdf: '#E5534B',
27  lock: '#6E6E6E', log: '#6E6E6E',
28}
29
30// Whole names that mean more than their extension.
31export const BY_NAME: Readonly<Record<string, Swatch>> = {
32  'package.json': { glyph: '◆', color: '#CB3837' },
33  'tsconfig.json': { glyph: '◆', color: '#3178C6' },
34  dockerfile: { glyph: '◆', color: '#2496ED' },
35  'docker-compose.yml': { glyph: '◆', color: '#2496ED' },
36  makefile: { glyph: '◆', color: '#6D8086' },
37  'readme.md': { glyph: '◆', color: '#7698B4' },
38  license: { glyph: '◆', color: '#B38F00' },
39  '.gitignore': { glyph: '▪', color: '#F05032' },
40  '.env': { glyph: '▪', color: '#C08A26' },
41  'claude.md': { glyph: '◆', color: '#D97757' },
42}
43
44/** The swatch for a file name: special names first, then the extension, then a dim square. */
45export function swatchFor(name: string): Swatch {
46  const lower = name.toLowerCase()
47  const special = own(BY_NAME, lower)
48  if (special) return special
49  if (lower.startsWith('.env')) return BY_NAME['.env']!
50  const dot = lower.lastIndexOf('.')
51  if (dot <= 0) return DEFAULT
52  const color = own(BY_EXT, lower.slice(dot + 1))
53  return color ? { glyph: '▪', color } : DEFAULT
54}
55
56/**
57 * A badge: a two-cell label on a colored tile, as an editor's file icons look. The tile carries
58 * its own text color, so it reads the same on any theme.
59 */
60export type FileIcon = { label: string; bg: string; fg: string }
61
62/** The tile of a file canopy has no badge for: blank, like a sheet of paper. */
63export const PLAIN_TILE = '#5A5F69'
64
65/** The tile of a type canopy doesn't know: its extension's first letters on gray. */
66export const UNKNOWN_TILE = '#6B7280'
67
68// [label, tile, text]: the label is two cells; the text color is worked out from the tile
69// unless given. Tiles follow each language's brand color.
70type Spec = readonly [label: string, bg: string, fg?: string]
71
72const LOCK: Spec = ['LK', '#6B7280']
73const DOCKER: Spec = ['DK', '#2496ED']
74const GIT: Spec = ['GI', '#F05032']
75const CONFIG: Spec = ['CF', '#6D8086']
76const LICENSE: Spec = ['© ', '#B38F00']
77
78export const BADGE_BY_EXT: Readonly<Record<string, Spec>> = {
79  ts: ['TS', '#3178C6'], mts: ['TS', '#3178C6'], cts: ['TS', '#3178C6'], tsx: ['TS', '#1F9BCF'],
80  js: ['JS', '#F0DB4F'], mjs: ['JS', '#F0DB4F'], cjs: ['JS', '#F0DB4F'], jsx: ['JS', '#1F9BCF'],
81  json: ['{}', '#C9A227'], jsonc: ['{}', '#C9A227'], json5: ['{}', '#C9A227'],
82  md: ['M↓', '#4A6A8A'], mdx: ['M↓', '#4A6A8A'], markdown: ['M↓', '#4A6A8A'], txt: ['TX', '#6B7280'], rst: ['TX', '#6B7280'],
83  css: ['CS', '#663399'], scss: ['SC', '#C6538C'], sass: ['SC', '#C6538C'], less: ['LE', '#1D365D'],
84  html: ['<>', '#E34C26'], htm: ['<>', '#E34C26'], xml: ['<>', '#0060AC'], svg: ['SG', '#FFB13B'],
85  vue: ['VU', '#41B883'], svelte: ['SV', '#FF3E00'], astro: ['AS', '#FF5D01'],
86  py: ['PY', '#3572A5', '#FFD43B'], ipynb: ['NB', '#DA5B0B'], rb: ['RB', '#CC342D'], go: ['GO', '#00ADD8'],
87  rs: ['RS', '#DEA584'], java: ['JV', '#B07219'], kt: ['KT', '#7F52FF'], kts: ['KT', '#7F52FF'],
88  swift: ['SW', '#F05138'], dart: ['DT', '#00B4AB'], c: ['C ', '#5C6BC0'], h: ['H ', '#5C6BC0'],
89  cpp: ['C+', '#F34B7D'], cc: ['C+', '#F34B7D'], cxx: ['C+', '#F34B7D'], hpp: ['H+', '#F34B7D'], cs: ['C#', '#178600'],
90  php: ['PH', '#777BB4'], lua: ['LU', '#000080'], ex: ['EX', '#6E4A7E'], exs: ['EX', '#6E4A7E'], zig: ['ZG', '#EC915C'],
91  sh: ['$_', '#4EAA25'], bash: ['$_', '#4EAA25'], zsh: ['$_', '#4EAA25'], fish: ['$_', '#4EAA25'], ps1: ['PS', '#012456'],
92  yml: ['YM', '#CB171E'], yaml: ['YM', '#CB171E'], toml: ['TO', '#9C4221'], ini: CONFIG, cfg: CONFIG, conf: CONFIG,
93  env: ['EN', '#ECD53F'], sql: ['SQ', '#E38C00'], prisma: ['PR', '#2D3748'], graphql: ['GQ', '#E10098'], gql: ['GQ', '#E10098'],
94  proto: ['PB', '#4285F4'], csv: ['CV', '#237346'], tsv: ['CV', '#237346'],
95  png: ['IM', '#8E5BB5'], jpg: ['IM', '#8E5BB5'], jpeg: ['IM', '#8E5BB5'], gif: ['IM', '#8E5BB5'], webp: ['IM', '#8E5BB5'],
96  ico: ['IM', '#8E5BB5'], bmp: ['IM', '#8E5BB5'], avif: ['IM', '#8E5BB5'],
97  mp4: ['VD', '#8E5BB5'], mov: ['VD', '#8E5BB5'], webm: ['VD', '#8E5BB5'], mp3: ['AU', '#8E5BB5'], wav: ['AU', '#8E5BB5'],
98  ogg: ['AU', '#8E5BB5'], flac: ['AU', '#8E5BB5'], pdf: ['PD', '#E5252A'],
99  lock: LOCK, log: ['LG', '#6B7280'], zip: ['ZP', '#6B7280'], gz: ['ZP', '#6B7280'], tar: ['ZP', '#6B7280'], tgz: ['ZP', '#6B7280'],
100}
101
102export const BADGE_BY_NAME: Readonly<Record<string, Spec>> = {
103  'package.json': ['{}', '#CB3837'], 'tsconfig.json': ['{}', '#3178C6'],
104  'package-lock.json': LOCK, 'yarn.lock': LOCK, 'pnpm-lock.yaml': LOCK, 'bun.lock': LOCK, 'bun.lockb': LOCK,
105  dockerfile: DOCKER, 'docker-compose.yml': DOCKER, 'docker-compose.yaml': DOCKER, 'compose.yml': DOCKER, 'compose.yaml': DOCKER,
106  makefile: ['MK', '#6D8086'], license: LICENSE, 'license.md': LICENSE, 'license.txt': LICENSE,
107  '.gitignore': GIT, '.gitattributes': GIT, '.gitmodules': GIT,
108  '.editorconfig': CONFIG, '.prettierrc': CONFIG, '.npmrc': CONFIG, '.nvmrc': CONFIG,
109  'claude.md': ['CL', '#D97757'],
110}
111
112/**
113 * The badge for a file name: special names first, then the extension. An extension canopy
114 * doesn't know shows its first two letters on gray; a name with none, a blank tile.
115 */
116export function iconFor(name: string): FileIcon {
117  const lower = name.toLowerCase()
118  const spec = own(BADGE_BY_NAME, lower) ?? (lower.startsWith('.env') ? BADGE_BY_EXT['env'] : undefined) ?? byExt(lower)
119  return { label: spec[0], bg: spec[1], fg: spec[2] ?? textOn(spec[1]) }
120}
121
122function byExt(lower: string): Spec {
123  const dot = lower.lastIndexOf('.')
124  if (dot <= 0) return ['  ', PLAIN_TILE]
125  const ext = lower.slice(dot + 1)
126  const known = own(BADGE_BY_EXT, ext)
127  if (known) return known
128  // Only letters and digits make a label: anything else could be wide or unprintable.
129  return /^[a-z0-9]+$/.test(ext) ? [ext.slice(0, 2).toUpperCase().padEnd(2), UNKNOWN_TILE] : ['  ', PLAIN_TILE]
130}
131
132/** White on a tile dark enough for it (3:1, as for bold text), else near-black. */
133export function textOn(bg: string): string {
134  return contrast('#FFFFFF', bg) >= 3 ? '#FFFFFF' : '#1A1A1A'
135}
136
137/** WCAG contrast ratio of two #rrggbb colors. */
138export function contrast(a: string, b: string): number {
139  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
140  return (hi! + 0.05) / (lo! + 0.05)
141}
142
143function luminance(hex: string): number {
144  const n = parseInt(hex.slice(1), 16)
145  const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map((v) => {
146    const c = v / 255
147    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
148  })
149  return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
150}
151
types/index.d.ts 34 lines
1// canopy's contract: the $.state values its pane is drawn from. They survive a hot reload;
2// /clear, /resume and /branch reset them, and canopy rescans the tree.
3// Types only. The shapes match Mark/Marks in hooks/activity.ts and Badge/GitState in
4// hooks/git.ts; the names carry a Canopy prefix because every mod's contract merges into
5// the same 'claude-code' module.
6
7declare module 'claude-code' {
8  /**
9   * What Claude last did to a file, how often it read and wrote it this session, and its
10   * latest write or commit (which a later read leaves in place).
11   */
12  type CanopyMark = { kind: 'read' | 'write' | 'commit'; at: number; reads: number; writes: number; change?: 'write' | 'commit' }
13
14  /** Marks by path relative to the project root. */
15  type CanopyMarks = Record<string, CanopyMark>
16
17  /** A git status letter: modified, added, deleted, renamed, untracked. */
18  type CanopyBadge = 'M' | 'A' | 'D' | 'R' | 'U'
19
20  /** The branch (null outside a repository) and each changed path's badge. */
21  type CanopyGitState = { branch: string | null; files: Record<string, CanopyBadge> }
22
23  interface PluginState {
24    canopy: {
25      marks: CanopyMarks
26      git: CanopyGitState
27      /** Folders the person opened. */
28      expanded: string[]
29      /** Folders the person closed, kept closed even while Claude works inside. */
30      collapsed: string[]
31    }
32  }
33}
34