Browse one directory at a time with clickable folders.

Browse one directory at a time with clickable folders.
Use it when: Explore a project without flooding the transcript.

Screenshot of this mod's render tree in the fixture preview, with sample data. This is not a capture of the Claude Code application. How previews are made.
claude plugin marketplace add whyashthakker/awesome-claude-code-mods
claude plugin install file-tree@awesome-claude-code-mods
Run /reload-plugins in an existing session, then /file-tree. Use Tab and Enter to operate controls; Esc closes the pane. Terminal and Desktop Code tab supported; pane UI is unavailable in claude -p and the VS Code chat panel.
Try the source without installing:
claude --plugin-dir ./mods/file-tree
Example: open /file-tree and enter . in its input.
Lists the directory the user chooses. Does not recursively walk or read file content.
Module variables reset on hot reload. All display output is bounded; viewers may truncate long content to 9,000 characters. Relative file paths and Git commands use the session working directory.
Tested with Claude Code 2.1.288. Minimum documented mods version: 2.1.287. The API can change; validate against your installed version.
claude plugin validate mods/file-tree --strict
claude plugin test mods/file-tree
Tests cover plugin commands, pane isolation, both UI surfaces, and the behavior described in tests/register.test.ts. Drawing tests validate element trees; they do not verify pixels painted by the native apps. Validation details.
MIT licensed. No runtime packages or build step required.
hooks/register.js 46 lines1import { clean, text, code, row, frame, bar, pretty, bounded, entries, safePath } from './ui.js'
2const ID = "file-tree"
3let target='.'
4let listing=[]
5let report=''
6
7async function load($) {
8 try { listing=(await $.fs.list(safePath(target))).slice(0,100); report='' }
9 catch(error) { listing=[];report=error.message }
10 $.ui.invalidate('ui.render')
11}
12
13export function register(on) {
14 on('session.start', async ($, e, next) => {
15
16 await $.command.register({name:ID, description:"Browse one directory at a time with clickable folders.", immediate:true, argumentHint:"[path]"})
17 return next(e)
18 })
19 on('command.run', {command:"file-tree"}, async ($, e) => {
20 if(e.args.trim()) target=e.args.trim()
21await load($)
22 await $.ui.open({id:ID,title:"File Tree",focus:true,closeOnEscape:true})
23 return {}
24 })
25
26 on('ui.render', {component:'Pane'}, async ($, e, next) => {
27 if(e.requestId !== ID) return next(e)
28 const E = $.ui.resolve(e)
29 try {
30 const body = await draw($, E)
31 return frame(E,"File Tree","Browse one directory at a time with clickable folders.",body)
32 } catch(error) {
33 return frame(E,"File Tree",'Unable to inspect this data.',[text(E,error.message,{color:'yellow'}),E.Button({key:'refresh',label:'Refresh',onPress:()=>$.ui.invalidate('ui.render')})])
34 }
35 })
36}
37async function draw($, E) {
38 const refresh = E.Button({key:'refresh',label:'Refresh',hotkey:'r',plain:true,onPress:()=>$.ui.invalidate('ui.render')})
39
40return [E.Input({key:'target',label:'Directory',value:target,submitLabel:'browse',onSubmit:async(v)=>{target=v;await load($)}}),
41 text(E,report || 'Up to 100 entries. Symlinks are shown but not followed by buttons.'),
42 ...listing.map((f,i)=>f.kind==='dir'&&!f.isLink ? E.Button({key:'dir-'+i,label:'[dir] '+f.name,plain:true,onPress:async()=>{target=target.replace(/\/$/,'')+'/'+f.name;await load($)}}):text(E,(f.isLink?'[link] ':'[file] ')+f.name+' '+(f.size||0)+' B')),
43 E.Button({key:'refresh',label:'Refresh directory',onPress:()=>load($)})]
44
45}
46hooks/ui.js 36 lines1// Pure UI helpers. No engine access; each plugin contains its own copy.
2export function clean(value, limit = 9000) {
3 return String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, limit)
4}
5export function text(E, value, props = {}) {
6 return E.Text({ ...props, children: [clean(value)] })
7}
8export function code(E, value, language = 'text') {
9 return E.Code({ source: clean(value), language })
10}
11export function row(E, children) {
12 return E.Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children })
13}
14export function frame(E, title, subtitle, children) {
15 return E.Box({ flexDirection: 'column', gap: 1, children: [
16 text(E, title, { bold: true, color: 'cyan' }),
17 text(E, subtitle, { dimColor: true }), ...children,
18 ] })
19}
20export function bar(percent, width = 24) {
21 const p = Math.max(0, Math.min(100, Number(percent) || 0))
22 const n = Math.round(p / 100 * width)
23 return '[' + '#'.repeat(n) + '-'.repeat(width - n) + '] ' + p.toFixed(1) + '%'
24}
25export function pretty(value) { return JSON.stringify(value, null, 2) }
26export function bounded(value, max = 9000) {
27 if (value.length > max) throw new Error('Input exceeds ' + max + ' characters. Use a smaller selection.')
28 return value
29}
30export function entries(value) { return value && typeof value === 'object' ? Object.entries(value) : [] }
31export function safePath(value) {
32 const path = value.trim()
33 if (!path || path.startsWith('-') || /[\x00-\x1f]/.test(path)) throw new Error('Enter a path without control characters or leading flags.')
34 return path
35}
36