SLOPSHOPPER

atlas

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.

newpanecommandtoastprocessnetwork
★ 1v0.2.0MITupdated 2026-10-02jamubc/toolbox/plugins/atlas
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · atlas
│ ┃ Atlas ✕ › fix the failing auth test and add an audit log call │ ┃ Atlas / › work › app u: Up h: Show dotfiles │ ┃ ⌕ : part of a name under this folder ⏎ searc ⏺ Read(src/auth.ts) │ ┃ ▪ package.json ⎿ Read 6 lines │ ┃ ≡ README.md ⏺ Update(src/auth.ts) │ ┃ · src ⎿ Added 2 lines, removed 1 line │ ┃ Properties ⏺ Bash(bun test) │ ┃ Select a file or folder: click it, or Tab to ⎿ 3 pass, 1 fail │ ┃ it. │ ┃ Actions ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ n: New file f: New folder │ ✻ Worked for 42s · done 4:20 PM │ │ › /atlas │ ⎿ atlas: Atlas opened on /work/app. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Atlas
Atlas / › work › app u: Up h: Show dotfiles z: Collapse g: ⌕ : part of a name under this folder ⏎ search names text ▪ package.json ≡ README.md · src Properties Select a file or folder: click it, or Tab to it. Actions n: New file f: New folder
README

atlas

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

Claude Code with the atlas pane docked on the right: a small TypeScript project with src and lib open, forecast.ts selected, and its properties, actions and colored source beside the Claude prompt

Install

Add the marketplace:

/plugin marketplace add jamubc/toolbox

Install the plugin:

/plugin install atlas@toolbox

Usage

Commandfunction
/atlasOpens 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 trailThe path at the top is a trail of folders: click one to go back up to it.
Click a folderOpens or closes it.
Click a fileShows its Properties and Source: code with line numbers, Markdown rendered, a PNG as a picture in kitty and Ghostty.
Git marksFiles 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.
FindType part of a name and press Enter; text searches inside files instead (ripgrep, else grep) and a hit opens the file at that line.
KeyAction
eEdit in micro
pAdd the file to your prompt as @path
cCopy the path
aOpen with the system's own app (open, xdg-open)
r / dRename / duplicate
xMove to the Trash, after you confirm
n / fNew file / new folder, in the selected folder
oOpen the selected folder as the root
u / gGo up a folder / refresh
h / zShow 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.

In the editor

micro runs for real inside the pane: your own micro settings, colors and plugins.

  • Click the text to type. Claude Code only hands keys to the editor after a click, and Esc gives them back to the prompt.
  • Ctrl+S saves, Ctrl+Q quits back to the explorer, and most other keys reach micro.
  • Claude Code keeps Ctrl+C, Ctrl+Z and Ctrl+O for itself, and Esc returns the keys, so the header has Esc, Copy and Undo buttons that send them to micro. Don't press Ctrl+Z: it suspends Claude Code.
  • Pasting with your terminal goes to the Claude prompt. Use micro's Ctrl+V, which reads the clipboard.
  • The pane won't close while micro is open: quit with Ctrl+Q so micro can ask about unsaved changes.
  • /config → Editor for files runs another terminal editor instead: nano and vim work too.

Compatibility

WhereExplorerEditor
Claude Code in any terminalYesYes
Claude desktop app, VS Code extensionYesNo.
MobileYes, without Find or naming fieldsNo
SystemStatus
macOSTested: browsing, Find, rename, new, duplicate, Trash, editing in micro.
LinuxShould work; untested. Trash needs gio or trash-cli; opening with an app needs xdg-open.
WindowsNot supported. Editing says so, and paths assume /.
  • Needs Python 3.9 or later (macOS's own /usr/bin/python3 works) and micro, or the editor you set. If one is missing, the pane says which and how to fix it.
  • Wide characters (CJK, emoji) show as ? in the editor; the file itself is untouched.
  • Trash, never delete. It uses macOS's trash, or gio trash or trash-put on Linux, after a confirmation. If none exists, nothing is deleted.
  • No overwrites. Rename, new file, new folder and duplicate refuse a name that already exists, and names cannot contain / or be ...
  • Unsaved edits. If Claude Code exits or is killed, or atlas reloads, while micro is open, micro closes and keeps a backup. Reopen the file and micro offers to recover it.
  • /clear and /compact. The pane, the tree and an open editor carry on through both: /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.
  • Nothing reaches Claude unless you press Add to prompt, or open a path with /atlas, whose reply names the folder.
  • Everything runs locally: no network. git status and the search run only under the folder shown.
Source 4 files
hooks/register.tsx 1112 lines
1// 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}
1112
hooks/files.ts 292 lines
1// 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)
292
hooks/terminal.tsx 28 lines
1import 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
28
types/index.d.ts 72 lines
1/** 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