SLOPSHOPPER

file-tree-pane

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

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-tree-pane
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ filter │ ┃ package.json ⏺ Read(src/auth.ts) │ ┃ README.md ⎿ Read 6 lines │ ┃ src ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tree │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
filter package.json README.md src
README

File Tree Pane

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

What this shows

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.

Demo

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.

How it works

  • 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.
  • The 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.
  • Colours: modified yellow M, added green A, untracked dim cyan ?, deleted red D. A folder containing changes shows a yellow M.
  • A file press appends @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.
  • The filter box at the top keeps rows whose path contains the text (case-insensitive). It filters expanded rows only; collapsed folders aren't searched.
  • At most 200 rows are drawn, then a +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

Run it

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.

Notes / limitations

  • Draws only in the terminal and the Desktop Code tab. Elsewhere (-p, VS Code, cloud) /tree does nothing visible. It does not use the AbovePrompt band.
  • Without a git repo, or if git status fails, files are uncoloured.
  • Status colours refresh on open and after each turn, not when you edit a file yourself.
  • Quoted paths (names with spaces or unusual characters, which git escapes) are shown uncoloured. Paths with spaces are inserted as plain @path without quotes.
  • Row cap is fixed at 200; use the filter to narrow. The module keeps expanded folders in memory only, so a reload collapses them.
  • A closed pane still redraws on turn.complete (harmless, one git status per turn).
  • Rows are truncated by hand to the pane width, so a very long name loses its end.
  • Not checked in a live session: pane placement, scrolling and how the colours look.

Dependencies

None.

Source 1 files
hooks/file-tree-pane.mjs 198 lines
1// 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