Adds /atlas: a file explorer in a pane beside Claude Code, with git marks, search by name or text, and micro to edit a file.

A file explorer in a pane beside Claude Code. Browse a tree explorer, find properties, and modify without leaving Claude Code.

Add the marketplace:
/plugin marketplace add jamubc/toolbox
Install the plugin:
/plugin install atlas@toolbox
| Command | function |
|---|---|
/atlas | Opens the explorer where you left it last time in this project, or on the folder Claude Code started in. |
/atlas <folder> | Opens the explorer there. |
/atlas <file> | Shows the file in the tree and opens it in micro. |
| The trail | The path at the top is a trail of folders: click one to go back up to it. |
| Click a folder | Opens or closes it. |
| Click a file | Shows its Properties and Source: code with line numbers, Markdown rendered, a PNG as a picture in kitty and Ghostty. |
| Git marks | Files git sees as changed, added, untracked, deleted or renamed carry M, A, ?, D, R in color; a folder holding one carries •. The header counts them. |
| Find | Type part of a name and press Enter; text searches inside files instead (ripgrep, else grep) and a hit opens the file at that line. |
| Key | Action |
e | Edit in micro |
p | Add the file to your prompt as @path |
c | Copy the path |
a | Open with the system's own app (open, xdg-open) |
r / d | Rename / duplicate |
x | Move to the Trash, after you confirm |
n / f | New file / new folder, in the selected folder |
o | Open the selected folder as the root |
u / g | Go up a folder / refresh |
h / z | Show or hide dotfiles (kept between sessions) / collapse every folder |
While the pane shows, the folders on screen and git are read again every few seconds, so what Claude writes appears by itself. The root and the open folders are remembered per project.
micro runs for real inside the pane: your own micro settings, colors and plugins.
/config → Editor for files runs another terminal editor instead: nano and vim work too.| Where | Explorer | Editor |
|---|---|---|
| Claude Code in any terminal | Yes | Yes |
| Claude desktop app, VS Code extension | Yes | No. |
| Mobile | Yes, without Find or naming fields | No |
| System | Status |
|---|---|
| macOS | Tested: browsing, Find, rename, new, duplicate, Trash, editing in micro. |
| Linux | Should work; untested. Trash needs gio or trash-cli; opening with an app needs xdg-open. |
| Windows | Not supported. Editing says so, and paths assume /. |
/usr/bin/python3 works) and micro, or the editor you set. If one is missing, the pane says which and how to fix it.? in the editor; the file itself is untouched.trash, or gio trash or trash-put on Linux, after a confirmation. If none exists, nothing is deleted./ or be .../clear starts a new session in the same process, so atlas keeps what it was showing and writes it into the new one.hooks/register.tsx draws the pane, answers /atlas, and runs the actions.hooks/files.ts holds the path, file class, tree, git status and search-output helpers.editor/term.py runs the editor in a pseudo-terminal, keeps its screen with a small terminal emulator, and streams it to the pane. Keys and clicks come back over a Unix socket in a private temp folder.hooks/terminal.tsx lies over the editor's picture and passes on keys, clicks and drags./atlas, whose reply names the folder.git status and the search run only under the folder shown.hooks/register.tsx 1112 lines1// Atlas, a Dex-style explorer for your files: an explorer pane (breadcrumbs,
2// tree with git marks, properties, source viewer and actions), search by name
3// or by text, and a real terminal editor, micro by default, run inside the pane.
4// editor/term.py hosts the editor in a pseudo-terminal and streams its screen
5// as Raster cells; a Client over the picture forwards keys and the pointer.
6
7import { atom, derive, read, update } from 'claude-code'
8import type { EngineInterface, Register, RenderSurface } from 'claude-code'
9
10import type { Details, Editing, Entry, GitMark, Hit, Naming, Preview, SearchMode } from '../types'
11import {
12 basename, classOf, copyName, crumbs, date, dirname, foldersBetween, indexMarks, isHidden, isMarkdown, isPng, join,
13 literalPattern, markFor, nameProblem, parseGitStatus, parseGrep, printable, printableSource, relative, rows, sameEntries, sameMarks,
14 size, sorted,
15} from './files'
16
17const PANE = 'atlas'
18// The tree's share of a pane's 100,000 characters, leaving room for the source.
19const TREE_LIMITS = { perFolder: 300, rows: 800, characters: 50000 }
20const PREVIEW_BYTES = 512 * 1024
21const PREVIEW_LINES = 80
22const PREVIEW_CHARS = 9000
23// Lines of context before a search hit in the source viewer.
24const HIT_CONTEXT = 8
25const SEARCH_LIMIT = 200
26const EDITOR_HEADER_ROWS = 1
27const DEFAULT_COLOR = 0x01000000
28// How often the tree is read again while the pane shows, so what Claude
29// or a build writes appears by itself.
30const WATCH_MS = 4000
31const GIT_TIMEOUT_MS = 5000
32const MARK_COLORS: Record<GitMark, string> = { M: 'yellow', A: 'green', '?': 'green', D: 'red', R: 'blue' }
33const MARK_NAMES: Record<GitMark, string> = { M: 'modified', A: 'added', '?': 'untracked', D: 'deleted', R: 'renamed' }
34
35const root = atom({ plugin: 'atlas', key: 'root' } as const, '')
36const expanded = atom({ plugin: 'atlas', key: 'expanded' } as const, [])
37const listings = atom({ plugin: 'atlas', key: 'listings' } as const, {})
38const selected = atom({ plugin: 'atlas', key: 'selected' } as const, null)
39const details = atom({ plugin: 'atlas', key: 'details' } as const, null)
40const query = atom({ plugin: 'atlas', key: 'query' } as const, '')
41const mode = atom({ plugin: 'atlas', key: 'mode' } as const, 'names')
42const results = atom({ plugin: 'atlas', key: 'results' } as const, null)
43const naming = atom({ plugin: 'atlas', key: 'naming' } as const, null)
44const notice = atom({ plugin: 'atlas', key: 'notice' } as const, null)
45const editing = atom({ plugin: 'atlas', key: 'editing' } as const, null)
46const showHidden = atom({ plugin: 'atlas', key: 'showHidden' } as const, false)
47const git = atom({ plugin: 'atlas', key: 'git' } as const, {})
48const hasPixels = atom({ plugin: 'atlas', key: 'hasPixels' } as const, false)
49const seeded = atom({ plugin: 'atlas', key: 'seeded' } as const, false)
50
51// /clear ends the session but not the process: the host's `$.state` starts over empty while this
52// module, its pane and the editor live on, so a value read straight from the host is suddenly its
53// initial (an empty root, no listings, nothing being edited). `seeded` is false exactly then, and
54// before the first `session.start`. `snapshot` is every value the pane draws from, read in one go:
55// the host's while `seeded`, else what this process last saw; `write` puts the kept values back
56// before the first change after a /clear, and a render that finds `seeded` false schedules that.
57type Snapshot = {
58 root: string
59 expanded: string[]
60 listings: Record<string, Entry[]>
61 selected: string | null
62 details: Details | null
63 query: string
64 mode: SearchMode
65 results: Hit[] | null
66 naming: Naming | null
67 notice: string | null
68 editing: Editing | null
69 showHidden: boolean
70 git: Record<string, GitMark>
71 hasPixels: boolean
72}
73const KEYS = ['root', 'expanded', 'listings', 'selected', 'details', 'query', 'mode', 'results', 'naming', 'notice', 'editing', 'showHidden', 'git', 'hasPixels'] as const
74let kept: Snapshot = {
75 root: '', expanded: [], listings: {}, selected: null, details: null, query: '', mode: 'names', results: null, naming: null, notice: null, editing: null, showHidden: false, git: {}, hasPixels: false,
76}
77let wasLive: boolean | null = null // what the last read saw; null before the first
78const snapshot = derive(
79 [seeded, root, expanded, listings, selected, details, query, mode, results, naming, notice, editing, showHidden, git, hasPixels],
80 (isLive, root, expanded, listings, selected, details, query, mode, results, naming, notice, editing, showHidden, git, hasPixels): Snapshot => {
81 wasLive = isLive
82 if (!isLive) return kept
83 kept = { root, expanded, listings, selected, details, query, mode, results, naming, notice, editing, showHidden, git, hasPixels }
84 return kept
85 },
86)
87
88/** The one place the atoms are written: each key to its own, as the validator asks. */
89async function put<K extends keyof Snapshot>($: EngineInterface, key: K, value: Snapshot[K]): Promise<void> {
90 switch (key) {
91 case 'root': await update($, root, () => value as Snapshot['root']); break
92 case 'expanded': await update($, expanded, () => value as Snapshot['expanded']); break
93 case 'listings': await update($, listings, () => value as Snapshot['listings']); break
94 case 'selected': await update($, selected, () => value as Snapshot['selected']); break
95 case 'details': await update($, details, () => value as Snapshot['details']); break
96 case 'query': await update($, query, () => value as Snapshot['query']); break
97 case 'mode': await update($, mode, () => value as Snapshot['mode']); break
98 case 'results': await update($, results, () => value as Snapshot['results']); break
99 case 'naming': await update($, naming, () => value as Snapshot['naming']); break
100 case 'notice': await update($, notice, () => value as Snapshot['notice']); break
101 case 'editing': await update($, editing, () => value as Snapshot['editing']); break
102 case 'showHidden': await update($, showHidden, () => value as Snapshot['showHidden']); break
103 case 'git': await update($, git, () => value as Snapshot['git']); break
104 case 'hasPixels': await update($, hasPixels, () => value as Snapshot['hasPixels']); break
105 }
106}
107
108/** After a /clear (or at the first start), writes what this process kept back to the host. */
109let reseeding: Promise<void> | null = null
110function reseed($: EngineInterface): Promise<void> {
111 reseeding ??= (async () => {
112 try {
113 if (await read($, seeded)) return
114 for (const key of KEYS) await put($, key, kept[key])
115 await update($, seeded, () => true)
116 wasLive = true
117 } finally {
118 reseeding = null
119 }
120 })()
121 return reseeding
122}
123
124/** Changes one value from what `snapshot` reads, and keeps it here too. */
125async function write<K extends keyof Snapshot>($: EngineInterface, key: K, change: (now: Snapshot[K]) => Snapshot[K]): Promise<void> {
126 // `kept` is current once a read has seen the host live: only this module writes these values.
127 if (wasLive === null) await read($, snapshot)
128 if (!wasLive) await reseed($)
129 const next = change(kept[key])
130 kept = { ...kept, [key]: next }
131 await put($, key, next)
132}
133
134type Options = { editor?: string; python?: string }
135type Size = { columns: number; rows: number }
136type Frame = Size & { cells: string }
137type Helper = { socket: string | null; sent: Size | null; stop: () => void }
138type TerminalMessage =
139 | { kind: 'size' }
140 | { kind: 'key'; key: string; ctrl?: true; shift?: true; meta?: true }
141 | { kind: 'pointer'; type: string; x: number; y: number; button: string }
142type Remembered = { root: string; expanded: string[] }
143
144// What only this process has. The editor's host is a child of this module, so
145// a hot reload ends it with the module, and these start over with it.
146let cwd = ''
147let home = ''
148let helper: Helper | null = null
149let frame: Frame | null = null
150let editorView: Size | null = null // the editor's picture as last drawn
151let paneBody: Size = { columns: 100, rows: 30 }
152let isWatching = false
153/** Counts searches so a slow earlier one cannot land its hits over a newer query. */
154let searching = 0
155/**
156 * Where git said the repository's top folder is, and the root it was asked
157 * about. `gitTop` is undefined until git has been asked, and null when it
158 * answered that there is no repository; either way it is not asked again for
159 * that root. Going to another root asks afresh.
160 */
161let gitTopFor: string | null = null
162let gitTop: string | null | undefined = undefined
163const blanks = new Map<string, string>()
164
165const reasonOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
166const say = ($: EngineInterface, text: string | null) => write($, 'notice', () => text)
167// Every path or name drawn passes through one of these: see `printable`.
168const tilde = (path: string) => printable(home && relative(path, home) !== path ? `~/${relative(path, home)}` : path)
169const nameOf = (path: string) => printable(basename(path))
170const relOf = (path: string, base: string) => printable(relative(path, base))
171
172function blankCells({ columns, rows: height }: Size): string {
173 const size = `${columns}x${height}`
174 let cells = blanks.get(size)
175 if (cells === undefined) {
176 const words = new Uint32Array(columns * height * 3)
177 for (let i = 0; i < words.length; i += 3) {
178 words[i] = 0x20
179 words[i + 1] = DEFAULT_COLOR
180 words[i + 2] = DEFAULT_COLOR
181 }
182 cells = new Uint8Array(words.buffer).toBase64()
183 blanks.set(size, cells)
184 }
185 return cells
186}
187
188// Real pixels where the terminal speaks the kitty graphics protocol. Not
189// through tmux, which drops it, nor over ssh.
190async function pixelsHere($: EngineInterface): Promise<boolean> {
191 const isRelayed =
192 (await $.env.get('TMUX')) !== undefined ||
193 (await $.env.get('SSH_CONNECTION')) !== undefined ||
194 (await $.env.get('SSH_TTY')) !== undefined
195 if (isRelayed) return false
196 const term = (await $.env.get('TERM')) ?? ''
197 const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
198 return (await $.env.get('KITTY_WINDOW_ID')) !== undefined || term.includes('kitty') || term.includes('ghostty') || program === 'ghostty'
199}
200
201// ---------------------------------------------------------------- the tree
202
203/** Reads one folder; answers whether what it holds changed. */
204async function load($: EngineInterface, dir: string, isQuiet = false): Promise<boolean> {
205 if (dir === '') return false
206 try {
207 const listed = await $.fs.list(dir)
208 const entries = sorted(listed.map(({ name, kind, size: bytes, mtimeMs, isLink }): Entry => (
209 { name, kind, size: bytes, mtimeMs, isLink })))
210 const known = (await read($, snapshot)).listings[dir]
211 if (known !== undefined && sameEntries(known, entries)) return false
212 await write($, 'listings', all => ({ ...all, [dir]: entries }))
213 return true
214 } catch (err) {
215 if (!isQuiet) await say($, `Cannot read ${tilde(dir)}: ${reasonOf(err)}`)
216 return false
217 }
218}
219
220/**
221 * What git says about the files under the root; nothing outside a repository.
222 * A repository's top folder cannot move while the root stays the same, so the
223 * watcher asks git where it is once per root rather than on every tick. `top`
224 * is undefined until git has been asked, and null when git answered that there
225 * is no repository or is not answering at all; neither is asked again for that
226 * root, and going to another root asks afresh.
227 */
228async function loadGit($: EngineInterface, base: string): Promise<boolean> {
229 let marks: Record<string, GitMark> = {}
230 if (base !== gitTopFor) {
231 gitTopFor = base
232 gitTop = undefined
233 }
234 if (gitTop === undefined) {
235 try {
236 const ran = await $.process.run(['git', '-C', base, 'rev-parse', '--show-toplevel'], { timeoutMs: GIT_TIMEOUT_MS })
237 gitTop = ran.exitCode === 0 ? ran.stdout.trim() : null
238 } catch {
239 gitTop = null // no git on this machine
240 }
241 }
242 if (gitTop !== null) {
243 try {
244 const ran = await $.process.run(
245 ['git', '-C', base, 'status', '--porcelain=v1', '-z', '--untracked-files=normal', '--', '.'],
246 { timeoutMs: GIT_TIMEOUT_MS },
247 )
248 // A status that failed or was cut short says nothing, so the marks stay as they are.
249 if (ran.exitCode === 0 && !ran.isStdoutTruncated) marks = parseGitStatus(ran.stdout, gitTop)
250 else return false
251 } catch {
252 return false
253 }
254 }
255 const known = (await read($, snapshot)).git
256 if (sameMarks(known, marks)) return false
257 await write($, 'git', () => marks)
258 return true
259}
260
261async function previewOf($: EngineInterface, path: string, bytes: number, aroundLine?: number): Promise<Preview> {
262 const name = basename(path)
263 if (isPng(name)) {
264 return (await read($, snapshot)).hasPixels ? { kind: 'image', path } : { kind: 'binary' }
265 }
266 if (bytes > PREVIEW_BYTES) return { kind: 'large' }
267 try {
268 const text = await $.fs.read(path)
269 const head = text.slice(0, 8000)
270 if (head.includes('\0') || (head.match(/�/g)?.length ?? 0) > head.length / 20) return { kind: 'binary' }
271 const lines = text.split('\n')
272 if (isMarkdown(name) && aroundLine === undefined) {
273 const shown = text.slice(0, PREVIEW_CHARS)
274 return { kind: 'markdown', text: printableSource(shown).replace(/\t/g, ' '), isCut: text.length > PREVIEW_CHARS }
275 }
276 const startLine = aroundLine === undefined ? 1 : Math.max(1, aroundLine - HIT_CONTEXT)
277 const kept = lines.slice(startLine - 1, startLine - 1 + PREVIEW_LINES).join('\n')
278 const shown = kept.slice(0, PREVIEW_CHARS)
279 return {
280 kind: 'text',
281 text: printableSource(shown),
282 isCut: lines.length > startLine - 1 + PREVIEW_LINES || kept.length > PREVIEW_CHARS,
283 startLine,
284 }
285 } catch (err) {
286 return { kind: 'unreadable', reason: reasonOf(err) }
287 }
288}
289
290async function select($: EngineInterface, path: string | null, aroundLine?: number): Promise<void> {
291 await write($, 'selected', () => path)
292 if (path === null) {
293 await write($, 'details', () => null)
294 return
295 }
296 try {
297 const stat = await $.fs.stat(path, { resolve: true })
298 let items: number | null = null
299 if (stat.kind === 'dir') {
300 try {
301 items = (await $.fs.list(path)).length
302 } catch {}
303 }
304 const found: Details = {
305 path,
306 kind: stat.kind,
307 size: stat.size,
308 mtimeMs: stat.mtimeMs,
309 isLink: stat.isLink,
310 realPath: stat.isLink ? stat.realPath ?? null : null,
311 items,
312 preview: stat.kind === 'file' ? await previewOf($, path, stat.size, aroundLine) : null,
313 }
314 // A later selection may have landed while this one read.
315 if ((await read($, snapshot)).selected === path) await write($, 'details', () => found)
316 } catch (err) {
317 await write($, 'details', () => null)
318 await say($, `Cannot read ${tilde(path)}: ${reasonOf(err)}`)
319 }
320}
321
322async function remember($: EngineInterface): Promise<void> {
323 const { root: base, expanded: open } = await read($, snapshot)
324 const kept: Remembered = { root: base, expanded: open }
325 try {
326 await $.store.set(`root:${cwd}`, kept)
327 } catch {}
328}
329
330async function toggle($: EngineInterface, path: string): Promise<void> {
331 if ((await read($, snapshot)).expanded.includes(path)) {
332 await write($, 'expanded', list => list.filter(p => p !== path && !p.startsWith(`${path}/`)))
333 } else {
334 await load($, path)
335 await write($, 'expanded', list => [...list, path])
336 }
337 await remember($)
338}
339
340async function setRoot($: EngineInterface, dir: string): Promise<void> {
341 await write($, 'root', () => dir)
342 await write($, 'results', () => null)
343 await load($, dir)
344 await loadGit($, dir)
345 await remember($)
346}
347
348/** Opens every folder down to `path` and selects it. */
349async function reveal($: EngineInterface, path: string, line?: number): Promise<void> {
350 const base = (await read($, snapshot)).root
351 for (const dir of foldersBetween(base, path)) {
352 await load($, dir)
353 await write($, 'expanded', list => (list.includes(dir) ? list : [...list, dir]))
354 }
355 await write($, 'results', () => null)
356 await select($, path, line)
357 await remember($)
358}
359
360async function refresh($: EngineInterface): Promise<void> {
361 await say($, null)
362 // Asked for by hand, so git is asked where the repository is again: the folder
363 // may have become one since the watcher last looked.
364 gitTopFor = null
365 gitTop = undefined
366 const base = (await read($, snapshot)).root
367 await load($, base)
368 for (const dir of (await read($, snapshot)).expanded) await load($, dir)
369 await loadGit($, base)
370 await select($, (await read($, snapshot)).selected)
371}
372
373/** A quiet pass while the pane shows: the folders on screen and git, redrawing only on a change. */
374async function watch($: EngineInterface): Promise<void> {
375 if (isWatching || helper !== null) return
376 const base = (await read($, snapshot)).root
377 if (base === '') return
378 let isShown = false
379 try {
380 isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
381 } catch {}
382 if (!isShown) return
383 isWatching = true
384 try {
385 let changed = await load($, base, true)
386 for (const dir of (await read($, snapshot)).expanded) changed = (await load($, dir, true)) || changed
387 await loadGit($, base)
388 const chosen = (await read($, snapshot)).selected
389 if (changed && chosen !== null && (await read($, snapshot)).details?.path === chosen) await select($, chosen)
390 } finally {
391 isWatching = false
392 }
393}
394
395async function search($: EngineInterface, text: string): Promise<void> {
396 // Which search asked last: `find` and ripgrep can run for seconds, and a slow
397 // earlier one must not land its hits over the query now in the box.
398 const mine = ++searching
399 await write($, 'query', () => text)
400 const words = text.trim()
401 if (words === '') {
402 await write($, 'results', () => null)
403 return
404 }
405 const base = (await read($, snapshot)).root
406 const how = (await read($, snapshot)).mode
407 try {
408 let hits: Hit[]
409 if (how === 'text') {
410 hits = await grep($, base, words)
411 } else {
412 const ran = await $.process.run(
413 ['find', base, '-mindepth', '1', '(', '-name', '.git', '-o', '-name', 'node_modules', ')', '-prune', '-o',
414 '-iname', literalPattern(words), '-print'],
415 { timeoutMs: 8000 },
416 )
417 hits = ran.stdout.split('\n').filter(Boolean).slice(0, SEARCH_LIMIT).map(path => ({ path }))
418 }
419 if (mine !== searching) return
420 await write($, 'results', () => hits)
421 await say($, hits.length === 0 ? `Nothing under ${tilde(base)} ${how === 'text' ? 'contains' : 'is named like'} "${words}".` : null)
422 } catch (err) {
423 if (mine !== searching) return
424 await say($, `Search failed: ${reasonOf(err)}`)
425 }
426}
427
428/** Lines holding the text, by ripgrep when it is there, else grep. */
429async function grep($: EngineInterface, base: string, words: string): Promise<Hit[]> {
430 const commands = [
431 ['rg', '--no-heading', '--line-number', '--color', 'never', '--smart-case', '--max-count', '3', '--max-columns', '200',
432 '--max-count', '3', '--glob', '!.git', '--glob', '!node_modules', '-e', words, base],
433 ['grep', '-rIn', '--exclude-dir=.git', '--exclude-dir=node_modules', '-i', '-e', words, base],
434 ]
435 let lastError: unknown = null
436 for (const argv of commands) {
437 try {
438 const ran = await $.process.run(argv, { timeoutMs: 15000 })
439 // 1 means nothing matched; 2 and up is a failure (grep) or a missing program.
440 if (ran.exitCode > 1) throw new Error(ran.stderr.trim() || `${argv[0]} exited ${ran.exitCode}`)
441 return parseGrep(ran.stdout, SEARCH_LIMIT)
442 } catch (err) {
443 lastError = err
444 }
445 }
446 throw new Error(`neither rg nor grep could search: ${reasonOf(lastError)}`)
447}
448
449// ---------------------------------------------------------------- actions
450
451/** The folder a new entry goes in: the selected folder, else the selected file's. */
452async function folderForNew($: EngineInterface): Promise<string> {
453 const { selected: path, details: info, root: base } = await read($, snapshot)
454 if (path === null) return base
455 return info?.path === path && info.kind === 'dir' ? path : dirname(path)
456}
457
458async function startNaming($: EngineInterface, action: Naming['action']): Promise<void> {
459 const path = (await read($, snapshot)).selected
460 if (action === 'rename' && path === null) return
461 const target = action === 'rename' ? (path as string) : await folderForNew($)
462 await say($, null)
463 await write($, 'naming', () => ({ action, target }))
464 // A click leaves the keys with the prompt: asking for the pane's focus again
465 // hands them to the pane, where the name field is drawn autoFocus.
466 await openPane($)
467}
468
469const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Atlas', focus: true, columns: 110 })
470
471async function finishNaming($: EngineInterface, typed: string): Promise<void> {
472 const named = (await read($, snapshot)).naming
473 if (named === null) return
474 const name = typed.trim()
475 const problem = nameProblem(name)
476 if (problem !== null) {
477 await say($, problem)
478 return
479 }
480 const dir = named.action === 'rename' ? dirname(named.target) : named.target
481 const to = join(dir, name)
482 if (named.action === 'rename' && to === named.target) {
483 await write($, 'naming', () => null)
484 return
485 }
486 if (await $.fs.exists(to)) {
487 await say($, `${name} already exists in ${tilde(dir)}. Nothing was changed.`)
488 return
489 }
490 try {
491 if (named.action === 'file') {
492 await $.fs.write(to, '')
493 } else {
494 const argv = named.action === 'folder' ? ['mkdir', '--', to] : ['mv', '-n', '--', named.target, to]
495 const ran = await $.process.run(argv, { timeoutMs: 10000 })
496 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `${argv[0]} exited ${ran.exitCode}`)
497 }
498 if (!(await $.fs.exists(to))) throw new Error(`${name} is not there afterwards`)
499 } catch (err) {
500 await say($, `Could not ${named.action === 'rename' ? 'rename' : 'create'} ${name}: ${reasonOf(err)}`)
501 return
502 }
503 if (named.action === 'rename') {
504 const from = named.target
505 await write($, 'expanded', list => list.map(p => (p === from || p.startsWith(`${from}/`) ? to + p.slice(from.length) : p)))
506 }
507 await write($, 'naming', () => null)
508 const base = (await read($, snapshot)).root
509 if (dir !== base) await write($, 'expanded', list => (list.includes(dir) ? list : [...list, dir]))
510 await load($, dir)
511 await loadGit($, base)
512 await select($, to)
513 await say($, named.action === 'rename' ? `Renamed to ${name}.` : `Created ${name}.`)
514}
515
516async function duplicate($: EngineInterface, path: string): Promise<void> {
517 for (let attempt = 1; attempt <= 20; attempt += 1) {
518 const to = join(dirname(path), copyName(basename(path), attempt))
519 if (await $.fs.exists(to)) continue
520 const ran = await $.process.run(['cp', '-R', '--', path, to], { timeoutMs: 60000 })
521 if (ran.exitCode !== 0 || !(await $.fs.exists(to))) {
522 await say($, `Could not duplicate ${nameOf(path)}: ${ran.stderr.trim() || `cp exited ${ran.exitCode}`}`)
523 return
524 }
525 await load($, dirname(path))
526 await loadGit($, (await read($, snapshot)).root)
527 await select($, to)
528 await say($, `Duplicated as ${nameOf(to)}.`)
529 return
530 }
531 await say($, `Too many copies of ${nameOf(path)} already.`)
532}
533
534/** Moves to the Trash, never deletes: macOS's trash, else gio or trash-cli on Linux. */
535async function trash($: EngineInterface, path: string): Promise<void> {
536 const name = nameOf(path)
537 let answer: string
538 try {
539 answer = await $.ui.ask(`Move ${name} to the Trash?`, ['Move to Trash', 'Cancel'])
540 } catch {
541 return // dismissed
542 }
543 if (answer !== 'Move to Trash') return
544 let ran = null
545 for (const argv of [['trash', path], ['gio', 'trash', path], ['trash-put', path]]) {
546 try {
547 ran = await $.process.run(argv, { timeoutMs: 30000 })
548 break
549 } catch {}
550 }
551 if (ran === null) {
552 await say($, 'No trash command here (macOS 15 or later has one; Linux needs gio or trash-cli). Nothing was deleted.')
553 return
554 }
555 if (ran.exitCode !== 0 || (await $.fs.exists(path))) {
556 await say($, `Could not move ${name} to the Trash: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`)
557 return
558 }
559 await write($, 'expanded', list => list.filter(p => p !== path && !p.startsWith(`${path}/`)))
560 await load($, dirname(path))
561 await loadGit($, (await read($, snapshot)).root)
562 await select($, null)
563 await say($, `Moved ${name} to the Trash.`)
564}
565
566async function mention($: EngineInterface, path: string): Promise<void> {
567 const filled = await $.prompt.fill({ text: `@${relative(path, cwd)} `, mode: 'append' })
568 $.ui.toast(filled.isFilled ? `Added @${relOf(path, cwd)} to your prompt.` : 'The prompt cannot take it right now.')
569}
570
571async function copyPath($: EngineInterface, path: string, surface: RenderSurface): Promise<void> {
572 const copied = await $.ui.copy({ text: path, surface })
573 $.ui.toast(copied.isCopied ? 'Copied the path.' : 'Could not copy the path here.')
574}
575
576/** Opens the file or folder with the system's own app: `open` on macOS, `xdg-open` on Linux. */
577async function openWithApp($: EngineInterface, path: string): Promise<void> {
578 for (const argv of [['open', path], ['xdg-open', path]]) {
579 try {
580 const ran = await $.process.run(argv, { timeoutMs: 10000 })
581 if (ran.exitCode === 0) return
582 await say($, `Could not open ${nameOf(path)}: ${ran.stderr.trim() || `${argv[0]} exited ${ran.exitCode}`}`)
583 return
584 } catch {}
585 }
586 await say($, 'No open command here (open on macOS, xdg-open on Linux).')
587}
588
589// ---------------------------------------------------------------- the editor
590
591async function call($: EngineInterface, route: string, body: object): Promise<void> {
592 const socket = helper?.socket
593 if (!socket) return
594 try {
595 const res = await $.http.fetch(`http://atlas${route}`, {
596 method: 'POST',
597 headers: { 'content-type': 'application/json' },
598 body: JSON.stringify(body),
599 socketPath: socket,
600 })
601 if (!res.ok) $.ui.log(`atlas: editor ${route} failed: ${res.text}`, { to: 'debug' })
602 } catch (err) {
603 $.ui.log(`atlas: editor ${route} failed: ${reasonOf(err)}`, { to: 'debug' })
604 }
605}
606
607/** Tells the editor the size its picture is now drawn at. */
608async function syncSize($: EngineInterface): Promise<void> {
609 const self = helper
610 const view = editorView
611 if (!self?.socket || view === null) return
612 if (self.sent?.columns === view.columns && self.sent.rows === view.rows) return
613 self.sent = view
614 await call($, '/resize', view)
615}
616
617async function show($: EngineInterface, next: Frame): Promise<void> {
618 frame = next
619 if (editorView === null || next.columns !== editorView.columns || next.rows !== editorView.rows) return
620 const res = await $.ui.blit({ requestId: PANE, key: 'editor', cells: next.cells })
621 if (res.deny !== undefined && !/mount/i.test(res.deny)) $.ui.log(`atlas: editor frame refused: ${res.deny}`, { to: 'debug' })
622}
623
624async function edit($: EngineInterface, options: Options, path: string): Promise<void> {
625 const now = (await read($, snapshot)).editing
626 if (helper !== null && now !== null) {
627 $.ui.toast(`Already editing ${nameOf(now.path)}: quit it first (Ctrl+Q).`)
628 return
629 }
630 if (!(await $.session.surfaces()).includes('terminal')) {
631 await say($, 'Editing runs in Claude Code\'s terminal; this app shows the explorer only.')
632 return
633 }
634 const python = options.python || 'python3'
635 const program = options.editor || 'micro'
636 const start = { columns: paneBody.columns, rows: Math.max(3, paneBody.rows - EDITOR_HEADER_ROWS) }
637 const child = $.process.spawn({
638 argv: [python, `${$.plugin.root}/editor/term.py`, String(start.columns), String(start.rows), program, path],
639 })
640 let isStopping = false
641 const self: Helper = {
642 socket: null,
643 sent: start,
644 stop: () => {
645 isStopping = true
646 void child.return(undefined as never)
647 },
648 }
649 helper = self
650 frame = null
651 const starting: Editing = { path, status: 'starting', message: null }
652 await write($, 'editing', () => starting)
653 $.ui.toast('Click the text to type. Ctrl+Z would suspend Claude Code: use the Undo button instead.', { timeoutMs: 8000 })
654
655 void (async () => {
656 let failure: string | null = null
657 let hasExited = false
658 let stderr = ''
659 let pending = ''
660 try {
661 for await (const piece of child) {
662 if (piece.stream === 'stderr') {
663 stderr = (stderr + piece.text).slice(-400)
664 continue
665 }
666 pending += piece.text
667 for (let end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n')) {
668 const line = pending.slice(0, end)
669 pending = pending.slice(end + 1)
670 let out
671 try {
672 out = JSON.parse(line)
673 } catch {
674 continue
675 }
676 if (out.type === 'ready') {
677 self.socket = out.socket
678 await write($, 'editing', (e): Editing | null => (e === null ? e : { ...e, status: 'running' }))
679 await syncSize($)
680 } else if (out.type === 'cells') {
681 await show($, out)
682 } else if (out.type === 'exit') {
683 hasExited = true
684 } else if (out.type === 'error') {
685 failure = String(out.message)
686 }
687 }
688 }
689 } catch (err) {
690 failure = `Could not run "${python}": ${reasonOf(err)}. Editing needs Python 3.9 or later; set its path in /config.`
691 }
692 if (helper === self) helper = null
693 frame = null
694 if (isStopping) return
695 if (!hasExited) {
696 const why = failure ?? (`The editor stopped unexpectedly. ${stderr.trim()}`.trim())
697 const failed: Editing = { path, status: 'error', message: why }
698 await write($, 'editing', () => failed)
699 return
700 }
701 await write($, 'editing', () => null)
702 await load($, dirname(path))
703 await loadGit($, (await read($, snapshot)).root)
704 if ((await read($, snapshot)).selected === path) await select($, path)
705 })().catch(() => {}) // the module unloaded under the loop: nothing left to tell
706}
707
708// ---------------------------------------------------------------- wiring
709
710export const register: Register = (on, options: Options) => {
711 const program = options.editor || 'micro'
712
713 on('session.start', async ($, e, next) => {
714 cwd = e.cwd
715 home = (await $.env.get('HOME')) ?? ''
716 await $.command.register({ name: 'atlas', description: 'Explore files in a pane; a file opens in micro (/atlas [path])', immediate: true })
717 await reseed($)
718 const pixels = e.surface === 'terminal' && (await pixelsHere($))
719 await write($, 'hasPixels', () => pixels)
720 try {
721 const kept = await $.store.get('showHidden')
722 await write($, 'showHidden', () => kept === true)
723 } catch {}
724 // A reload ended the old editor with the old module.
725 const closed = 'The editor closed when atlas reloaded. micro keeps a backup of unsaved changes and offers it when you reopen the file.'
726 await write($, 'editing', (now): Editing | null => (now === null ? now : { ...now, status: 'error', message: closed }))
727 if (e.isInteractive) $.clock.every(WATCH_MS, () => void watch($).catch(() => {}))
728
729 return next(e)
730 })
731
732 // /clear: the editor and the pane stay up, so what they show is written back as soon as the
733 // host's state is the new session's (now, or from the next event if that comes later).
734 on('session.end', async ($, e, next) => {
735 const result = await next(e)
736 if (e.reason === 'clear') await reseed($).catch(() => {})
737 return result
738 })
739
740 on('command.run', { command: 'atlas' }, async ($, e) => {
741 await reseed($)
742 const typed = e.args.trim()
743 let target: string | null = null
744 if (typed !== '') {
745 const spelled = typed === '~' ? home : typed.startsWith('~/') ? join(home, typed.slice(2)) : typed.startsWith('/') ? typed : join(cwd, typed)
746 try {
747 const stat = await $.fs.stat(spelled, { resolve: true })
748 target = stat.realPath ?? spelled
749 const current = (await read($, snapshot)).root || cwd
750 if (stat.kind === 'dir') await setRoot($, target)
751 else await setRoot($, target.startsWith(`${current}/`) ? current : dirname(target))
752 } catch {
753 return { text: `atlas: nothing at ${typed}.` }
754 }
755 } else if ((await read($, snapshot)).root === '') {
756 // Back where the person left this project last time, when that folder is still there.
757 let kept: Remembered | null = null
758 try {
759 const stored = (await $.store.get(`root:${cwd}`)) as Remembered | undefined
760 if (stored && typeof stored.root === 'string' && (await $.fs.exists(stored.root))) kept = stored
761 } catch {}
762 await setRoot($, kept?.root ?? cwd)
763 if (kept) {
764 for (const dir of kept.expanded.filter(p => p.startsWith(`${kept.root}/`))) {
765 if (await load($, dir)) await write($, 'expanded', list => (list.includes(dir) ? list : [...list, dir]))
766 }
767 }
768 } else {
769 await refresh($)
770 }
771 const opened = await openPane($)
772 const base = (await read($, snapshot)).root
773 if (target !== null && target !== base) {
774 await reveal($, target)
775 const info = (await read($, snapshot)).details
776 if (info?.kind === 'file') await edit($, options, target)
777 }
778 return { text: `Atlas opened on ${tilde(base)}.${opened.isPlaced ? '' : ' Widen the terminal to see the pane.'}` }
779 })
780
781 on('ui.close', async ($, e, next) => {
782 if (e.id === PANE && helper !== null && e.origin.kind !== 'unload') {
783 $.ui.toast(`${program} is still open: quit it with Ctrl+Q first, so it can ask about unsaved changes.`)
784 return { deny: `${program} is still open` }
785 }
786 return next(e)
787 })
788
789 // Walking the tree with the keys selects as it goes, as Dex's explorer does.
790 // A row's key is `n:` and its path below the root.
791 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
792 const result = await next(e)
793 if (result.deny === undefined && e.element?.startsWith('n:')) {
794 const path = join((await read($, snapshot)).root, e.element.slice(2))
795 if ((await read($, snapshot)).selected !== path) void select($, path).catch(() => {})
796 }
797 return result
798 })
799
800 on('ui.message', { requestId: PANE }, async ($, e) => {
801 const data = e.data as TerminalMessage
802 if (data.kind === 'size') await syncSize($)
803 else if (data.kind === 'key') await call($, '/key', data)
804 else if (data.kind === 'pointer') await call($, '/mouse', data)
805 return {}
806 })
807
808 // The wheel over the editor scrolls the file; over the explorer, the pane.
809 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
810 if (helper === null || editorView === null) return next(e)
811 const y = e.pointer ? e.pointer.row - EDITOR_HEADER_ROWS : Math.floor(editorView.rows / 2)
812 const x = e.pointer ? e.pointer.column : Math.floor(editorView.columns / 2)
813 const type = e.by < 0 ? 'wheel-up' : 'wheel-down'
814 for (let i = 0; i < Math.min(5, Math.abs(e.by)); i += 1) await call($, '/mouse', { type, x, y })
815 return {}
816 })
817
818 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
819 const elements = $.ui.resolve(e)
820 const { Box, Button, Code, Markdown, Text } = elements
821 paneBody = { columns: e.props.bodyColumns, rows: e.props.scroll.bodyRows }
822 // Drawn from what this process kept after a /clear; the host gets it back off the render.
823 if (!(await read($, seeded))) $.clock.after(0, () => void reseed($).catch(() => {}))
824 const shown = await read($, snapshot)
825 const { editing: now, root: base } = shown
826
827 if (now !== null) {
828 const rel = relOf(now.path, base)
829 if (now.status === 'error' || e.surface !== 'terminal') {
830 editorView = null
831 return (
832 <Box flexDirection="column">
833 <Text color="red">{now.message ?? `${program} draws only in Claude Code's terminal.`}</Text>
834 <Button key="back" variant="primary" label="Back to files" onPress={() => write($, 'editing', () => null)} />
835 </Box>
836 )
837 }
838 const { Client, Raster } = $.ui.resolve(e)
839 const view = { columns: e.props.bodyColumns, rows: Math.max(3, e.props.scroll.bodyRows - EDITOR_HEADER_ROWS) }
840 editorView = view
841 const fits = frame !== null && frame.columns === view.columns && frame.rows === view.rows
842 // Ctrl+C and Ctrl+Z never reach the editor (Claude Code keeps them), and
843 // Escape hands the keys back to the prompt: these send them instead.
844 const send = (text: string) => () => call($, '/text', { text })
845 return (
846 <Box flexDirection="column">
847 <Box flexDirection="row" gap={1}>
848 <Button key="esc" label="Esc" onPress={send('\x1b')} />
849 <Button key="copy" label="Copy" onPress={send('\x03')} />
850 <Button key="undo" label="Undo" onPress={send('\x1a')} />
851 <Text wrap="truncate-end">
852 <Text bold>{now.status === 'starting' ? `Starting ${program}…` : program}</Text>
853 <Text dimColor>{` ${rel} · click the text to type · Ctrl+S save · Ctrl+Q quit`}</Text>
854 </Text>
855 </Box>
856 <Box width={view.columns} height={view.rows}>
857 <Raster key="editor" columns={view.columns} rows={view.rows} cells={fits ? (frame as Frame).cells : blankCells(view)} />
858 <Box position="absolute" top={0} left={0}>
859 <Client key="terminal" module="./terminal.tsx" width={view.columns} height={view.rows} />
860 </Box>
861 </Box>
862 </Box>
863 )
864 }
865 editorView = null
866
867 const hidden = shown.showHidden
868 const marks = shown.git
869 // Indexed once for this drawing: every folder row asks it below.
870 const marksByPath = indexMarks(marks, base)
871 const allListings = shown.listings
872 const shownListings = hidden
873 ? allListings
874 : Object.fromEntries(Object.entries(allListings).map(([dir, entries]) => [dir, entries.filter(entry => !isHidden(entry.name))]))
875 const tree = rows(base, shownListings, shown.expanded, TREE_LIMITS)
876 const hiddenCount = (allListings[base] ?? []).filter(entry => isHidden(entry.name)).length
877 const chosen = shown.selected
878 const info = shown.details
879 const hits = shown.results
880 const named = shown.naming
881 const line = shown.notice
882 const typed = shown.query
883 const how = shown.mode
884 const pixels = shown.hasPixels
885 const width = e.props.bodyColumns
886 const isWide = width >= 90
887 const treeWidth = isWide ? Math.min(48, Math.floor(width * 0.42)) : width
888 const canType = e.surface !== 'mobile' // mobile draws no Input: naming needs one
889 const changed = Object.keys(marks).length
890 const setMode = async (next: SearchMode) => {
891 await write($, 'mode', () => next)
892 if (typed.trim()) await search($, typed)
893 }
894 const toggleHidden = async () => {
895 const next = !hidden
896 await write($, 'showHidden', () => next)
897 try {
898 await $.store.set('showHidden', next)
899 } catch {}
900 }
901 const collapseAll = async () => {
902 await write($, 'expanded', () => [])
903 await remember($)
904 }
905
906 let finder = null
907 if (e.surface !== 'mobile') {
908 const { Input } = $.ui.resolve(e)
909 finder = (
910 <Box flexDirection="row" gap={1}>
911 <Box flexGrow={1} flexShrink={1}>
912 <Input key="find" label="⌕ " placeholder={how === 'text' ? 'text inside files under this folder' : 'part of a name under this folder'}
913 value={typed} submitLabel="search" onSubmit={value => search($, value)} />
914 </Box>
915 {how === 'names'
916 ? <Text bold inverse>{' names '}</Text>
917 : <Button key="mode-names" plain label="names" onPress={() => setMode('names')} />}
918 {how === 'text'
919 ? <Text bold inverse>{' text '}</Text>
920 : <Button key="mode-text" plain label="text" onPress={() => setMode('text')} />}
921 </Box>
922 )
923 }
924
925 let asking = null
926 if (named !== null && e.surface !== 'mobile') {
927 const { Input } = $.ui.resolve(e)
928 const label = named.action === 'rename' ? `Rename ${nameOf(named.target)} to`
929 : `New ${named.action} in ${named.target === base ? tilde(base) : relOf(named.target, base)}`
930 asking = (
931 <Box flexDirection="row" gap={1}>
932 <Box flexGrow={1}>
933 <Input key="name" label={label} autoFocus value={named.action === 'rename' ? nameOf(named.target) : ''}
934 submitLabel={named.action === 'rename' ? 'rename' : 'create'} onSubmit={value => finishNaming($, value)} />
935 </Box>
936 <Button key="cancel" plain label="Cancel" onPress={() => write($, 'naming', () => null)} />
937 </Box>
938 )
939 }
940
941 let explorer
942 if (hits !== null) {
943 explorer = (
944 <Box flexDirection="column">
945 <Box flexDirection="row" gap={1}>
946 <Text bold>{`${hits.length}${hits.length === SEARCH_LIMIT ? '+' : ''} found`}</Text>
947 <Button key="clear" plain label="Back to the tree" onPress={() => write($, 'results', () => null)} />
948 </Box>
949 {hits.map((hit, index) => {
950 const where = relOf(hit.path, base)
951 const key = hit.line === undefined ? `r:${relative(hit.path, base)}` : `r:${relative(hit.path, base)}:${hit.line}`
952 return (
953 <Box key={`hit-${index}`} flexDirection="column">
954 <Button key={key} plain label={hit.line === undefined ? where : `${where}:${hit.line}`} onPress={() => reveal($, hit.path, hit.line)} />
955 {hit.text !== undefined && <Text dimColor wrap="truncate-end">{` ${hit.text}`}</Text>}
956 </Box>
957 )
958 })}
959 </Box>
960 )
961 } else {
962 // The focus ring starts in the tree, so the arrows walk it at once.
963 const firstEntry = tree.find(row => row.type === 'entry')
964 const ringStart = tree.some(row => row.path === chosen) ? chosen : firstEntry?.path
965 explorer = (
966 <Box flexDirection="column">
967 {tree.length === 0 && <Text dimColor>{hiddenCount > 0 ? `Only hidden files here (${hiddenCount}).` : 'This folder is empty.'}</Text>}
968 {tree.map(row => {
969 const indent = ' '.repeat(row.depth)
970 if (row.type === 'more') return <Text key={`more:${relative(row.path, base)}`} dimColor>{` ${indent}… ${row.hidden} more`}</Text>
971 if (row.type === 'cut') return <Text key="cut" dimColor> … more: close some folders, or use Find</Text>
972 const kind = classOf(row.entry.name, row.entry.kind, row.entry.isLink)
973 const isChosen = row.path === chosen
974 const mark = row.entry.kind === 'dir' ? markFor(marksByPath, row.path) : marks[row.path] ?? null
975 return (
976 <Box key={`row:${relative(row.path, base)}`} flexDirection="row" hover={{ backgroundColor: '#1c2730' }}>
977 <Text color="cyan">{isChosen ? '▌' : ' '}</Text>
978 <Text color={kind.color}>{`${indent}${row.entry.kind === 'dir' ? (row.isOpen ? '▾' : '▸') : kind.glyph} `}</Text>
979 <Button key={`n:${relative(row.path, base)}`} plain dimColor={isHidden(row.entry.name) && !isChosen}
980 autoFocus={row.path === ringStart ? true : undefined}
981 label={printable(row.entry.kind === 'dir' ? `${row.entry.name}/` : row.entry.name)}
982 onPress={async () => {
983 await say($, null)
984 if (row.entry.kind === 'dir') await toggle($, row.path)
985 await select($, row.path)
986 }} />
987 {mark !== null && (
988 <Text color={MARK_COLORS[mark]}>{row.entry.kind === 'dir' ? ' •' : ` ${mark}`}</Text>
989 )}
990 </Box>
991 )
992 })}
993 </Box>
994 )
995 }
996
997 const prop = (name: string, value: string, color?: string) => (
998 <Box key={`prop:${name}`} flexDirection="row">
999 <Box width={10} flexShrink={0}><Text dimColor>{name}</Text></Box>
1000 <Text wrap="truncate-middle" color={color}>{value}</Text>
1001 </Box>
1002 )
1003 const isFile = info !== null && info.kind === 'file'
1004 const isDir = info !== null && info.kind === 'dir'
1005 const chosenMark = info === null ? null : marks[info.path] ?? null
1006 let source = null
1007 if (info?.preview) {
1008 const shown = info.preview
1009 if (shown.kind === 'text') {
1010 source = (
1011 <Box flexDirection="column">
1012 {shown.startLine > 1 && <Text dimColor>{`… from line ${shown.startLine}`}</Text>}
1013 <Code source={shown.text || ' '} path={printable(info.path)} startLine={shown.startLine} wrap="truncate-end" />
1014 {shown.isCut && <Text dimColor>{`… press e to open the whole file in ${program}`}</Text>}
1015 </Box>
1016 )
1017 } else if (shown.kind === 'markdown') {
1018 source = (
1019 <Box flexDirection="column">
1020 <Markdown key="markdown" text={shown.text || ' '} />
1021 {shown.isCut && <Text dimColor>{`… press e to open the whole file in ${program}`}</Text>}
1022 </Box>
1023 )
1024 } else if (shown.kind === 'image' && 'Image' in elements && pixels) {
1025 const room = { columns: Math.max(10, Math.min(isWide ? width - treeWidth - 4 : width - 2, 80)), rows: Math.max(4, Math.min(24, Math.floor(paneBody.rows * 0.5))) }
1026 source = <elements.Image key="picture" source={{ file: shown.path, format: 'png', generation: Math.trunc(info.mtimeMs) }} columns={room.columns} rows={room.rows} alt={nameOf(shown.path)} />
1027 } else {
1028 source = <Text dimColor>{shown.kind === 'binary' ? 'Binary file: no preview.' : shown.kind === 'large' ? 'Too large to preview here.' : shown.kind === 'image' ? 'A picture: open it with its app to see it.' : `Cannot read it: ${shown.reason}`}</Text>
1029 }
1030 }
1031
1032 const inspector = (
1033 <Box flexDirection="column" flexGrow={1}>
1034 <Text bold>Properties</Text>
1035 {info === null || info.path !== chosen
1036 ? <Text dimColor>{chosen === null ? 'Select a file or folder: click it, or Tab to it.' : 'Reading…'}</Text>
1037 : (
1038 <Box flexDirection="column">
1039 {prop('Name', nameOf(info.path))}
1040 {prop('Class', classOf(basename(info.path), info.kind, info.isLink).name)}
1041 {prop('Path', relOf(info.path, base))}
1042 {isFile && prop('Size', size(info.size))}
1043 {isDir && info.items !== null && prop('Items', String(info.items))}
1044 {prop('Modified', date(info.mtimeMs))}
1045 {chosenMark !== null && prop('Git', MARK_NAMES[chosenMark], MARK_COLORS[chosenMark])}
1046 {info.realPath !== null && prop('Links to', tilde(info.realPath))}
1047 </Box>
1048 )}
1049 <Text bold>Actions</Text>
1050 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1051 {isFile && <Button key="edit" plain hotkey="e" label={`Edit in ${program}`} onPress={() => edit($, options, info.path)} />}
1052 {isDir && <Button key="open" plain hotkey="o" label="Open as root" onPress={() => setRoot($, info.path)} />}
1053 {chosen !== null && <Button key="mention" plain hotkey="p" label="Add to prompt" onPress={() => mention($, chosen)} />}
1054 {chosen !== null && <Button key="copy-path" plain hotkey="c" label="Copy path" onPress={press => copyPath($, chosen, press.surface)} />}
1055 {chosen !== null && e.surface === 'terminal' && <Button key="open-app" plain hotkey="a" label="Open with app" onPress={() => openWithApp($, chosen)} />}
1056 {chosen !== null && canType && <Button key="rename" plain hotkey="r" label="Rename" onPress={() => startNaming($, 'rename')} />}
1057 {chosen !== null && <Button key="duplicate" plain hotkey="d" label="Duplicate" onPress={() => duplicate($, chosen)} />}
1058 {chosen !== null && <Button key="trash" plain hotkey="x" label="Trash" onPress={() => trash($, chosen)} />}
1059 {canType && <Button key="new-file" plain hotkey="n" label="New file" onPress={() => startNaming($, 'file')} />}
1060 {canType && <Button key="new-folder" plain hotkey="f" label="New folder" onPress={() => startNaming($, 'folder')} />}
1061 </Box>
1062 {source !== null && <Text bold>{info?.preview?.kind === 'markdown' ? 'Preview' : 'Source'}</Text>}
1063 {source}
1064 </Box>
1065 )
1066
1067 const trail = crumbs(base, home)
1068 const shownTrail = trail.length > 4 ? trail.slice(-4) : trail
1069 return (
1070 <Box flexDirection="column">
1071 <Box flexDirection="row" gap={1} flexWrap="wrap">
1072 <Box flexShrink={0}><Text bold color="cyan">Atlas</Text></Box>
1073 <Box flexDirection="row" flexShrink={1} flexWrap="wrap">
1074 {trail.length > 4 && <Text dimColor>{'… '}</Text>}
1075 {shownTrail.map((crumb, index) => (
1076 <Box key={`crumb-${index}`} flexDirection="row">
1077 {index === shownTrail.length - 1
1078 ? <Text bold>{printable(crumb.label)}</Text>
1079 : <Button key={`crumb:${crumb.path}`} plain dimColor label={printable(crumb.label)} onPress={() => setRoot($, crumb.path)} />}
1080 {index < shownTrail.length - 1 && <Text dimColor>{' › '}</Text>}
1081 </Box>
1082 ))}
1083 </Box>
1084 <Box flexGrow={1} />
1085 {changed > 0 && <Text color="yellow">{`${changed} changed`}</Text>}
1086 <Button key="up" plain hotkey="u" label="Up" onPress={() => setRoot($, dirname(base))} />
1087 <Button key="hidden" plain hotkey="h" label={hidden ? 'Hide dotfiles' : `Show dotfiles${hiddenCount > 0 ? ` (${hiddenCount})` : ''}`}
1088 onPress={() => toggleHidden()} />
1089 <Button key="collapse" plain hotkey="z" label="Collapse" onPress={() => collapseAll()} />
1090 <Button key="refresh" plain hotkey="g" label="Refresh" onPress={() => refresh($)} />
1091 </Box>
1092 {finder}
1093 {asking}
1094 {line !== null && <Text color="yellow" wrap="truncate-end">{line}</Text>}
1095 {isWide
1096 ? (
1097 <Box flexDirection="row" gap={2}>
1098 <Box width={treeWidth} flexShrink={0} flexDirection="column">{explorer}</Box>
1099 {inspector}
1100 </Box>
1101 )
1102 : (
1103 <Box flexDirection="column">
1104 {explorer}
1105 {inspector}
1106 </Box>
1107 )}
1108 </Box>
1109 )
1110 })
1111}
1112hooks/files.ts 292 lines1// Pure helpers for paths, file classes and the flattened tree: nothing here
2// touches `$`, so the tests import them directly.
3
4import type { Entry, GitMark } from '../types'
5
6/**
7 * A name from disk as drawable text: a file name may legally hold a newline
8 * or an escape, and one control character in a label refuses the whole pane.
9 */
10export const printable = (text: string) => text.replace(/[\x00-\x1f\x7f-\x9f]/g, '?')
11
12/** File content for the Code element, which takes tab and newline and no other control. */
13export const printableSource = (text: string) =>
14 text.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '�')
15
16export const dirname = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/'
17export const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1) || path
18export const join = (dir: string, name: string) => (dir === '/' ? `/${name}` : `${dir}/${name}`)
19
20/** `path` from `base` when it lies inside it, else `path` as given. */
21export function relative(path: string, base: string): string {
22 if (path === base) return '.'
23 return path.startsWith(base === '/' ? '/' : `${base}/`) ? path.slice(base === '/' ? 1 : base.length + 1) : path
24}
25
26/** What a typed name may be: one path segment, nothing that climbs out. */
27export function nameProblem(name: string): string | null {
28 if (name.trim() === '') return 'A name cannot be empty.'
29 if (name.includes('/') || name.includes('\0')) return 'A name cannot contain / .'
30 if (name === '.' || name === '..') return `"${name}" is not a name.`
31 if (name.length > 255) return 'That name is too long.'
32 return null
33}
34
35/** "a.txt" → "a copy.txt", then "a copy 2.txt" and on. */
36export function copyName(name: string, attempt: number): string {
37 const dot = name.lastIndexOf('.')
38 const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
39 return `${stem} copy${attempt > 1 ? ` ${attempt}` : ''}${ext}`
40}
41
42export function size(bytes: number): string {
43 if (bytes < 1024) return `${bytes} B`
44 const units = ['KB', 'MB', 'GB', 'TB']
45 let value = bytes / 1024
46 let unit = 0
47 while (value >= 1024 && unit < units.length - 1) {
48 value /= 1024
49 unit += 1
50 }
51 return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
52}
53
54export function date(ms: number): string {
55 const d = new Date(ms)
56 const two = (n: number) => String(n).padStart(2, '0')
57 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
58}
59
60/** A file's class, as Dex names an instance's: a glyph, a color and a name. */
61export type FileClass = { glyph: string; color: string; name: string }
62
63const LANGUAGES: Record<string, string> = {
64 ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript',
65 mjs: 'JavaScript', cjs: 'JavaScript', py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin',
66 swift: 'Swift', c: 'C', h: 'C', cpp: 'C++', hpp: 'C++', cc: 'C++', cs: 'C#', php: 'PHP', lua: 'Lua', luau: 'Luau',
67 sh: 'Shell', bash: 'Shell', zsh: 'Shell', fish: 'Shell', sql: 'SQL', html: 'HTML', css: 'CSS', scss: 'SCSS',
68 vue: 'Vue', svelte: 'Svelte', dart: 'Dart', zig: 'Zig', ex: 'Elixir', hs: 'Haskell',
69}
70const DOCUMENTS: Record<string, string> = { md: 'Markdown', mdx: 'MDX', txt: 'Text', rst: 'reStructuredText', adoc: 'AsciiDoc' }
71const DATA: Record<string, string> = {
72 json: 'JSON', jsonc: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', ini: 'INI', xml: 'XML', csv: 'CSV',
73 lock: 'Lockfile', env: 'Environment', plist: 'Property list',
74}
75const MEDIA = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'mp3', 'wav', 'mp4', 'mov', 'webm'])
76const BINARY = new Set(['zip', 'tar', 'gz', 'tgz', 'dmg', 'exe', 'bin', 'o', 'so', 'dylib', 'wasm', 'pdf', 'db', 'sqlite'])
77
78export function classOf(name: string, kind: Entry['kind'], isLink: boolean): FileClass {
79 if (kind === 'dir') return { glyph: '▸', color: 'yellow', name: isLink ? 'Folder (link)' : 'Folder' }
80 if (isLink) return { glyph: '↪', color: 'blue', name: 'Link' }
81 const dot = name.lastIndexOf('.')
82 const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : name.startsWith('.env') ? 'env' : ''
83 if (ext in LANGUAGES) return { glyph: '◆', color: 'cyan', name: `Script · ${LANGUAGES[ext]}` }
84 if (ext in DOCUMENTS) return { glyph: '≡', color: 'white', name: `Document · ${DOCUMENTS[ext]}` }
85 if (ext in DATA) return { glyph: '▪', color: 'green', name: `Data · ${DATA[ext]}` }
86 if (MEDIA.has(ext)) return { glyph: '▣', color: 'magenta', name: `Media · ${ext.toUpperCase()}` }
87 if (BINARY.has(ext)) return { glyph: '■', color: 'red', name: `Binary · ${ext.toUpperCase()}` }
88 return { glyph: '·', color: 'gray', name: kind === 'other' ? 'Special file' : 'File' }
89}
90
91/** Folders first, then names, ignoring case. */
92export function sorted(entries: readonly Entry[]): Entry[] {
93 return [...entries].sort((a, b) =>
94 a.kind === 'dir' && b.kind !== 'dir' ? -1
95 : b.kind === 'dir' && a.kind !== 'dir' ? 1
96 : a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
97}
98
99export type Row =
100 | { type: 'entry'; path: string; depth: number; entry: Entry; isOpen: boolean }
101 | { type: 'more'; path: string; depth: number; hidden: number }
102 | { type: 'cut'; path: string; depth: 0 }
103
104/** How much of the tree one drawing holds: a pane refuses past 100,000 characters of text. */
105export type TreeLimits = { perFolder: number; rows: number; characters: number }
106
107/**
108 * The tree as drawn: the root's children, and each open folder's beneath it,
109 * up to `limits`; past them a `cut` row stands for the rest.
110 */
111export function rows(
112 root: string,
113 listings: Readonly<Record<string, readonly Entry[]>>,
114 expanded: readonly string[],
115 limits: TreeLimits,
116): Row[] {
117 const open = new Set(expanded)
118 const out: Row[] = []
119 let characters = 0
120 const walk = (dir: string, depth: number): boolean => {
121 const entries = listings[dir]
122 if (entries === undefined) return true
123 for (const entry of entries.slice(0, limits.perFolder)) {
124 const path = join(dir, entry.name)
125 // The row's label and indent, and its key: the path below the root.
126 characters += entry.name.length + depth * 2 + (path.length - root.length) + 8
127 if (out.length >= limits.rows || characters > limits.characters) return false
128 const isOpen = entry.kind === 'dir' && open.has(path)
129 out.push({ type: 'entry', path, depth, entry, isOpen })
130 if (isOpen && !walk(path, depth + 1)) return false
131 }
132 if (entries.length > limits.perFolder) out.push({ type: 'more', path: dir, depth, hidden: entries.length - limits.perFolder })
133 return true
134 }
135 if (!walk(root, 0)) out.push({ type: 'cut', path: root, depth: 0 })
136 return out
137}
138
139/** The folders between `root` (exclusive) and `path` (exclusive), outermost first. */
140export function foldersBetween(root: string, path: string): string[] {
141 const out: string[] = []
142 for (let dir = dirname(path); dir !== root && dir.startsWith(root) && dir !== '/'; dir = dirname(dir)) out.unshift(dir)
143 return out
144}
145
146/** `-iname` reads `* ? [ ]` as a pattern; the typed words are literal. */
147export const literalPattern = (text: string) => `*${text.replace(/[\\*?[\]]/g, '\\$&')}*`
148
149/** Whether an entry is hidden by convention: its name starts with a dot. */
150export const isHidden = (name: string) => name.startsWith('.')
151
152/** The folders from `home` or `/` down to `path`, each with its label and full path. */
153export function crumbs(path: string, home: string): { label: string; path: string }[] {
154 const out: { label: string; path: string }[] = []
155 const inHome = home !== '' && (path === home || path.startsWith(`${home}/`))
156 const base = inHome ? home : '/'
157 out.push({ label: inHome ? '~' : '/', path: base })
158 const rest = inHome ? path.slice(home.length) : path
159 let at = base
160 for (const part of rest.split('/').filter(Boolean)) {
161 at = join(at, part)
162 out.push({ label: part, path: at })
163 }
164 return out
165}
166
167/**
168 * `git status --porcelain -z` as marks by absolute path. An untracked folder
169 * is listed once with a trailing slash; everything beneath it is untracked.
170 * A rename lists the new name first, then the old, NUL-separated.
171 */
172export function parseGitStatus(porcelain: string, top: string): Record<string, GitMark> {
173 const marks: Record<string, GitMark> = {}
174 const fields = porcelain.split('\0')
175 for (let i = 0; i < fields.length; i += 1) {
176 const field = fields[i] ?? ''
177 if (field.length < 4) continue
178 const x = field[0] ?? ' '
179 const y = field[1] ?? ' '
180 const rel = field.slice(3)
181 const path = join(top, rel.replace(/\/$/, ''))
182 if (x === 'R' || x === 'C') {
183 marks[path] = 'R'
184 i += 1 // the old name follows
185 } else if (x === '?' && y === '?') marks[path] = '?'
186 else if (x === 'D' || y === 'D') marks[path] = 'D'
187 else if (x === 'A') marks[path] = 'A'
188 else if (x === 'M' || y === 'M' || x === 'T' || y === 'T') marks[path] = 'M'
189 }
190 return marks
191}
192
193/** The mark a folder shows: the strongest of what lies beneath it, or none. */
194export function folderMark(dir: string, marks: Readonly<Record<string, GitMark>>): GitMark | null {
195 let found: GitMark | null = null
196 const prefix = dir === '/' ? '/' : `${dir}/`
197 for (const [path, mark] of Object.entries(marks)) {
198 if (path === dir || path.startsWith(prefix)) {
199 if (mark === 'M' || mark === 'D') return 'M'
200 found = found ?? mark
201 }
202 }
203 return found
204}
205
206/**
207 * The marks as the tree draws them, rolled up to the folders holding them.
208 *
209 * `folderMark` answers one folder by walking every mark, and the tree asks it
210 * once per folder row, so a repository with thousands of changed files costs
211 * rows × marks on every redraw. This builds the same answers once: each mark
212 * is offered to the folders above it, deepest first, so a drawing costs
213 * marks × depth to build and one lookup per row after that.
214 *
215 * A mark keeps the same standing `folderMark` gives it: a change or a deletion
216 * anywhere beneath reads as a change, and otherwise the first mark found wins.
217 */
218export type MarkIndex = { byPath: Readonly<Record<string, GitMark>>; byFolder: ReadonlyMap<string, GitMark> }
219
220export function indexMarks(marks: Readonly<Record<string, GitMark>>, root: string): MarkIndex {
221 const byFolder = new Map<string, GitMark>()
222 const under = root === '/' ? '/' : `${root}/`
223 for (const [path, mark] of Object.entries(marks)) {
224 if (path !== root && !path.startsWith(under)) continue
225 // The folders this mark shows on: every folder from the root down to the
226 // mark's own path, which is a folder itself when git reported a directory.
227 const chain = [path]
228 for (let dir = dirname(path); ; dir = dirname(dir)) {
229 chain.push(dir)
230 if (dir === root || dir === '/') break
231 }
232 for (const dir of chain.reverse()) {
233 const was = byFolder.get(dir)
234 // A change or a deletion anywhere beneath reads as a change, whatever
235 // was found first; anything else keeps the first mark that got here.
236 if (mark === 'M' || mark === 'D') {
237 if (was !== 'M') byFolder.set(dir, 'M')
238 } else if (was === undefined) byFolder.set(dir, mark)
239 }
240 }
241 return { byPath: marks, byFolder }
242}
243
244/** The mark a folder row draws, from an index built once for the drawing. */
245export function markFor(index: MarkIndex, dir: string): GitMark | null {
246 return index.byFolder.get(dir) ?? null
247}
248
249/**
250 * Whether a folder now holds what the drawing already has. The watcher asks
251 * this every few seconds for every open folder, and building two JSON strings
252 * to compare them costs far more than reading the fields.
253 */
254export function sameEntries(known: readonly Entry[], fresh: readonly Entry[]): boolean {
255 if (known === fresh) return true
256 if (known.length !== fresh.length) return false
257 for (let i = 0; i < known.length; i += 1) {
258 const was = known[i]!
259 const now = fresh[i]!
260 if (was.name !== now.name || was.kind !== now.kind || was.size !== now.size
261 || was.mtimeMs !== now.mtimeMs || was.isLink !== now.isLink) return false
262 }
263 return true
264}
265
266/** Whether git now says what the tree already draws, without stringifying either. */
267export function sameMarks(known: Readonly<Record<string, GitMark>>, fresh: Readonly<Record<string, GitMark>>): boolean {
268 if (known === fresh) return true
269 const paths = Object.keys(known)
270 if (paths.length !== Object.keys(fresh).length) return false
271 for (const path of paths) if (known[path] !== fresh[path]) return false
272 return true
273}
274
275/** `path:line:text` lines as grep and ripgrep print them, absolute paths only. */
276export function parseGrep(output: string, limit: number): { path: string; line: number; text: string }[] {
277 const hits: { path: string; line: number; text: string }[] = []
278 for (const row of output.split('\n')) {
279 const match = /^(\/[^\0]*?):(\d+):(.*)$/.exec(row)
280 if (!match) continue
281 hits.push({ path: match[1] ?? '', line: Number(match[2]), text: printable((match[3] ?? '').trim()).slice(0, 160) })
282 if (hits.length >= limit) break
283 }
284 return hits
285}
286
287/** Whether a file is a picture a kitty-graphics terminal can read as it is: a PNG. */
288export const isPng = (name: string) => /\.png$/i.test(name)
289
290/** Whether a file reads as Markdown, drawn rendered rather than as source. */
291export const isMarkdown = (name: string) => /\.(md|mdx|markdown)$/i.test(name)
292hooks/terminal.tsx 28 lines1import type { ClientModule } from 'claude-code'
2
3type Size = { columns: number; rows: number }
4
5// Lies over the editor's picture and hands the hooks module what the person
6// does there: its size as laid out, every key while it has the focus (a click
7// gives it; Escape returns it to the prompt), and the pointer in cells.
8const Terminal: ClientModule<null, Size> = (_props, surface) => {
9 if (surface.state === undefined) {
10 surface.onKey(e => surface.post({ kind: 'key', ...e }))
11 surface.onPointer(e => {
12 // A move matters only as a drag: micro selects text with it.
13 if (e.type === 'enter' || e.type === 'leave' || (e.type === 'move' && e.button === undefined)) return
14 surface.post({ kind: 'pointer', type: e.type, x: e.x, y: e.y, button: e.button ?? 'left' })
15 })
16 }
17 const { columns, rows } = surface
18 if (columns > 0 && (surface.state?.columns !== columns || surface.state?.rows !== rows)) {
19 surface.post({ kind: 'size', columns, rows })
20 surface.setState({ columns, rows })
21 }
22 const { Box } = surface.elements
23
24 return <Box width={columns} height={rows} />
25}
26
27export default Terminal
28types/index.d.ts 72 lines1/** One directory entry, as `$.fs.list` answers it. */
2export type Entry = {
3 name: string
4 kind: 'file' | 'dir' | 'other'
5 size: number
6 mtimeMs: number
7 isLink: boolean
8}
9
10/** What the source viewer shows for the selected file. */
11export type Preview =
12 | { kind: 'text'; text: string; isCut: boolean; startLine: number }
13 | { kind: 'markdown'; text: string; isCut: boolean }
14 | { kind: 'image'; path: string }
15 | { kind: 'binary' }
16 | { kind: 'large' }
17 | { kind: 'unreadable'; reason: string }
18
19/** How a file stands against git: changed, added, untracked, deleted, renamed. */
20export type GitMark = 'M' | 'A' | '?' | 'D' | 'R'
21
22/** One search hit: a file by name, or a line in a file by its text. */
23export type Hit = { path: string; line?: number; text?: string }
24
25/** Which search runs: file names, or text inside files. */
26export type SearchMode = 'names' | 'text'
27
28/** The Properties panel: the selected entry, read when it was selected. */
29export type Details = {
30 path: string
31 kind: 'file' | 'dir' | 'other'
32 size: number
33 mtimeMs: number
34 isLink: boolean
35 realPath: string | null
36 items: number | null
37 preview: Preview | null
38}
39
40/** A name being typed: a rename of `target`, or a new file or folder in it. */
41export type Naming = { action: 'rename' | 'file' | 'folder'; target: string }
42
43/** The file open in the editor, and how far its start got. */
44export type Editing = { path: string; status: 'starting' | 'running' | 'error'; message: string | null }
45
46declare module 'claude-code' {
47 interface PluginState {
48 atlas: {
49 root: string
50 expanded: string[]
51 listings: Record<string, Entry[]>
52 selected: string | null
53 details: Details | null
54 query: string
55 mode: SearchMode
56 /** Search hits under the root, or null while the tree shows. */
57 results: Hit[] | null
58 naming: Naming | null
59 notice: string | null
60 editing: Editing | null
61 /** Whether files whose name starts with a dot are drawn. */
62 showHidden: boolean
63 /** What git says about files under the root, by absolute path. */
64 git: Record<string, GitMark>
65 /** Whether this terminal draws real pixels, for picture previews. */
66 hasPixels: boolean
67 /** True once this session's values are written; false after a /clear empties them. */
68 seeded: boolean
69 }
70 }
71}
72