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

<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%">
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.
<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.)
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.
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.
| State | When | How it shows | |
|---|---|---|---|
| ✎ | editing | Claude is writing the file: Edit, Write, NotebookEdit, or a shell command that changed it | The name turns bold in Claude's orange, and the pencil pulses bright and dim, for 8 seconds |
| ● | edited | Claude changed it and it isn't committed yet | The name stays yellow, like git's M, until a commit takes it. Put back (git restore, checkout, stash), it goes back to read |
| ✓ | committed | A commit took it: one Claude made (every file it took, new ones included) or one you made in your own terminal or editor | The name turns green, with a check, until Claude changes it again |
| ◐ | reading | Claude is reading it: the Read tool, or cat, head, tail, sed -n, grep, rg, wc… in the shell | The 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 |
| ○ | read | Claude read it earlier | A blue ring beside its plain name. Reading a changed file spins for a moment, then shows the change again |
M A D R U | git | git status | Modified, 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 |
·3 | inside a folder | A closed folder holds files Claude touched | The 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 type | Always | A 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.
| Command | What it does |
|---|---|
/canopy | Shows the tree, or hides it when it's showing. When another mod's pane is in front, brings canopy's back |
/canopy refresh | Reads .gitignore again and rescans everything |
Set them with /plugin configure canopy@canopy:
| Option | Values | Default | What it does |
|---|---|---|---|
start | on, off | on | on opens the tree when a session starts, if the terminal is wide enough (otherwise a toast says to run /canopy) |
activity | all, writes, none | all | Which of Claude's file activity lights the tree. writes skips reads; none keeps a plain tree with git badges |
ignore | comma-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) |
follow | on, off | on | Scroll to the file Claude is touching |
icons | badges, swatches | badges | badges draws a two-letter tile in each file type's color; swatches a small colored square (◆ for the files that define a project) |
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.
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.
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).
Inspired by the idea of a live file tree for coding agents; written from scratch against the public mods API.
MIT © flaviorg
hooks/register.ts 518 lines1// 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}
518hooks/activity.ts 216 lines1// 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}
216hooks/bashpaths.ts 211 lines1// 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}
211hooks/git.ts 123 lines1// 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}
123hooks/tree.ts 200 lines1// 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}
200hooks/view.ts 424 lines1// 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}
424hooks/filetypes.ts 151 lines1// 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}
151types/index.d.ts 34 lines1// 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