Adds /tree: a side pane with a lazy file tree coloured by git status. Press a file to put @path in the prompt.

/tree opens a side pane with the working directory's files, coloured by git status. Press a folder to expand it, press a file to put @path in the prompt.
A Pane opened from a command ($.ui.open) with Button rows and an Input, redrawn through $.ui.invalidate. Also lazy $.fs.list, one git status --porcelain via $.process.run, and $.prompt.fill in append mode.
Not captured from a live session. A pane can't be seen in claude -p, so the drawing is covered by $.ui.mount tests (expand and collapse, press a file, type in the filter, row cap) and claude plugin validate.
session.start registers /tree (immediate, so it opens while Claude works)./tree runs git status, then opens the pane (focus, closeOnEscape). If the surface won't place it, a toast gives the reason.Pane render hook lists directories on demand ($.fs.list, one level, cached until the next refresh). .git, node_modules and .DS_Store are hidden. Folders come first.M, added green A, untracked dim cyan ?, deleted red D. A folder containing changes shows a yellow M.@relative/path to the draft (mode: "append", with a space inserted if the draft doesn't end in one), so repeated presses build a list. A toast appears if the prompt refuses it.+N more line. Names are cut to the pane width. The host scrolls.turn.complete re-runs git status and redraws, once the pane has been opened.What claude plugin validate reports:
hooks: session.start, command.run{command=tree}, turn.complete, ui.render{component=Pane}
calls: $.command.register, $.fs.list (via entries), $.process.run (via refresh), $.prompt.fill (via insert), $.prompt.read (via insert), $.session.cwd (via draw), $.session.repo (via refresh), $.ui.invalidate (via redraw, refresh), $.ui.open, $.ui.resolve, $.ui.toast
Requires Claude Code 2.1.287 or later and git on PATH (the tree still works without it, uncoloured).
claude --plugin-dir ./mods/file-tree-pane # one session, then type /tree
claude plugin marketplace add justmalhar/awesome-claude-mods
claude plugin install file-tree-pane@awesome-claude-mods --scope user
Test it: claude plugin test mods/file-tree-pane. The tests run against a virtual project, not a live session.
-p, VS Code, cloud) /tree does nothing visible. It does not use the AbovePrompt band.git status fails, files are uncoloured.@path without quotes.turn.complete (harmless, one git status per turn).None.
hooks/file-tree-pane.mjs 198 lines1// File Tree Pane: `/tree` opens a pane with the working directory's files.
2//
3// Directories load lazily (one $.fs.list each) when expanded. Colours come from
4// one `git status --porcelain` run, refreshed on open and on turn.complete.
5// Pressing a file appends `@path ` to the prompt. The host reads `on(...)` and
6// `$.noun.method(...)` from source, so they are spelled literally.
7
8const PANE = "file-tree-pane";
9const HIDDEN = new Set([".git", "node_modules", ".DS_Store"]);
10const MAX_ROWS = 200; // ponytail: no paging; narrow with the filter box
11const COLORS = { M: "yellow", A: "green", "?": "cyan", D: "red" };
12
13// Module state resets on reload, which only costs the expanded folders.
14const expanded = new Set(); // absolute dir paths
15const listings = new Map(); // dir -> sorted entries; cleared on refresh
16let changes = new Map(); // absolute path (dirs end in "/") -> M | A | ? | D
17let filter = "";
18let isOpened = false; // ponytail: never reset on close, so a closed pane still redraws on turn.complete
19
20export function register(on) {
21 on("session.start", async ($, e, next) => {
22 try {
23 await $.command.register({ name: "tree", description: "Open the file tree pane", immediate: true });
24 } catch {
25 // name taken or unsupported: the rest of the mod is harmless without it
26 }
27 return next(e);
28 });
29
30 on("command.run", { command: "tree" }, async ($, e, next) => {
31 try {
32 await refresh($);
33 const r = await $.ui.open({ id: PANE, title: "Files", focus: true, closeOnEscape: true });
34 isOpened = true;
35 if (r && r.isPlaced === false) {
36 $.ui.toast(r.reason || "The terminal is too narrow for the file tree pane.");
37 }
38 } catch {
39 toast($, "Could not open the file tree.");
40 }
41 return {};
42 });
43
44 on("turn.complete", async ($, e, next) => {
45 if (isOpened) {
46 try {
47 await refresh($);
48 } catch {
49 // stale colours beat a failed turn
50 }
51 }
52 return next(e);
53 });
54
55 on("ui.render", { component: "Pane" }, async ($, e, next) => {
56 if (e.requestId !== PANE) {
57 return next(e);
58 }
59 try {
60 return await draw($, e);
61 } catch {
62 const { Text } = $.ui.resolve(e);
63 return Text({ dimColor: true, children: "Could not read the file tree." });
64 }
65 });
66}
67
68function toast($, text) {
69 try {
70 $.ui.toast(text);
71 } catch {
72 // a toast is a courtesy
73 }
74}
75
76async function refresh($) {
77 listings.clear();
78 const next = new Map();
79 try {
80 const repo = await $.session.repo();
81 if (repo) {
82 const r = await $.process.run(["git", "status", "--porcelain"], { cwd: repo.root, timeoutMs: 10000 });
83 for (const line of r.exitCode === 0 ? r.stdout.split("\n") : []) {
84 if (line.length < 4) continue;
85 const xy = line.slice(0, 2);
86 const path = line.slice(3).split(" -> ").pop().replace(/^"|"$/g, ""); // ponytail: quoted paths with escapes are shown uncoloured
87 next.set(`${repo.root}/${path}`, xy === "??" ? "?" : xy.includes("D") ? "D" : xy.includes("A") ? "A" : "M");
88 }
89 }
90 } catch {
91 // not a repo, or git is missing: draw the tree uncoloured
92 }
93 changes = next;
94 await $.ui.invalidate("ui.render");
95}
96
97// A file's own code, else the code of an untracked/deleted parent dir.
98function codeOf(path) {
99 const own = changes.get(path);
100 if (own) return own;
101 for (const [p, c] of changes) if (p.endsWith("/") && path.startsWith(p)) return c;
102 return "";
103}
104
105function dirHasChanges(path) {
106 for (const p of changes.keys()) if (p.startsWith(`${path}/`)) return true;
107 return false;
108}
109
110async function entries($, dir) {
111 let list = listings.get(dir);
112 if (!list) {
113 try {
114 list = (await $.fs.list(dir)).filter((x) => !HIDDEN.has(x.name));
115 } catch {
116 list = []; // unreadable dir shows empty
117 }
118 list.sort((a, b) => (a.kind === "dir") !== (b.kind === "dir") ? (a.kind === "dir" ? -1 : 1) : a.name.localeCompare(b.name));
119 listings.set(dir, list);
120 }
121 return list;
122}
123
124async function walk($, dir, depth, out) {
125 for (const x of await entries($, dir)) {
126 const path = `${dir}/${x.name}`;
127 const isDir = x.kind === "dir";
128 out.push({ path, name: x.name, depth, isDir });
129 if (isDir && expanded.has(path)) await walk($, path, depth + 1, out);
130 }
131}
132
133async function insert($, rel) {
134 try {
135 // append adds right after the draft, so separate it from what is typed
136 const { text } = await $.prompt.read();
137 const sep = text && !/\s$/.test(text) ? " " : "";
138 const r = await $.prompt.fill({ text: `${sep}@${rel} `, mode: "append" });
139 if (!r.isFilled) toast($, "The prompt could not take the path.");
140 } catch {
141 toast($, "The prompt could not take the path.");
142 }
143}
144
145function redraw($) {
146 Promise.resolve($.ui.invalidate("ui.render")).catch(() => {});
147}
148
149async function draw($, e) {
150 const { Box, Text, Button, Input } = $.ui.resolve(e);
151 const cwd = await $.session.cwd();
152 const cols = e.props?.bodyColumns ?? 60;
153 const rows = [];
154 await walk($, cwd, 0, rows);
155 const needle = filter.toLowerCase();
156 const shown = needle ? rows.filter((r) => r.path.slice(cwd.length + 1).toLowerCase().includes(needle)) : rows;
157
158 const children = [
159 Input({
160 key: "filter",
161 placeholder: "filter",
162 value: filter,
163 onInput: (v) => { filter = v; redraw($); },
164 onSubmit: (v) => { filter = v; redraw($); },
165 }),
166 ];
167 for (const r of shown.slice(0, MAX_ROWS)) {
168 const code = r.isDir ? (dirHasChanges(r.path) ? "M" : "") : codeOf(r.path);
169 const mark = r.isDir ? (expanded.has(r.path) ? "v " : "> ") : " ";
170 // ponytail: truncated by hand, a Button label can't take wrap
171 const label = `${" ".repeat(r.depth)}${mark}${r.name}${r.isDir ? "/" : ""}`.slice(0, Math.max(10, cols - 4));
172 children.push(
173 Box({
174 key: `r:${r.path}`,
175 flexDirection: "row",
176 children: [
177 Text({ color: COLORS[code], dimColor: code === "?", children: `${code || " "} ` }),
178 Button({
179 key: r.path,
180 plain: true,
181 label,
182 onPress: r.isDir
183 ? () => { expanded.has(r.path) ? expanded.delete(r.path) : expanded.add(r.path); redraw($); }
184 : () => { insert($, r.path.slice(cwd.length + 1)); },
185 }),
186 ],
187 }),
188 );
189 }
190 if (shown.length > MAX_ROWS) {
191 children.push(Text({ dimColor: true, wrap: "truncate-end", children: `+${shown.length - MAX_ROWS} more` }));
192 }
193 if (shown.length === 0) {
194 children.push(Text({ dimColor: true, wrap: "truncate-end", children: needle ? "No match." : "Empty directory." }));
195 }
196 return Box({ flexDirection: "column", children });
197}
198