Read the files of your project in a calm pane beside the chat.

A Claude Code mod. /files opens a pane beside the chat with the files of your project. Markdown shows as a document, with headings, tables and paragraphs that wrap.

The document is next to the chat. The file tree is at the right edge. The prompt stays where it is, at the bottom.
In a Claude Code terminal session:
/plugin install folio --marketplace krishna-goutham-tls/folio
Answer y to add the marketplace, then pick a scope. The user scope loads folio in every session. Then type /files.
folio is built and tested on Claude Code 2.1.295. Mods are early access, so a later release can change the API and break it.
| You do | The pane does |
|---|---|
/files | Opens, and shows the README of the project |
/files docs/guide.md | Opens that file and its folder |
| Click a folder | Opens or closes it |
| Click a file | Shows it |
| Scroll over the document or the tree | Moves that part only |
Click Read or Source | Shows the formatted document or the text as written |
| Click a link to a file of the project | Opens that file |
Click Mention | Puts @path in the prompt |
Click Open | Opens the file in its Mac app |
Click ⇄ | Moves the tree to the other side |
ctrl+x tab gives the pane the keyboard. The arrows and the page keys move the document, Tab walks the rows, and Enter opens one. Esc returns to the prompt.
| File | How |
|---|---|
| Markdown | Headings, paragraphs that wrap, lists, task lists, quotes, tables, code blocks with colours, front matter |
| Code and text | Claude Code's syntax colours, with line numbers |
| CSV and TSV | A table |
| PNG, JPG, GIF, HEIC | The picture, in a terminal with the kitty graphics protocol such as Ghostty or kitty |
| PDF, Office files, SVG | The first page as a picture, made by macOS Quick Look |
.env, keys, credentials | A notice. The text stays hidden until you click Reveal, so a shared screen or a recording is safe |
| Other files | The name and size. Open shows the file in its own app |
A file that Claude writes gets a ● in the tree. The open document reads the disk again when a turn ends.
The pane follows your light or dark theme.

A mod runs inside Claude Code, so it is fair to ask what this one does.
sips and qlmanage to make a picture preview, mkdir and mv for that preview in the temp folder, and open when you click Open.claude plugin validate . prints the same list from the source.
Mention puts the path in the prompt so you can ask Claude for the change.● only when its folder is open in the tree.Open work is in TRACKER.md.
hooks/register.tsx has the hooks and every read of the disk.hooks/markdown.ts turns markdown into rows, one per terminal line. The pane draws only the rows in view, so a long file scrolls like a short one.hooks/view.tsx draws the tree and the reader.git clone https://github.com/krishna-goutham-tls/folio
cd folio
claude --plugin-dir .
Claude Code loads the mod from the folder and reloads it when you save a file. It also writes the API types to .claude-plugin/types/, which the tsconfig.json here reads.
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .
MIT
hooks/register.tsx 630 lines1// folio: the files of the project in a pane beside the chat. A tree on the
2// left, the open file on the right, markdown set as a document.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { FolioMode, FolioUi } from '../types'
7import { baseOf, dirOf, hasTwoForms, isHidden, kindOf } from './files'
8import { MAX_TEXT_BYTES, byName, goneDoc, hash, lockedDoc, notice, opaqueDoc, pictureDoc, planOf, textDoc } from './load'
9import type { DocRequest, Entry } from './load'
10import type { Doc, Frame, Node } from './model'
11import { drawPane } from './view'
12import type { Actions } from './view'
13
14const PANE = 'folio'
15const TITLE = 'Files'
16/** The pane shows tree and reader side by side from this many columns. */
17const SPLIT_FROM = 84
18/** The widest a line of prose runs, for a comfortable read. */
19const MEASURE = 88
20const FIRST_FILES = ['README.md', 'AGENTS.md', 'CLAUDE.md', 'readme.md']
21
22const START: FolioUi = { expanded: [], selected: '', mode: 'read', docTop: 0, treeTop: 0, revealed: [], tick: 0, side: 'right', isRestored: false }
23const ui = atom({ plugin: 'folio', key: 'ui' } as const, START)
24const touched = atom({ plugin: 'folio', key: 'touched' } as const, [])
25
26const clamp = (n: number, low: number, high: number): number => Math.max(low, Math.min(high, n))
27
28// What was read from disk, kept until the files change (`forget`).
29let root = ''
30const listings = new Map<string, Entry[]>()
31const docs = new Map<string, Doc>()
32
33const forget = (): void => {
34 listings.clear()
35 docs.clear()
36}
37
38const rootOf = async ($: EngineInterface): Promise<string> => {
39 if (root === '') {
40 root = (await $.session.root()).replace(/\/+$/, '')
41 }
42
43 return root
44}
45
46/** Keeps the open folders and the open file for the next session in this project. */
47const keep = async ($: EngineInterface): Promise<void> => {
48 const { value } = await $.state.get({ plugin: 'folio', key: 'ui' } as const)
49
50 if (value !== undefined && root !== '') {
51 await $.store.set(`ui:${root}`, { expanded: value.expanded.slice(-200), selected: value.selected, mode: value.mode })
52 }
53}
54
55const ancestors = (path: string): string[] => {
56 const parts = path.split('/').slice(0, -1)
57
58 return parts.map((_, n) => parts.slice(0, n + 1).join('/'))
59}
60
61/** A path as a link or a command names it, made relative to the project. */
62const resolve = (from: string, target: string): string => {
63 let clean = target.split('#')[0] ?? ''
64
65 try {
66 clean = decodeURIComponent(clean)
67 } catch {
68 // Kept as written.
69 }
70
71 if (root !== '' && clean.startsWith(`${root}/`)) {
72 clean = clean.slice(root.length)
73 }
74
75 const parts = clean.startsWith('/') ? [] : from.split('/').filter(part => part !== '')
76
77 for (const part of clean.split('/')) {
78 if (part === '..') {
79 parts.pop()
80 } else if (part !== '' && part !== '.') {
81 parts.push(part)
82 }
83 }
84
85 return parts.join('/')
86}
87
88const list = async ($: EngineInterface, dir: string): Promise<Entry[]> => {
89 const known = listings.get(dir)
90
91 if (known !== undefined) {
92 return known
93 }
94
95 const abs = dir === '' ? root : `${root}/${dir}`
96 const entries: Entry[] = []
97 const raw = await $.fs.list(abs).catch(() => [])
98
99 for (const entry of raw) {
100 if (isHidden(entry.name)) {
101 continue
102 }
103
104 if (entry.kind !== 'other') {
105 entries.push({ name: entry.name, isDir: entry.kind === 'dir', mtimeMs: entry.mtimeMs })
106 continue
107 }
108
109 if (entry.isLink) {
110 const target = await $.fs.stat(`${abs}/${entry.name}`).catch(() => undefined)
111
112 if (target !== undefined && target.kind !== 'other') {
113 entries.push({ name: entry.name, isDir: target.kind === 'dir', mtimeMs: target.mtimeMs })
114 }
115 }
116 }
117
118 entries.sort(byName)
119 listings.set(dir, entries)
120
121 return entries
122}
123
124/** The rows of the tree: the project folder's entries and those of each open folder. */
125const treeNodes = async ($: EngineInterface, expanded: ReadonlySet<string>): Promise<Node[]> => {
126 const out: Node[] = []
127 // Folders wait here to be read; the newest is read first, so rows come out in tree order.
128 const waiting: Array<{ dir: string; depth: number }> = [{ dir: '', depth: 0 }]
129 const rowsOf = new Map<string, Node[]>()
130
131 while (waiting.length > 0) {
132 const { dir, depth } = waiting.pop() ?? { dir: '', depth: 0 }
133 const entries = await list($, dir)
134 const rows: Node[] = []
135
136 if (entries.length === 0 && dir !== '') {
137 rows.push({ rel: `${dir}/`, name: '', depth, isDir: false, isOpen: false, isEmpty: true })
138 }
139
140 for (const entry of entries) {
141 const rel = dir === '' ? entry.name : `${dir}/${entry.name}`
142 const isOpen = entry.isDir && expanded.has(rel) && depth < 24
143
144 rows.push({ rel, name: entry.name, depth, isDir: entry.isDir, isOpen, isEmpty: false })
145
146 if (isOpen) {
147 waiting.push({ dir: rel, depth: depth + 1 })
148 }
149 }
150
151 rowsOf.set(dir, rows)
152 }
153
154 const place = (dir: string): void => {
155 for (const row of rowsOf.get(dir) ?? []) {
156 out.push(row)
157
158 if (row.isOpen) {
159 place(row.rel)
160 }
161 }
162 }
163
164 place('')
165
166 return out
167}
168
169const pixels = async ($: EngineInterface, file: string): Promise<{ width: number; height: number } | undefined> => {
170 const info = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeoutMs: 8000 })
171 const width = Number(/pixelWidth:\s*(\d+)/.exec(info.stdout)?.[1])
172 const height = Number(/pixelHeight:\s*(\d+)/.exec(info.stdout)?.[1])
173
174 return width > 0 && height > 0 ? { width, height } : undefined
175}
176
177/**
178 * A PNG of the file in the temp folder, made by macOS: `sips` for a picture,
179 * Quick Look for a document's first page. The terminal reads the PNG itself.
180 */
181const preview = async (
182 $: EngineInterface,
183 abs: string,
184 isImage: boolean,
185 stamp: string,
186): Promise<{ file: string; width: number; height: number } | undefined> => {
187 try {
188 const temp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
189 const dir = `${temp}/folio-${hash(`${abs}|${stamp}`)}`
190 const out = `${dir}/preview.png`
191
192 if (!(await $.fs.exists(out))) {
193 await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5000 })
194
195 let isMade = false
196
197 if (isImage) {
198 const size = await pixels($, abs)
199 const fit = size !== undefined && Math.max(size.width, size.height) > 1600 ? ['-Z', '1600'] : []
200 const made = await $.process.run(['sips', '-s', 'format', 'png', ...fit, abs, '--out', out], { timeoutMs: 20_000 })
201
202 isMade = made.exitCode === 0 && (await $.fs.exists(out))
203 }
204
205 if (!isMade) {
206 await $.process.run(['qlmanage', '-t', '-s', '1600', '-o', dir, abs], { timeoutMs: 30_000 })
207
208 const made = `${dir}/${baseOf(abs)}.png`
209
210 if (await $.fs.exists(made)) {
211 await $.process.run(['mv', made, out], { timeoutMs: 5000 })
212 isMade = true
213 }
214 }
215
216 if (!isMade) {
217 return undefined
218 }
219 }
220
221 const size = await pixels($, out)
222
223 return size === undefined ? undefined : { file: out, ...size }
224 } catch {
225 return undefined
226 }
227}
228
229/** One file as the reader shows it. Kept until `forget`. */
230const loadDoc = async ($: EngineInterface, request: DocRequest): Promise<Doc> => {
231 const key = [request.path, request.isSource, request.isRevealed, request.columns, request.measure, request.rows].join('|')
232 const known = docs.get(key)
233
234 if (known !== undefined) {
235 return known
236 }
237
238 const abs = `${request.root}/${request.path}`
239 const stat = await $.fs.stat(abs).catch(() => undefined)
240 let doc: Doc
241
242 if (stat === undefined || stat.kind !== 'file') {
243 doc = goneDoc(request)
244 } else {
245 const plan = planOf(request)
246
247 if (plan === 'locked') {
248 doc = lockedDoc(request, stat)
249 } else if (plan === 'picture') {
250 const made = await preview($, abs, kindOf(baseOf(request.path)) === 'image', `${stat.mtimeMs}|${stat.size}`)
251
252 doc = made === undefined ? opaqueDoc(request, stat, 'No preview for this file here.') : pictureDoc(request, stat, made)
253 } else if (plan === 'opaque') {
254 doc = opaqueDoc(request, stat, 'This file is not text.')
255 } else if (stat.size > MAX_TEXT_BYTES) {
256 doc = opaqueDoc(request, stat, 'This file is too large to show here.')
257 } else {
258 const text = await $.fs.read(abs).catch(() => undefined)
259
260 doc = text === undefined ? notice(request, stat, baseOf(request.path), ['This file cannot be read.']) : textDoc(request, stat, text)
261 }
262 }
263
264 docs.set(key, doc)
265
266 return doc
267}
268
269/** A file to bring into view in the tree at the next drawing. */
270let reveal = ''
271
272/** Opens a path of the project: a file in the reader, a folder in the tree. */
273const show = async ($: EngineInterface, path: string): Promise<boolean> => {
274 const base = await rootOf($)
275 const stat = await $.fs.stat(`${base}/${path}`).catch(() => undefined)
276
277 if (path === '' || stat === undefined || stat.kind === 'other') {
278 return false
279 }
280
281 const isDir = stat.kind === 'dir'
282 const open = isDir ? [...ancestors(path), path] : ancestors(path)
283
284 reveal = path
285 await update($, ui, state => ({
286 ...state,
287 expanded: [...new Set([...state.expanded, ...open])],
288 ...(isDir ? {} : { selected: path, docTop: 0 }),
289 }))
290 await keep($)
291
292 return true
293}
294
295/** When the turn that runs now began; 0 between turns. */
296let turnBegan = 0
297
298/**
299 * Marks the files that changed on disk while Claude's turn ran: what a shell
300 * command wrote, which no file tool reports. Only the folders open in the tree
301 * are looked at.
302 */
303const noteTurn = async ($: EngineInterface): Promise<void> => {
304 try {
305 const began = turnBegan
306 const { value } = await $.state.get({ plugin: 'folio', key: 'ui' } as const)
307
308 turnBegan = 0
309 forget()
310
311 if (began > 0 && value !== undefined) {
312 await rootOf($)
313
314 const changed: string[] = []
315
316 for (const dir of ['', ...value.expanded].slice(0, 60)) {
317 for (const entry of await list($, dir)) {
318 if (!entry.isDir && entry.mtimeMs >= began - 1000) {
319 changed.push(dir === '' ? entry.name : `${dir}/${entry.name}`)
320 }
321 }
322 }
323
324 if (changed.length > 0) {
325 await update($, touched, known => [...new Set([...known, ...changed])].slice(-300))
326 }
327 }
328
329 await update($, ui, state => ({ ...state, tick: state.tick + 1 }))
330 } catch {
331 // The pane's bookkeeping never stands in a turn's way.
332 }
333}
334
335/** Marks a file Claude wrote, and has the pane read the disk again. */
336const noteWrite = async ($: EngineInterface, file: unknown): Promise<void> => {
337 try {
338 const base = await rootOf($)
339
340 if (typeof file === 'string' && file.startsWith(`${base}/`)) {
341 const path = file.slice(base.length + 1)
342
343 await update($, touched, list => (list.includes(path) ? list : [...list, path].slice(-300)))
344 }
345
346 await update($, ui, state => ({ ...state, tick: state.tick + 1 }))
347 } catch {
348 // The pane's bookkeeping never stands in a tool's way.
349 }
350}
351
352export const register: Register = on => {
353 let seenTick = -1
354 /**
355 * True when another copy of this mod runs in the session (one under
356 * development beside an installed one). That copy does the work; this one
357 * passes every event on.
358 */
359 let isIdle = false
360 /** Where the lists stood at the last drawing; the wheel moves them from here. */
361 const drawn = { isSplit: false, isReading: false, treeFrom: 0, treeTo: 0, treeTop: 0, treeMax: 0, docTop: 0, docMax: 0 }
362
363 on('session.start', async ($, e, next) => {
364 // The first copy to start in a session writes its name here; a second copy reads it and stays idle.
365 const session = await $.session.id().catch(() => '')
366 const mine = `${session}|${$.plugin.root}`
367 const home = (await $.env.get('FOLIO_HOME').catch(() => undefined)) ?? ''
368
369 isIdle = home.startsWith(`${session}|`) && home !== mine
370
371 if (isIdle) {
372 return next(e)
373 }
374
375 await $.env.set('FOLIO_HOME', mine).catch(() => undefined)
376 await $.command.register({
377 name: 'files',
378 description: 'Browse and read the files of this project in a pane',
379 argumentHint: '[path]',
380 })
381
382 const base = await rootOf($)
383 const saved = (await $.store.get(`ui:${base}`).catch(() => undefined)) as Partial<FolioUi> | undefined
384 const side = await $.store.get('side').catch(() => undefined)
385
386 await update($, ui, (state): FolioUi =>
387 state.isRestored
388 ? state
389 : {
390 ...state,
391 isRestored: true,
392 expanded: Array.isArray(saved?.expanded) ? saved.expanded.filter((path): path is string => typeof path === 'string') : state.expanded,
393 selected: typeof saved?.selected === 'string' ? saved.selected : state.selected,
394 mode: saved?.mode === 'source' ? 'source' : 'read',
395 side: side === 'left' ? 'left' : 'right',
396 },
397 )
398
399 // The pane comes back where the person left it open.
400 if ((await $.store.get(`open:${base}`).catch(() => undefined)) === true) {
401 void $.ui.open({ id: PANE, title: TITLE })
402 }
403
404 return next(e)
405 })
406
407 on('command.run', { command: 'files' }, async ($, e, next) => {
408 if (isIdle) {
409 return next(e)
410 }
411
412 const base = await rootOf($)
413 const asked = e.args.trim().replace(/^@/, '')
414 const state = await read($, ui)
415
416 if (asked !== '') {
417 if (!(await show($, resolve('', asked)))) {
418 return { text: `No file or folder at ${asked} in this project.` }
419 }
420 } else if (state.selected === '') {
421 for (const name of FIRST_FILES) {
422 if (await $.fs.exists(`${base}/${name}`)) {
423 await show($, name)
424 break
425 }
426 }
427 }
428
429 const columns = clamp(Math.round(e.presentation.columns * 0.52), 60, 124)
430 const opened = await $.ui.open({ id: PANE, title: TITLE, columns, rows: 28 })
431
432 await $.store.set(`open:${base}`, true)
433
434 return opened.isPlaced ? {} : { text: `The files pane is open but waits for room: ${opened.reason}` }
435 })
436
437 on('ui.close', { id: PANE }, async ($, e, next) => {
438 if (!isIdle && e.origin.kind === 'person') {
439 await $.store.set(`open:${await rootOf($)}`, false)
440 }
441
442 return next(e)
443 })
444
445 // The pane keeps its own windows over the tree and the document, so the
446 // header and the tree stay put while the document moves.
447 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
448 if (isIdle) {
449 return next(e)
450 }
451
452 const column = e.pointer?.column
453 const isOverTree = drawn.isSplit ? column !== undefined && column >= drawn.treeFrom && column <= drawn.treeTo : !drawn.isReading
454
455 await update($, ui, state =>
456 isOverTree
457 ? { ...state, treeTop: clamp(drawn.treeTop + e.by, 0, drawn.treeMax) }
458 : { ...state, docTop: clamp(drawn.docTop + e.by, 0, drawn.docMax) },
459 )
460
461 return { deny: 'folio moves its own rows' }
462 }).catch(() => ({ deny: 'folio moves its own rows' }))
463
464 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
465 const result = await next(e)
466
467 if (!isIdle) {
468 await noteWrite($, e.file_path)
469 }
470
471 return result
472 }).catch(($, e, next) => next(e))
473
474 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
475 const result = await next(e)
476
477 if (!isIdle) {
478 await noteWrite($, e.file_path)
479 }
480
481 return result
482 }).catch(($, e, next) => next(e))
483
484 on('turn.start', async ($, e, next) => {
485 turnBegan = await $.clock.now().catch(() => 0)
486
487 return next(e)
488 })
489
490 on('turn.complete', async ($, e, next) => {
491 if (e.agentId === undefined && !isIdle) {
492 await noteTurn($)
493 }
494
495 return next(e)
496 })
497
498 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
499 if (isIdle) {
500 return next(e)
501 }
502
503 const el = $.ui.resolve(e)
504 const { Box, Text } = el
505
506 try {
507 const base = await rootOf($)
508 const state = await read($, ui)
509 const written = await read($, touched)
510
511 if (state.tick !== seenTick) {
512 forget()
513 seenTick = state.tick
514 }
515
516 const columns = Math.max(24, e.props.bodyColumns)
517 const rows = Math.max(8, e.props.scroll.bodyRows)
518 const isSplit = columns >= SPLIT_FROM
519 const treeWidth = isSplit ? clamp(Math.round(columns * 0.29), 26, 38) : columns
520 const isTreeRight = isSplit && state.side === 'right'
521 const frame: Frame = {
522 columns,
523 rows,
524 isSplit,
525 isTreeRight,
526 treeWidth,
527 // Beside the text: the tree, the line between them and the two thumbs, or one margin and a thumb.
528 textWidth: Math.max(8, isSplit ? columns - treeWidth - 5 : columns - 3),
529 treeRows: rows - 2,
530 docRows: rows - 5,
531 }
532 const nodes = await treeNodes($, new Set(state.expanded))
533 const treeMax = Math.max(0, nodes.length - frame.treeRows)
534 let treeTop = clamp(state.treeTop, 0, treeMax)
535
536 if (reveal !== '') {
537 const at = nodes.findIndex(node => node.rel === reveal)
538
539 if (at !== -1 && (at < treeTop || at >= treeTop + frame.treeRows)) {
540 treeTop = clamp(at - Math.floor(frame.treeRows / 3), 0, treeMax)
541 }
542
543 reveal = ''
544 }
545
546 const isSource = state.mode === 'source' && hasTwoForms(baseOf(state.selected))
547 const doc =
548 state.selected === ''
549 ? undefined
550 : await loadDoc($, {
551 root: base,
552 path: state.selected,
553 isSource,
554 isRevealed: state.revealed.includes(state.selected),
555 columns: frame.textWidth,
556 measure: MEASURE,
557 rows: frame.docRows,
558 now: await $.clock.now(),
559 })
560 const docMax = doc === undefined || doc.picture !== undefined ? 0 : Math.max(0, doc.rows.length - frame.docRows)
561 const docTop = clamp(state.docTop, 0, docMax)
562
563 Object.assign(drawn, {
564 isSplit,
565 isReading: state.selected !== '',
566 treeFrom: isTreeRight ? columns - treeWidth - 1 : 0,
567 treeTo: isTreeRight ? columns : treeWidth,
568 treeTop,
569 treeMax,
570 docTop,
571 docMax,
572 })
573
574 const change = (step: (state: FolioUi) => FolioUi) => {
575 void update($, ui, step).then(() => keep($))
576 }
577 const actions: Actions = {
578 toggle: path =>
579 change(now => ({
580 ...now,
581 expanded: now.expanded.includes(path) ? now.expanded.filter(open => open !== path) : [...now.expanded, path],
582 })),
583 open: path => change(now => ({ ...now, selected: path, docTop: 0 })),
584 back: () => change(now => ({ ...now, selected: '' })),
585 mode: (mode: FolioMode) => change(now => ({ ...now, mode, docTop: 0 })),
586 reveal: () => change(now => ({ ...now, revealed: [...now.revealed, state.selected] })),
587 mention: () => {
588 void $.prompt.fill({ text: `@${state.selected} `, mode: 'insert' })
589 },
590 outside: () => {
591 void $.process.run(['open', `${base}/${state.selected}`], { timeoutMs: 10_000 }).catch(() => undefined)
592 },
593 swap: () => {
594 void update($, ui, (now): FolioUi => ({ ...now, side: now.side === 'right' ? 'left' : 'right' })).then(now => $.store.set('side', now.side))
595 },
596 follow: href => {
597 const path = resolve(dirOf(state.selected), href)
598
599 void show($, path).then(isShown => {
600 if (!isShown) {
601 $.ui.toast(`${path} is not in this project`)
602 }
603 })
604 },
605 }
606
607 return drawPane(e.surface === 'terminal' ? $.ui.resolve(e) : el, {
608 frame,
609 title: baseOf(base) || base,
610 nodes,
611 treeTop,
612 selected: state.selected,
613 touched: new Set(written),
614 touchedFolders: new Set(written.flatMap(path => ancestors(path))),
615 doc,
616 docTop,
617 mode: isSource ? 'source' : 'read',
618 on: actions,
619 })
620 } catch (error) {
621 return (
622 <Box flexDirection="column">
623 <Text bold>The files pane could not draw.</Text>
624 <Text dimColor>{String(error instanceof Error ? error.message : error).slice(0, 300)}</Text>
625 </Box>
626 )
627 }
628 })
629}
630hooks/files.ts 164 lines1// What a file is, by its name: how the pane shows it, and the words for its
2// size and age.
3
4export type Kind = 'markdown' | 'table' | 'code' | 'image' | 'look' | 'binary'
5
6const MARKDOWN = new Set(['md', 'mdx', 'markdown'])
7const TABLE = new Set(['csv', 'tsv'])
8const IMAGE = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'heif', 'ico', 'avif'])
9/** Files macOS Quick Look can draw a first page of. */
10const LOOK = new Set(['pdf', 'svg', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt', 'key', 'pages', 'numbers', 'mp4', 'mov', 'm4v', 'psd', 'ai', 'eps'])
11const BINARY = new Set([
12 'zip', 'gz', 'tar', 'tgz', 'bz2', 'xz', '7z', 'rar', 'dmg', 'pkg', 'exe', 'dll', 'so', 'dylib', 'o', 'a', 'class', 'jar', 'wasm',
13 'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3', 'wav', 'm4a', 'flac', 'ogg', 'sqlite', 'db', 'bundle', 'bin', 'icns', 'lockb', 'pyc', 'node',
14])
15
16/** Folders and files that are noise in a project tree. */
17const HIDDEN = new Set(['.git', '.DS_Store', 'node_modules', '__pycache__', '.Trash', 'Thumbs.db'])
18
19const SECRET = [/^\.env(\..+)?$/i, /\.(pem|key|p12|pfx|keystore|jks)$/i, /^id_(rsa|dsa|ecdsa|ed25519)/i, /^\.(npmrc|netrc|pgpass)$/i, /(^|[._-])(secret|secrets|credential|credentials)([._-]|$)/i, /^auth\.json$/i, /recovery[_-]?codes/i]
20
21const LABELS: Record<string, string> = {
22 md: 'Markdown', mdx: 'MDX', ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
23 json: 'JSON', jsonl: 'JSON lines', yml: 'YAML', yaml: 'YAML', toml: 'TOML', py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust',
24 sh: 'Shell', zsh: 'Shell', bash: 'Shell', css: 'CSS', scss: 'SCSS', html: 'HTML', xml: 'XML', sql: 'SQL', txt: 'Text', log: 'Log',
25 csv: 'CSV', tsv: 'TSV', pdf: 'PDF', svg: 'SVG', png: 'PNG image', jpg: 'JPEG image', jpeg: 'JPEG image', gif: 'GIF image',
26 webp: 'WebP image', heic: 'HEIC image', docx: 'Word document', xlsx: 'Excel workbook', pptx: 'PowerPoint deck', mp4: 'Video',
27 mov: 'Video', zip: 'Zip archive', swift: 'Swift', kt: 'Kotlin', java: 'Java', c: 'C', h: 'C header', cpp: 'C++', lock: 'Lock file',
28}
29
30export const extOf = (name: string): string => {
31 const dot = name.lastIndexOf('.')
32
33 return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
34}
35
36export const baseOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
37
38export const dirOf = (path: string): string => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')
39
40export const kindOf = (name: string): Kind => {
41 const ext = extOf(name)
42
43 if (MARKDOWN.has(ext)) {
44 return 'markdown'
45 }
46
47 if (TABLE.has(ext)) {
48 return 'table'
49 }
50
51 if (IMAGE.has(ext)) {
52 return 'image'
53 }
54
55 if (LOOK.has(ext)) {
56 return 'look'
57 }
58
59 return BINARY.has(ext) ? 'binary' : 'code'
60}
61
62export const labelOf = (name: string): string => {
63 const ext = extOf(name)
64
65 return LABELS[ext] ?? (ext === '' ? 'File' : ext.toUpperCase())
66}
67
68export const isHidden = (name: string): boolean => HIDDEN.has(name)
69
70/** A file whose text should not show on a shared screen until the person asks. */
71export const isSecret = (name: string): boolean => SECRET.some(pattern => pattern.test(name))
72
73export const sizeText = (bytes: number): string => {
74 if (bytes < 1000) {
75 return `${bytes} B`
76 }
77
78 if (bytes < 1_000_000) {
79 return `${(bytes / 1000).toFixed(bytes < 10_000 ? 1 : 0)} KB`
80 }
81
82 return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`
83}
84
85const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const
86
87export const ageText = (mtimeMs: number, now: number): string => {
88 const minutes = Math.floor((now - mtimeMs) / 60_000)
89
90 if (minutes < 1) {
91 return 'just now'
92 }
93
94 if (minutes < 60) {
95 return `${minutes}m ago`
96 }
97
98 if (minutes < 60 * 24) {
99 return `${Math.floor(minutes / 60)}h ago`
100 }
101
102 if (minutes < 60 * 24 * 7) {
103 return `${Math.floor(minutes / (60 * 24))}d ago`
104 }
105
106 const date = new Date(mtimeMs)
107 const year = date.getFullYear() === new Date(now).getFullYear() ? '' : ` ${date.getFullYear()}`
108
109 return `${date.getDate()} ${MONTHS[date.getMonth()] ?? ''}${year}`
110}
111
112/** Comma or tab separated text to rows of cells. */
113export const parseTable = (text: string, separator: string, limit: number): string[][] => {
114 const rows: string[][] = []
115 let row: string[] = []
116 let cell = ''
117 let isQuoted = false
118
119 for (let i = 0; i < text.length && rows.length < limit; i += 1) {
120 const ch = text[i] ?? ''
121
122 if (isQuoted) {
123 if (ch === '"' && text[i + 1] === '"') {
124 cell += '"'
125 i += 1
126 } else if (ch === '"') {
127 isQuoted = false
128 } else {
129 cell += ch
130 }
131 } else if (ch === '"' && cell === '') {
132 isQuoted = true
133 } else if (ch === separator) {
134 row.push(cell)
135 cell = ''
136 } else if (ch === '\n' || ch === '\r') {
137 if (ch === '\r' && text[i + 1] === '\n') {
138 i += 1
139 }
140
141 row.push(cell)
142 rows.push(row)
143 row = []
144 cell = ''
145 } else {
146 cell += ch
147 }
148 }
149
150 if ((cell !== '' || row.length > 0) && rows.length < limit) {
151 row.push(cell)
152 rows.push(row)
153 }
154
155 return rows.filter(cells => cells.some(value => value.trim() !== ''))
156}
157
158/** True when the file has a formatted form and a source form to switch between. */
159export const hasTwoForms = (name: string): boolean => {
160 const kind = kindOf(name)
161
162 return kind === 'markdown' || kind === 'table' || extOf(name) === 'svg'
163}
164hooks/load.ts 231 lines1// A file's content as the rows the reader shows. Nothing here reads the disk:
2// the hooks module reads, and hands the text or the picture's size to these.
3import { ageText, baseOf, isSecret, kindOf, labelOf, parseTable, sizeText } from './files'
4import type { Kind } from './files'
5import { renderMarkdown, tableRows } from './markdown'
6import type { Doc, Picture } from './model'
7import { clean, spansWidth } from './text'
8import type { Row, Span } from './text'
9import { C } from './theme'
10
11export type Entry = { name: string; isDir: boolean; mtimeMs: number }
12
13export const MAX_TEXT_BYTES = 3_500_000
14const MAX_LINES = 20_000
15const MAX_LINE_CHARS = 2_000
16const MAX_TABLE_ROWS = 1_000
17/** A terminal cell is about this many times taller than it is wide. */
18const CELL_SHAPE = 2.1
19const CELL_PIXELS = 8
20
21export type DocRequest = {
22 root: string
23 path: string
24 isSource: boolean
25 isRevealed: boolean
26 /** Cells across the reader's text. */
27 columns: number
28 /** The widest a line of prose may run. */
29 measure: number
30 /** Rows the reader shows at once. */
31 rows: number
32 now: number
33}
34
35export type FileFacts = { size: number; mtimeMs: number }
36
37/** Folders first, names that start with a dot last, then by name. */
38export const byName = (a: Entry, b: Entry): number => {
39 if (a.isDir !== b.isDir) {
40 return a.isDir ? -1 : 1
41 }
42
43 const aDot = a.name.startsWith('.')
44 const bDot = b.name.startsWith('.')
45
46 if (aDot !== bDot) {
47 return aDot ? 1 : -1
48 }
49
50 const x = a.name.toLowerCase()
51 const y = b.name.toLowerCase()
52
53 return x < y ? -1 : x > y ? 1 : 0
54}
55
56/** Short lines set in the middle of the reader. */
57const card = (cols: number, lines: readonly Span[][]): Row[] =>
58 lines.map(spans => ({ spans: [{ text: ' '.repeat(Math.max(0, Math.floor((cols - spansWidth(spans)) / 2))) }, ...spans] }))
59
60const about = (request: DocRequest, facts: FileFacts): string[] => [sizeText(facts.size), ageText(facts.mtimeMs, request.now)]
61
62/** A short notice in place of a file's content. */
63export const notice = (request: DocRequest, facts: FileFacts | undefined, title: string, lines: readonly string[]): Doc => {
64 const name = baseOf(request.path)
65
66 return {
67 path: request.path,
68 kind: kindOf(name),
69 meta: facts === undefined ? [] : [labelOf(name), ...about(request, facts)],
70 canToggle: false,
71 isCard: true,
72 isLocked: false,
73 rows: card(request.columns, [[{ text: title, bold: true }], [], ...lines.map(text => [{ text, color: C.muted }])]),
74 }
75}
76
77export const goneDoc = (request: DocRequest): Doc => notice(request, undefined, baseOf(request.path), ['This file is not on disk any more.'])
78
79export const opaqueDoc = (request: DocRequest, facts: FileFacts, why: string): Doc =>
80 notice(request, facts, baseOf(request.path), [why, 'Open shows it in its own app.'])
81
82export const lockedDoc = (request: DocRequest, facts: FileFacts): Doc => ({
83 ...notice(request, facts, 'Kept off the screen', ['This file can hold secrets.', 'Its text stays hidden until you ask for it.']),
84 isLocked: true,
85})
86
87/** How the reader shows the file: what the hooks module has to read for it. */
88export const planOf = (request: DocRequest): 'locked' | 'picture' | 'opaque' | 'text' => {
89 const name = baseOf(request.path)
90 const kind = kindOf(name)
91
92 if (isSecret(name) && !request.isRevealed) {
93 return 'locked'
94 }
95
96 if (kind === 'image' || (kind === 'look' && !request.isSource)) {
97 return 'picture'
98 }
99
100 return kind === 'binary' || kind === 'look' ? (name.toLowerCase().endsWith('.svg') ? 'text' : 'opaque') : 'text'
101}
102
103export const hash = (text: string): string => {
104 let h = 0x811c9dc5
105
106 for (let i = 0; i < text.length; i += 1) {
107 h ^= text.charCodeAt(i)
108 h = Math.imul(h, 0x01000193) >>> 0
109 }
110
111 return h.toString(16).padStart(8, '0')
112}
113
114const fitPicture = (width: number, height: number, maxColumns: number, maxRows: number): { columns: number; rows: number } => {
115 let columns = Math.min(maxColumns, Math.max(4, Math.round(width / CELL_PIXELS)))
116 let rows = Math.max(1, Math.round((columns * height) / width / CELL_SHAPE))
117
118 if (rows > maxRows) {
119 rows = Math.max(1, maxRows)
120 columns = Math.max(1, Math.min(maxColumns, Math.round((rows * CELL_SHAPE * width) / height)))
121 }
122
123 return { columns: Math.min(255, columns), rows: Math.min(255, rows) }
124}
125
126export const pictureDoc = (request: DocRequest, facts: FileFacts, made: { file: string; width: number; height: number }): Doc => {
127 const name = baseOf(request.path)
128 const kind = kindOf(name)
129 const fit = fitPicture(made.width, made.height, request.columns, Math.max(1, request.rows - 1))
130 const picture: Picture = { file: made.file, ...fit, generation: Math.round(facts.mtimeMs), alt: `${name} (picture)` }
131
132 return {
133 path: request.path,
134 kind,
135 rows: [],
136 meta: [labelOf(name), ...(kind === 'image' ? [`${made.width} × ${made.height}`] : []), ...about(request, facts)],
137 canToggle: name.toLowerCase().endsWith('.svg'),
138 isCard: false,
139 isLocked: false,
140 picture,
141 }
142}
143
144const looksBinary = (text: string): boolean => {
145 const sample = text.slice(0, 8000)
146
147 if (sample.includes('\u0000')) {
148 return true
149 }
150
151 let bad = 0
152
153 for (const ch of sample) {
154 if (ch === '�') {
155 bad += 1
156 }
157 }
158
159 return bad > 8 && bad > sample.length / 200
160}
161
162const sourceRows = (text: string): { rows: Row[]; lines: number; isCut: boolean } => {
163 const all = clean(text).replace(/\n$/, '').split('\n')
164 const lines = all.slice(0, MAX_LINES)
165
166 return {
167 lines: all.length,
168 isCut: all.length > MAX_LINES,
169 rows: lines.map((line, n) => ({
170 spans: [],
171 code: { line: line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) : line, block: 0, number: n + 1 },
172 })),
173 }
174}
175
176/** A text file as a document: markdown set as prose, a table as columns, the rest as code. */
177export const textDoc = (request: DocRequest, facts: FileFacts, text: string): Doc => {
178 const { path, columns } = request
179 const name = baseOf(path)
180 const kind: Kind = kindOf(name)
181
182 if (looksBinary(text)) {
183 return opaqueDoc(request, facts, 'This file is not text.')
184 }
185
186 const source = sourceRows(text)
187 const lines = `${source.lines} ${source.lines === 1 ? 'line' : 'lines'}`
188 const meta = (length: string): string[] => [labelOf(name), length, ...about(request, facts)]
189
190 if (kind === 'markdown' && !request.isSource) {
191 return {
192 path,
193 kind,
194 rows: renderMarkdown(text, Math.min(columns, request.measure)),
195 meta: meta(lines),
196 canToggle: true,
197 isCard: false,
198 isLocked: false,
199 }
200 }
201
202 if (kind === 'table' && !request.isSource) {
203 const cells = parseTable(text, name.toLowerCase().endsWith('.tsv') ? '\t' : ',', MAX_TABLE_ROWS + 1)
204 const toSpans = (value: string, isHead: boolean): Span[] => [
205 { text: clean(value).replace(/\n/g, ' ').trim(), ...(isHead ? { bold: true } : {}) },
206 ]
207 const head = (cells[0] ?? []).map(value => toSpans(value, true))
208 const body = cells.slice(1).map(row => row.map(value => toSpans(value, false)))
209
210 return {
211 path,
212 kind,
213 rows: cells.length === 0 ? card(columns, [[{ text: 'This table is empty.', color: C.muted }]]) : tableRows(head, [], body, columns),
214 meta: meta(`${Math.max(0, cells.length - 1)} rows`),
215 canToggle: true,
216 isCard: cells.length === 0,
217 isLocked: false,
218 }
219 }
220
221 return {
222 path,
223 kind: kind === 'markdown' || kind === 'table' || kind === 'look' ? kind : 'code',
224 rows: source.rows,
225 meta: meta(source.isCut ? `first ${MAX_LINES} of ${lines}` : lines),
226 canToggle: kind === 'markdown' || kind === 'table' || kind === 'look',
227 isCard: false,
228 isLocked: false,
229 }
230}
231hooks/model.ts 47 lines1import type { Kind } from './files'
2import type { Row } from './text'
3
4/** One row of the file tree. */
5export type Node = {
6 rel: string
7 name: string
8 depth: number
9 isDir: boolean
10 isOpen: boolean
11 /** A stand-in row under an open folder that holds nothing. */
12 isEmpty: boolean
13}
14
15/** A picture the terminal reads from disk itself. */
16export type Picture = { file: string; columns: number; rows: number; generation: number; alt: string }
17
18/** A file as the reader shows it. */
19export type Doc = {
20 path: string
21 kind: Kind
22 rows: Row[]
23 /** The words of the footer: kind, length, size, age. */
24 meta: string[]
25 /** True when the file has a formatted form and a source form. */
26 canToggle: boolean
27 /** True for a short notice, drawn in the middle of the reader. */
28 isCard: boolean
29 /** True while the file's text is held back. */
30 isLocked: boolean
31 picture?: Picture
32}
33
34/** The pane's measures for one drawing, in cells. */
35export type Frame = {
36 columns: number
37 rows: number
38 isSplit: boolean
39 /** True when the tree is at the pane's right edge, the document beside the chat. */
40 isTreeRight: boolean
41 treeWidth: number
42 /** Cells across the document's text. */
43 textWidth: number
44 treeRows: number
45 docRows: number
46}
47hooks/view.tsx 472 lines1// Draws the pane: the file tree, the reader, and the thin marks between them.
2// Every element here is one terminal row tall unless it says its height.
3import type { Elements, RenderElement } from 'claude-code'
4
5import type { FolioMode } from '../types'
6import { baseOf } from './files'
7import type { Doc, Frame, Node } from './model'
8import { fitSpans, pad, spansWidth, truncate, truncateMiddle, width } from './text'
9import type { Row, Span } from './text'
10import { C } from './theme'
11
12type Terminal = Elements['terminal']
13
14export type El = Pick<Terminal, 'Box' | 'Text' | 'Button' | 'Code' | 'Link'> & { Image?: Terminal['Image'] }
15
16export type Actions = {
17 toggle: (path: string) => void
18 open: (path: string) => void
19 back: () => void
20 mode: (mode: FolioMode) => void
21 reveal: () => void
22 mention: () => void
23 outside: () => void
24 follow: (href: string) => void
25 swap: () => void
26}
27
28export type PaneProps = {
29 frame: Frame
30 title: string
31 nodes: readonly Node[]
32 treeTop: number
33 selected: string
34 touched: ReadonlySet<string>
35 touchedFolders: ReadonlySet<string>
36 doc: Doc | undefined
37 docTop: number
38 mode: FolioMode
39 on: Actions
40}
41
42const SOFT = { inverse: false, backgroundColor: C.fill } as const
43const BRIGHT = { inverse: false, dimColor: false } as const
44
45const piece = (el: El, span: Span): RenderElement => {
46 const { Text, Link } = el
47 const style = {
48 ...(span.bold === true ? { bold: true } : {}),
49 ...(span.italic === true ? { italic: true } : {}),
50 ...(span.underline === true ? { underline: true } : {}),
51 ...(span.strike === true ? { strikethrough: true } : {}),
52 ...(span.dim === true ? { dimColor: true } : {}),
53 ...(span.color === undefined ? {} : { color: span.color }),
54 ...(span.bg === undefined ? {} : { backgroundColor: span.bg }),
55 }
56
57 // A bare address is left as text: the terminal finds it, and it never shows twice.
58 if (span.href !== undefined && /^https?:\/\//.test(span.href) && span.href.length < 2000 && span.text !== span.href) {
59 return (
60 <Link href={span.href}>
61 <Text {...style}>{span.text}</Text>
62 </Link>
63 )
64 }
65
66 return <Text {...style}>{span.text}</Text>
67}
68
69/** One row of styled text, cut to `cells`. */
70const line = (el: El, spans: readonly Span[], cells: number): RenderElement => {
71 const { Text } = el
72 const fitted = fitSpans(spans.filter(span => span.text !== ''), cells)
73
74 if (fitted.length === 0) {
75 return <Text> </Text>
76 }
77
78 return <Text wrap="truncate-end">{fitted.map(span => piece(el, span))}</Text>
79}
80
81const empty = (el: El): RenderElement => <el.Text> </el.Text>
82
83// --- the tree -----------------------------------------------------------------
84
85const treeRow = (el: El, props: PaneProps, node: Node, cells: number): RenderElement => {
86 const { Box, Text, Button } = el
87 const indent = ' '.repeat(Math.min(node.depth, 12))
88
89 if (node.isEmpty) {
90 return (
91 <Text color={C.faint} wrap="truncate-end">
92 {` ${indent}empty`}
93 </Text>
94 )
95 }
96
97 const isOpenFile = !node.isDir && node.rel === props.selected
98 const room = Math.max(4, cells - 4 - indent.length)
99 const name = pad(truncateMiddle(node.name, room), room)
100 const mark = node.isDir ? (node.isOpen ? '▾ ' : '▸ ') : ' '
101 const dot = props.touched.has(node.rel) ? '●' : node.isDir && !node.isOpen && props.touchedFolders.has(node.rel) ? '•' : ' '
102 const isQuiet = node.name.startsWith('.')
103
104 return (
105 <Box key={`row:${node.rel}`} width={cells} {...(isOpenFile ? { backgroundColor: C.fill } : {})}>
106 <Button
107 key={`node:${node.rel}`}
108 plain
109 hover={SOFT}
110 onPress={() => (node.isDir ? props.on.toggle(node.rel) : props.on.open(node.rel))}
111 >
112 {isOpenFile ? <Text color={C.accent}>▎</Text> : ' '}
113 {indent}
114 {node.isDir ? <Text color={C.muted}>{mark}</Text> : mark}
115 <Text {...(isOpenFile ? { bold: true } : {})} {...(isQuiet && !isOpenFile ? { color: C.muted } : {})}>
116 {name}
117 </Text>
118 {dot === ' ' ? ' ' : <Text color={C.touched}>{dot}</Text>}
119 </Button>
120 </Box>
121 )
122}
123
124const tree = (el: El, props: PaneProps): RenderElement => {
125 const { Box, Text, Button } = el
126 const { frame } = props
127 const cells = frame.treeWidth
128 const shown = props.nodes.slice(props.treeTop, props.treeTop + frame.treeRows)
129
130 return (
131 <Box flexDirection="column" width={cells} height={frame.rows} overflow="hidden">
132 <Box key="tree-head" flexDirection="row" width={cells} height={1} overflow="hidden">
133 <Box width={Math.max(1, cells - 3)}>
134 <Text bold wrap="truncate-end">
135 {` ${truncate(props.title, cells - 5)}`}
136 </Text>
137 </Box>
138 {props.frame.isSplit ? (
139 <Button key="swap" plain hover={{ inverse: false, color: C.accent }} onPress={() => props.on.swap()}>
140 <Text color={C.faint}>⇄</Text>
141 </Button>
142 ) : null}
143 </Box>
144 {empty(el)}
145 {shown.map(node => treeRow(el, props, node, cells))}
146 {shown.length === 0 ? <Text color={C.faint}>{' This folder is empty.'}</Text> : null}
147 </Box>
148 )
149}
150
151/**
152 * A one-cell column the height of the pane. Beside the `view` rows that scroll
153 * (after `lead` rows of header) it shows a thumb where the window stands;
154 * `rest` fills every other row: a divider's line, or nothing.
155 */
156const rail = (el: El, rows: number, lead: number, view: number, total: number, top: number, rest: string): RenderElement => {
157 const { Box, Text } = el
158 const marks: RenderElement[] = []
159 const thumb = total > view ? Math.max(1, Math.round((view * view) / total)) : 0
160 const at = total > view ? lead + Math.round((top / (total - view)) * (view - thumb)) : 0
161
162 for (let r = 0; r < rows; r += 1) {
163 const isThumb = thumb > 0 && r >= at && r < at + thumb
164
165 marks.push(<Text color={C.faint}>{isThumb ? '┃' : rest}</Text>)
166 }
167
168 return (
169 <Box flexDirection="column" width={1} height={rows} overflow="hidden">
170 {marks}
171 </Box>
172 )
173}
174
175// --- the reader ---------------------------------------------------------------
176
177const crumbs = (path: string, cells: number): Span[] => {
178 const parts = path.split('/')
179 const name = parts.pop() ?? ''
180 const tail: Span = { text: truncateMiddle(name, Math.max(4, cells)), bold: true }
181 let left = cells - width(tail.text)
182 const head: string[] = []
183
184 for (let k = parts.length - 1; k >= 0; k -= 1) {
185 const part = `${parts[k] ?? ''} › `
186 const more = k > 0 ? 4 : 0
187
188 if (width(part) + more > left) {
189 if (left >= 4) {
190 head.unshift('… › ')
191 }
192
193 break
194 }
195
196 head.unshift(part)
197 left -= width(part)
198 }
199
200 return [{ text: head.join(''), color: C.muted }, tail]
201}
202
203const header = (el: El, props: PaneProps, cells: number): RenderElement => {
204 const { Box, Text, Button } = el
205 const { doc, frame } = props
206 const hasToggle = doc !== undefined && doc.canToggle
207 const toggleCells = hasToggle ? 'Read Source'.length + 2 : 0
208 const backCells = frame.isSplit ? 0 : 3
209 const room = Math.max(4, cells - toggleCells - backCells)
210 const label = (text: string, isOn: boolean) => (isOn ? <Text bold color={C.accent}>{text}</Text> : <Text color={C.muted}>{text}</Text>)
211
212 return (
213 <Box key="head" flexDirection="row" width={cells} height={1} overflow="hidden">
214 {frame.isSplit ? null : (
215 <Button key="back" plain hover={BRIGHT} onPress={() => props.on.back()}>
216 <Text color={C.muted}>{'‹ '}</Text>
217 </Button>
218 )}
219 <Box width={room}>{line(el, crumbs(props.selected, room), room)}</Box>
220 {hasToggle ? <Text>{' '}</Text> : null}
221 {hasToggle ? (
222 <Button key="mode:read" plain hover={{ inverse: false, underline: true }} onPress={() => props.on.mode('read')}>
223 {label('Read', props.mode === 'read')}
224 </Button>
225 ) : null}
226 {hasToggle ? <Text>{' '}</Text> : null}
227 {hasToggle ? (
228 <Button key="mode:source" plain hover={{ inverse: false, underline: true }} onPress={() => props.on.mode('source')}>
229 {label('Source', props.mode === 'source')}
230 </Button>
231 ) : null}
232 </Box>
233 )
234}
235
236const footer = (el: El, props: PaneProps, cells: number): RenderElement => {
237 const { Box, Text, Button } = el
238 const { doc } = props
239 const actions = doc === undefined ? 0 : doc.isLocked ? 'Reveal Mention Open'.length : 'Mention Open'.length
240 const room = Math.max(0, cells - actions - 2)
241 const meta = doc === undefined ? '' : truncate(doc.meta.join(' · '), room)
242 const action = (key: string, text: string, onPress: () => void) => (
243 <Button key={key} plain hover={{ inverse: false, color: C.accent }} onPress={onPress}>
244 <Text color={C.muted}>{text}</Text>
245 </Button>
246 )
247
248 if (doc === undefined) {
249 return empty(el)
250 }
251
252 return (
253 <Box key="foot" flexDirection="row" width={cells} height={1} overflow="hidden">
254 <Box width={room + 2}>
255 <Text color={C.faint} wrap="truncate-end">
256 {meta === '' ? ' ' : meta}
257 </Text>
258 </Box>
259 {doc.isLocked ? action('act:reveal', 'Reveal', props.on.reveal) : null}
260 {doc.isLocked ? <Text>{' '}</Text> : null}
261 {action('act:mention', 'Mention', props.on.mention)}
262 <Text>{' '}</Text>
263 {action('act:open', 'Open', props.on.outside)}
264 </Box>
265 )
266}
267
268/** Rows of code that touch are one highlighted block. */
269const codeBlock = (el: El, doc: Doc, run: readonly Row[], cells: number): RenderElement => {
270 const { Box, Code } = el
271 const first = run[0]?.code
272 const gutter = spansWidth(run[0]?.spans ?? [])
273 const source = run.map(row => (row.code === undefined || row.code.line === '' ? ' ' : row.code.line)).join('\n')
274 const language = first?.language
275 const code = (
276 <Code
277 source={source}
278 wrap="truncate-end"
279 {...(language === undefined ? (first?.block === 0 ? { path: baseOf(doc.path) } : {}) : { language })}
280 {...(first?.number === undefined ? {} : { startLine: first.number })}
281 />
282 )
283
284 return (
285 <Box flexDirection="row" width={cells} height={run.length} overflow="hidden">
286 {gutter === 0 ? null : (
287 <Box flexDirection="column" width={gutter}>
288 {run.map(row => line(el, row.spans, gutter))}
289 </Box>
290 )}
291 <Box flexDirection="column" width={Math.max(1, cells - gutter)}>
292 {code}
293 </Box>
294 </Box>
295 )
296}
297
298const textRow = (el: El, props: PaneProps, row: Row, index: number, cells: number): RenderElement => {
299 const { Box, Text, Button } = el
300
301 if (row.link === undefined) {
302 return line(el, row.spans, cells)
303 }
304
305 const href = row.link
306 const fitted = fitSpans(row.spans.filter(span => span.text !== ''), cells)
307
308 return (
309 <Box key={`link:${index}`} width={cells} height={1} overflow="hidden">
310 <Button key={`go:${index}`} plain hover={SOFT} onPress={() => props.on.follow(href)}>
311 {fitted.map(span => (
312 <Text
313 {...(span.bold === true ? { bold: true } : {})}
314 {...(span.italic === true ? { italic: true } : {})}
315 {...(span.underline === true ? { underline: true } : {})}
316 {...(span.dim === true ? { dimColor: true } : {})}
317 {...(span.color === undefined ? {} : { color: span.color })}
318 >
319 {span.text}
320 </Text>
321 ))}
322 </Button>
323 </Box>
324 )
325}
326
327const body = (el: El, props: PaneProps, cells: number, rows: number): RenderElement => {
328 const { Box, Text, Image } = el
329 const { doc } = props
330 const out: RenderElement[] = []
331
332 if (doc === undefined) {
333 const lead = Math.max(0, Math.floor(rows / 3))
334 const say = (text: string, isStrong: boolean) => {
335 const free = ' '.repeat(Math.max(0, Math.floor((cells - width(text)) / 2)))
336
337 return isStrong ? <Text bold>{free + text}</Text> : <Text color={C.muted}>{free + text}</Text>
338 }
339
340 for (let r = 0; r < lead; r += 1) {
341 out.push(empty(el))
342 }
343
344 out.push(say(props.title, true), empty(el), say('Select a file to read it here.', false))
345 } else if (doc.picture !== undefined) {
346 const { picture } = doc
347 const lead = Math.max(0, Math.floor((rows - picture.rows) / 2))
348
349 for (let r = 0; r < lead; r += 1) {
350 out.push(empty(el))
351 }
352
353 out.push(
354 <Box width={cells} height={picture.rows} justifyContent="center">
355 {Image === undefined ? (
356 <Text color={C.muted}>{picture.alt}</Text>
357 ) : (
358 <Image
359 key="picture"
360 source={{ file: picture.file, format: 'png', generation: picture.generation }}
361 columns={picture.columns}
362 rows={picture.rows}
363 alt={picture.alt}
364 />
365 )}
366 </Box>,
367 )
368 } else {
369 const shown = doc.rows.slice(props.docTop, props.docTop + rows)
370 const lead = doc.isCard ? Math.max(0, Math.floor((rows - shown.length) / 3)) : 0
371
372 for (let r = 0; r < lead; r += 1) {
373 out.push(empty(el))
374 }
375
376 for (let r = 0; r < shown.length; r += 1) {
377 const row = shown[r]
378
379 if (row === undefined) {
380 continue
381 }
382
383 if (row.code === undefined) {
384 out.push(textRow(el, props, row, props.docTop + r, cells))
385 continue
386 }
387
388 let end = r + 1
389
390 while (end < shown.length && shown[end]?.code?.block === row.code.block) {
391 end += 1
392 }
393
394 out.push(codeBlock(el, doc, shown.slice(r, end), cells))
395 r = end - 1
396 }
397 }
398
399 return (
400 <Box flexDirection="column" width={cells} height={rows} overflow="hidden">
401 {out}
402 </Box>
403 )
404}
405
406const reader = (el: El, props: PaneProps): RenderElement => {
407 const { Box, Text } = el
408 const { frame, doc } = props
409 const cells = frame.textWidth
410 const isBare = doc === undefined && frame.isSplit
411
412 return (
413 <Box flexDirection="column" width={cells} height={frame.rows} overflow="hidden">
414 {isBare ? empty(el) : header(el, props, cells)}
415 <Text color={C.faint}>{isBare ? ' ' : '─'.repeat(cells)}</Text>
416 {empty(el)}
417 {body(el, props, cells, frame.docRows)}
418 {empty(el)}
419 {footer(el, props, cells)}
420 </Box>
421 )
422}
423
424/** The whole pane: tree and reader side by side when there is room, else one of them. */
425export const drawPane = (el: El, props: PaneProps): RenderElement => {
426 const { Box, Text } = el
427 const { frame, doc } = props
428 const docTotal = doc === undefined || doc.picture !== undefined ? 0 : doc.rows.length
429 const docRail = (rest: string) => rail(el, frame.rows, 3, frame.docRows, docTotal, props.docTop, rest)
430 const treeRail = (rest: string) => rail(el, frame.rows, 2, frame.treeRows, props.nodes.length, props.treeTop, rest)
431
432 if (!frame.isSplit) {
433 if (props.selected === '') {
434 return tree(el, props)
435 }
436
437 return (
438 <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
439 <Text> </Text>
440 {reader(el, props)}
441 <Text> </Text>
442 {docRail(' ')}
443 </Box>
444 )
445 }
446
447 // The line between the two parts carries the thumb of the part on its left.
448 if (frame.isTreeRight) {
449 return (
450 <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
451 <Text> </Text>
452 {reader(el, props)}
453 <Text>{' '}</Text>
454 {docRail('│')}
455 {tree(el, props)}
456 {treeRail(' ')}
457 </Box>
458 )
459 }
460
461 return (
462 <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
463 {tree(el, props)}
464 {treeRail('│')}
465 <Text>{' '}</Text>
466 {reader(el, props)}
467 <Text> </Text>
468 {docRail(' ')}
469 </Box>
470 )
471}
472hooks/markdown.ts 979 lines1// Markdown to terminal rows. Every row is one line of the pane, so the pane
2// can show any window of a document and knows its height.
3import { clean, spansWidth, width, wrapSpans } from './text'
4import type { Row, Span, Style } from './text'
5import { C } from './theme'
6
7type Align = 'left' | 'right' | 'center'
8
9type Ctx = {
10 /** How deep in lists the block is; picks the bullet. */
11 depth: number
12 /** Inside a tight list item: no blank row between its blocks. */
13 isTight: boolean
14 /** Counts fenced blocks, so two that touch stay apart. */
15 blocks: { n: number }
16}
17
18const BULLETS = ['•', '◦', '▪'] as const
19const RULE = '─'
20const ESCAPABLE = /[\\`*_{}[\]()#+\-.!|~<>&"']/
21const ENTITIES: Record<string, string> = {
22 amp: '&',
23 lt: '<',
24 gt: '>',
25 quot: '"',
26 apos: "'",
27 nbsp: ' ',
28 mdash: '—',
29 ndash: '–',
30 hellip: '…',
31 rarr: '→',
32 larr: '←',
33 copy: '©',
34 times: '×',
35 middot: '·',
36}
37
38/** Inline code: a quiet tint, so a page full of paths stays calm. */
39const CODE: Style = { color: C.code }
40
41const blank = (): Row => ({ spans: [] })
42
43const isBlank = (row: Row | undefined): boolean =>
44 row !== undefined && row.code === undefined && row.spans.every(span => span.text.trim() === '')
45
46const indentOf = (line: string): number => line.length - line.trimStart().length
47
48/** A link the pane can follow itself: a path, not a web address or an anchor. */
49export const isLocalLink = (href: string): boolean => href !== '' && !/^([a-z][a-z0-9+.-]*:|#|\/\/)/i.test(href)
50
51// --- inline -----------------------------------------------------------------
52
53const skipCode = (src: string, at: number): number => {
54 let run = 1
55
56 while (src[at + run] === '`') {
57 run += 1
58 }
59
60 let k = at + run
61
62 while (k < src.length) {
63 if (src[k] !== '`') {
64 k += 1
65 continue
66 }
67
68 let close = 1
69
70 while (src[k + close] === '`') {
71 close += 1
72 }
73
74 if (close === run) {
75 return k + close
76 }
77
78 k += close
79 }
80
81 return -1
82}
83
84/** Where the closing run of `len` marks starts, or -1. */
85const findClose = (src: string, from: number, mark: string, len: number): number => {
86 let k = from
87
88 while (k < src.length) {
89 const ch = src[k]
90
91 if (ch === '\\') {
92 k += 2
93 continue
94 }
95
96 if (ch === '`') {
97 const end = skipCode(src, k)
98
99 if (end === -1) {
100 k += 1
101 } else {
102 k = end
103 }
104
105 continue
106 }
107
108 if (ch !== mark) {
109 k += 1
110 continue
111 }
112
113 let run = 1
114
115 while (src[k + run] === mark) {
116 run += 1
117 }
118
119 const before = src[k - 1] ?? ' '
120 const after = src[k + run] ?? ' '
121 const fits = len === 1 ? run % 2 === 1 : run >= len
122 const closes = k > from && !/\s/.test(before) && (mark !== '_' || !/[A-Za-z0-9]/.test(after))
123
124 if (fits && closes) {
125 return k + run - len
126 }
127
128 k += run
129 }
130
131 return -1
132}
133
134const readLink = (src: string, start: number): { text: string; href: string; end: number } | undefined => {
135 let depth = 0
136 let k = start
137
138 for (; k < src.length; k += 1) {
139 const ch = src[k]
140
141 if (ch === '\\') {
142 k += 1
143 } else if (ch === '`') {
144 const end = skipCode(src, k)
145
146 if (end !== -1) {
147 k = end - 1
148 }
149 } else if (ch === '[') {
150 depth += 1
151 } else if (ch === ']') {
152 depth -= 1
153
154 if (depth === 0) {
155 break
156 }
157 }
158 }
159
160 if (k >= src.length || src[k + 1] !== '(') {
161 return undefined
162 }
163
164 let p = k + 2
165 let parens = 1
166
167 for (; p < src.length; p += 1) {
168 const ch = src[p]
169
170 if (ch === '\\') {
171 p += 1
172 } else if (ch === '(') {
173 parens += 1
174 } else if (ch === ')') {
175 parens -= 1
176
177 if (parens === 0) {
178 break
179 }
180 }
181 }
182
183 if (p >= src.length) {
184 return undefined
185 }
186
187 let href = src.slice(k + 2, p).trim()
188 const titled = /^(\S+)\s+["'(].*["')]$/.exec(href)
189
190 if (titled?.[1] !== undefined) {
191 href = titled[1]
192 }
193
194 return { text: src.slice(start + 1, k), href: href.replace(/^<|>$/g, ''), end: p + 1 }
195}
196
197const entity = (name: string): string | undefined => {
198 if (name.startsWith('#x') || name.startsWith('#X')) {
199 const cp = Number.parseInt(name.slice(2), 16)
200
201 return Number.isFinite(cp) && cp > 31 && cp < 0x110000 ? String.fromCodePoint(cp) : undefined
202 }
203
204 if (name.startsWith('#')) {
205 const cp = Number.parseInt(name.slice(1), 10)
206
207 return Number.isFinite(cp) && cp > 31 && cp < 0x110000 ? String.fromCodePoint(cp) : undefined
208 }
209
210 return ENTITIES[name]
211}
212
213/** Inline markdown to styled spans. */
214export const inline = (src: string, base: Style = {}): Span[] => {
215 const out: Span[] = []
216 let buf = ''
217 let i = 0
218
219 const flush = () => {
220 if (buf !== '') {
221 out.push({ ...base, text: buf })
222 buf = ''
223 }
224 }
225
226 while (i < src.length) {
227 const ch = src[i] ?? ''
228 const next = src[i + 1] ?? ''
229
230 if (ch === '\\') {
231 if (next !== '' && ESCAPABLE.test(next)) {
232 buf += next
233 i += 2
234 } else {
235 buf += ch
236 i += 1
237 }
238
239 continue
240 }
241
242 if (ch === '`') {
243 const end = skipCode(src, i)
244
245 if (end !== -1) {
246 let run = 1
247
248 while (src[i + run] === '`') {
249 run += 1
250 }
251
252 let code = src.slice(i + run, end - run).replace(/\n/g, ' ')
253
254 if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') {
255 code = code.slice(1, -1)
256 }
257
258 flush()
259 out.push({ ...base, text: code, ...CODE })
260 i = end
261 continue
262 }
263 }
264
265 if (ch === '!' && next === '[') {
266 const image = readLink(src, i + 1)
267
268 if (image !== undefined) {
269 flush()
270 out.push({ ...base, text: `▣ ${image.text === '' ? 'image' : image.text}`, color: C.muted })
271 i = image.end
272 continue
273 }
274 }
275
276 if (ch === '[') {
277 const link = readLink(src, i)
278
279 if (link !== undefined) {
280 flush()
281 out.push(...inline(link.text, { ...base, underline: true, href: link.href }))
282 i = link.end
283 continue
284 }
285 }
286
287 if (ch === '<') {
288 const rest = src.slice(i, i + 400)
289 const auto = /^<((?:https?:\/\/|mailto:)[^\s<>]+)>/.exec(rest)
290
291 if (auto?.[1] !== undefined) {
292 flush()
293 out.push({ ...base, text: auto[1].replace(/^mailto:/, ''), underline: true, href: auto[1] })
294 i += auto[0].length
295 continue
296 }
297
298 const br = /^<br\s*\/?>/i.exec(rest)
299
300 if (br !== null) {
301 buf += '\n'
302 i += br[0].length
303 continue
304 }
305
306 const tag = /^<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s[^<>]*)?\/?>/.exec(rest)
307
308 if (tag !== null) {
309 i += tag[0].length
310 continue
311 }
312 }
313
314 if (ch === '&') {
315 const found = /^&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});/.exec(src.slice(i, i + 12))
316 const value = found?.[1] === undefined ? undefined : entity(found[1])
317
318 if (found !== null && value !== undefined) {
319 buf += value
320 i += found[0].length
321 continue
322 }
323 }
324
325 if (ch === '*' || ch === '_') {
326 let run = 1
327
328 while (src[i + run] === ch) {
329 run += 1
330 }
331
332 const before = src[i - 1] ?? ' '
333 const after = src[i + run] ?? ' '
334 const opens = !/\s/.test(after) && (ch === '*' || !/[A-Za-z0-9]/.test(before))
335 let isDone = false
336
337 if (opens) {
338 for (let len = Math.min(run, 3); len >= 1 && !isDone; len -= 1) {
339 const close = findClose(src, i + run, ch, len)
340
341 if (close === -1) {
342 continue
343 }
344
345 buf += ch.repeat(run - len)
346 flush()
347 out.push(
348 ...inline(src.slice(i + run, close), {
349 ...base,
350 ...(len >= 2 ? { bold: true } : {}),
351 ...(len !== 2 ? { italic: true } : {}),
352 }),
353 )
354 i = close + len
355 isDone = true
356 }
357 }
358
359 if (!isDone) {
360 buf += ch.repeat(run)
361 i += run
362 }
363
364 continue
365 }
366
367 if (ch === '~' && next === '~' && !/\s/.test(src[i + 2] ?? ' ')) {
368 const close = src.indexOf('~~', i + 2)
369
370 if (close !== -1) {
371 flush()
372 out.push(...inline(src.slice(i + 2, close), { ...base, strike: true }))
373 i = close + 2
374 continue
375 }
376 }
377
378 if (ch === 'h' && (src.startsWith('https://', i) || src.startsWith('http://', i)) && /[\s(>]|^$/.test(src[i - 1] ?? '')) {
379 const found = /^https?:\/\/[^\s<>]+/.exec(src.slice(i, i + 2100))
380
381 if (found !== null) {
382 const url = found[0].replace(/[.,;:!?)\]'"*_]+$/, '')
383
384 flush()
385 out.push({ ...base, text: url, underline: true, href: url })
386 i += url.length
387 continue
388 }
389 }
390
391 buf += ch
392 i += 1
393 }
394
395 flush()
396
397 return out
398}
399
400// --- blocks -----------------------------------------------------------------
401
402const HEADING = /^ {0,3}(#{1,6})(?:[ ]+(.*?))?(?:[ ]+#+)?[ ]*$/
403const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/
404const HR = /^ {0,3}([-*_])(?:[ ]*\1){2,}[ ]*$/
405const QUOTE = /^ {0,3}>/
406const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(?:(\s+)(.*))?$/
407const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
408const COMMENT = /^\s*<!--/
409
410const isTableRule = (line: string | undefined): boolean =>
411 line !== undefined && line.includes('|') && line.includes('-') && TABLE_RULE.test(line)
412
413const startsBlock = (line: string, after: string | undefined): boolean => {
414 if (HEADING.test(line) || FENCE.test(line) || HR.test(line) || QUOTE.test(line) || COMMENT.test(line)) {
415 return true
416 }
417
418 const item = ITEM.exec(line)
419
420 if (item !== null && item[4] !== undefined && item[4] !== '' && (item[2] === '1.' || item[2] === '1)' || !/\d/.test(item[2] ?? ''))) {
421 return true
422 }
423
424 return line.includes('|') && isTableRule(after)
425}
426
427const cellsOf = (line: string): string[] => {
428 const cells: string[] = []
429 let cell = ''
430 let isCode = false
431 const text = line.trim()
432
433 for (let k = 0; k < text.length; k += 1) {
434 const ch = text[k] ?? ''
435
436 if (ch === '\\' && text[k + 1] === '|') {
437 cell += '|'
438 k += 1
439 } else if (ch === '`') {
440 isCode = !isCode
441 cell += ch
442 } else if (ch === '|' && !isCode) {
443 cells.push(cell.trim())
444 cell = ''
445 } else {
446 cell += ch
447 }
448 }
449
450 cells.push(cell.trim())
451
452 if (text.startsWith('|')) {
453 cells.shift()
454 }
455
456 if (text.endsWith('|') && !text.endsWith('\\|')) {
457 cells.pop()
458 }
459
460 return cells
461}
462
463const sum = (list: readonly number[]): number => list.reduce((total, n) => total + n, 0)
464
465/**
466 * Column widths that fit `room`. Each column first gets room for its longest
467 * word, then the columns with more text share what is left.
468 */
469export const fitColumns = (natural: readonly number[], room: number, words: readonly number[] = []): number[] => {
470 if (sum(natural) <= room) {
471 return [...natural]
472 }
473
474 const count = Math.max(1, natural.length)
475 const floor = Math.max(3, Math.min(10, Math.floor(room / count)))
476 const base = natural.map((w, c) => Math.min(w, Math.max(floor, Math.min(words[c] ?? floor, Math.floor(room / 2)))))
477
478 if (sum(base) <= room) {
479 const widths = [...base]
480 let left = room - sum(widths)
481
482 // The column that is shortest of its full width takes the next cell.
483 while (left > 0) {
484 let pick = -1
485 let need = 0
486
487 for (let c = 0; c < widths.length; c += 1) {
488 const short = ((natural[c] ?? 0) - (widths[c] ?? 0)) / Math.max(1, widths[c] ?? 1)
489
490 if (short > need) {
491 need = short
492 pick = c
493 }
494 }
495
496 if (pick === -1) {
497 break
498 }
499
500 widths[pick] = (widths[pick] ?? 0) + 1
501 left -= 1
502 }
503
504 return widths
505 }
506
507 let low = floor
508 let high = Math.max(...natural)
509
510 while (low < high) {
511 const mid = Math.ceil((low + high) / 2)
512
513 if (sum(natural.map(w => Math.min(w, mid))) <= room) {
514 low = mid
515 } else {
516 high = mid - 1
517 }
518 }
519
520 return natural.map(w => Math.max(Math.min(w, low), Math.min(w, floor)))
521}
522
523const longestWord = (spans: readonly Span[]): number =>
524 Math.max(0, ...spans.map(span => span.text).join('').split(/\s+/).map(word => width(word)))
525
526/** A table with a rule under its head and air between its columns, no box. */
527export const tableRows = (head: readonly Span[][], aligns: readonly Align[], body: readonly Span[][][], cols: number): Row[] => {
528 const count = Math.max(head.length, ...body.map(row => row.length), 1)
529 const gap = count > 4 ? 2 : 3
530 const natural = Array.from({ length: count }, (_, c) =>
531 Math.max(1, spansWidth(head[c] ?? []), ...body.map(row => spansWidth(row[c] ?? []))),
532 )
533 const words = Array.from({ length: count }, (_, c) =>
534 Math.min(26, Math.max(longestWord(head[c] ?? []), ...body.map(row => longestWord(row[c] ?? [])))),
535 )
536 const widths = fitColumns(natural, Math.max(count * 3, cols - gap * (count - 1)), words)
537 const out: Row[] = []
538
539 const line = (cells: readonly Span[][]): Row[] => {
540 const wrapped = Array.from({ length: count }, (_, c) => wrapSpans(cells[c] ?? [], widths[c] ?? 1))
541 const height = Math.max(1, ...wrapped.map(lines => lines.length))
542 const rows: Row[] = []
543
544 for (let l = 0; l < height; l += 1) {
545 const spans: Span[] = []
546
547 for (let c = 0; c < count; c += 1) {
548 const part = wrapped[c]?.[l] ?? []
549 const room = widths[c] ?? 1
550 const free = Math.max(0, room - spansWidth(part))
551 const align = aligns[c] ?? 'left'
552 const left = align === 'right' ? free : align === 'center' ? Math.floor(free / 2) : 0
553 const right = free - left + (c < count - 1 ? gap : 0)
554
555 if (left > 0) {
556 spans.push({ text: ' '.repeat(left) })
557 }
558
559 spans.push(...part)
560
561 if (right > 0 && c < count - 1) {
562 spans.push({ text: ' '.repeat(right) })
563 }
564 }
565
566 rows.push({ spans })
567 }
568
569 return rows
570 }
571
572 out.push(...line(head))
573 out.push({
574 spans: widths.flatMap((w, c) => [
575 { text: RULE.repeat(w), color: C.faint },
576 ...(c < count - 1 ? [{ text: ' '.repeat(gap) }] : []),
577 ]),
578 })
579
580 const lines = body.map(line)
581 const isAiry = lines.some(rows => rows.length > 1)
582
583 lines.forEach((rows, r) => {
584 if (isAiry && r > 0) {
585 out.push(blank())
586 }
587
588 out.push(...rows)
589 })
590
591 return out
592}
593
594const headingRows = (level: number, text: string, cols: number): Row[] => {
595 const style: Style =
596 level <= 3 ? { bold: true } : level === 4 ? { bold: true, color: C.muted } : { color: C.muted, italic: true }
597 const rows = wrapSpans(inline(text, style), cols).map(spans => ({ spans }))
598
599 if (level === 1) {
600 rows.push({ spans: [{ text: '━━━━', color: C.accent }] })
601 } else if (level === 2) {
602 rows.push({ spans: [{ text: RULE.repeat(cols), color: C.faint }] })
603 }
604
605 return rows
606}
607
608const blockRows = (lines: readonly string[], cols: number, ctx: Ctx): Row[] => {
609 const out: Row[] = []
610 let i = 0
611
612 const gap = (rows = 1, isForced = false) => {
613 if (out.length === 0 || (ctx.isTight && !isForced)) {
614 return
615 }
616
617 let have = 0
618
619 for (let k = out.length - 1; k >= 0 && isBlank(out[k]); k -= 1) {
620 have += 1
621 }
622
623 for (; have < rows; have += 1) {
624 out.push(blank())
625 }
626 }
627
628 while (i < lines.length) {
629 const line = lines[i] ?? ''
630
631 if (line.trim() === '') {
632 i += 1
633 continue
634 }
635
636 if (COMMENT.test(line)) {
637 while (i < lines.length && !(lines[i] ?? '').includes('-->')) {
638 i += 1
639 }
640
641 i += 1
642 continue
643 }
644
645 const fence = FENCE.exec(line)
646
647 if (fence !== null) {
648 const mark = fence[1] ?? '```'
649 const language = (fence[2] ?? '').trim().split(/\s+/)[0] ?? ''
650 const indent = indentOf(line)
651 const block = ctx.blocks.n
652 const body: string[] = []
653
654 ctx.blocks.n += 1
655 i += 1
656
657 while (i < lines.length) {
658 const inner = lines[i] ?? ''
659 const trimmed = inner.trim()
660
661 i += 1
662
663 if (trimmed.startsWith(mark) && trimmed.replace(new RegExp(`\\${mark[0] ?? '`'}`, 'g'), '') === '') {
664 break
665 }
666
667 body.push(inner.slice(Math.min(indent, indentOf(inner))))
668 }
669
670 while (body.length > 0 && (body[body.length - 1] ?? '').trim() === '') {
671 body.pop()
672 }
673
674 gap(1, true)
675
676 for (const code of body.length === 0 ? [''] : body) {
677 out.push({
678 spans: [{ text: '│ ', color: C.faint }],
679 code: { line: code, block, ...(language === '' ? {} : { language }) },
680 })
681 }
682
683 continue
684 }
685
686 const heading = HEADING.exec(line)
687
688 if (heading !== null) {
689 const level = (heading[1] ?? '#').length
690
691 gap(level <= 2 ? 2 : 1, true)
692 out.push(...headingRows(level, (heading[2] ?? '').trim(), cols))
693 i += 1
694 continue
695 }
696
697 if (HR.test(line)) {
698 gap(1, true)
699 out.push({ spans: [{ text: RULE.repeat(cols), color: C.faint }] })
700 i += 1
701 continue
702 }
703
704 if (line.includes('|') && isTableRule(lines[i + 1])) {
705 const head = cellsOf(line).map(cell => inline(cell, { bold: true }))
706 const aligns = cellsOf(lines[i + 1] ?? '').map((cell): Align =>
707 cell.startsWith(':') && cell.endsWith(':') ? 'center' : cell.endsWith(':') ? 'right' : 'left',
708 )
709 const body: Span[][][] = []
710
711 i += 2
712
713 while (i < lines.length && (lines[i] ?? '').trim() !== '' && (lines[i] ?? '').includes('|')) {
714 body.push(cellsOf(lines[i] ?? '').map(cell => inline(cell)))
715 i += 1
716 }
717
718 gap(1, true)
719 out.push(...tableRows(head, aligns, body, cols))
720 continue
721 }
722
723 if (QUOTE.test(line)) {
724 const inner: string[] = []
725
726 while (i < lines.length) {
727 const quoted = lines[i] ?? ''
728
729 if (QUOTE.test(quoted)) {
730 inner.push(quoted.replace(/^ {0,3}> ?/, ''))
731 } else if (quoted.trim() !== '' && inner.length > 0 && !startsBlock(quoted, lines[i + 1])) {
732 inner.push(quoted)
733 } else {
734 break
735 }
736
737 i += 1
738 }
739
740 gap(1, true)
741
742 for (const row of blockRows(inner, Math.max(8, cols - 2), { ...ctx, isTight: false })) {
743 out.push({
744 ...row,
745 spans: [{ text: '▎ ', color: C.faint }, ...row.spans.map(span => (span.color === undefined ? { ...span, color: C.muted } : span))],
746 })
747 }
748
749 continue
750 }
751
752 const first = ITEM.exec(line)
753
754 if (first !== null) {
755 const base = (first[1] ?? '').length
756 const isOrdered = /\d/.test(first[2] ?? '')
757 const items: string[][] = []
758 const start = isOrdered ? Number.parseInt(first[2] ?? '1', 10) : 1
759 let isLoose = false
760
761 while (i < lines.length) {
762 const item = ITEM.exec(lines[i] ?? '')
763
764 // A list ends where the indent or the kind of marker changes.
765 if (item === null || (item[1] ?? '').length !== base || /\d/.test(item[2] ?? '') !== isOrdered || HR.test(lines[i] ?? '')) {
766 break
767 }
768
769 const offset = base + (item[2] ?? '').length + Math.min((item[3] ?? ' ').length, 4)
770 const body = [item[4] ?? '']
771
772 i += 1
773
774 while (i < lines.length) {
775 const inner = lines[i] ?? ''
776
777 if (inner.trim() === '') {
778 let k = i + 1
779
780 while (k < lines.length && (lines[k] ?? '').trim() === '') {
781 k += 1
782 }
783
784 if (k < lines.length && indentOf(lines[k] ?? '') > base) {
785 body.push('')
786 i += 1
787 continue
788 }
789
790 break
791 }
792
793 const indent = indentOf(inner)
794
795 if (indent > base) {
796 body.push(inner.slice(Math.min(indent, offset)))
797 i += 1
798 continue
799 }
800
801 if (ITEM.test(inner) || startsBlock(inner, lines[i + 1])) {
802 break
803 }
804
805 body.push(inner.trim())
806 i += 1
807 }
808
809 items.push(body)
810
811 let k = i
812
813 while (k < lines.length && (lines[k] ?? '').trim() === '') {
814 k += 1
815 }
816
817 if (k > i && k < lines.length) {
818 const following = ITEM.exec(lines[k] ?? '')
819
820 const isSame = following !== null && (following[1] ?? '').length === base && /\d/.test(following[2] ?? '') === isOrdered
821
822 if (isSame && !HR.test(lines[k] ?? '')) {
823 isLoose = true
824 i = k
825 continue
826 }
827
828 break
829 }
830 }
831
832 const digits = String(start + items.length - 1).length
833 const room = isOrdered ? digits + 2 : 2
834
835 gap()
836
837 items.forEach((body, n) => {
838 const task = /^\[([ xX])\]\s+/.exec(body[0] ?? '')
839 const isChecked = task !== null && task[1] !== ' '
840
841 if (task !== null) {
842 body[0] = (body[0] ?? '').slice(task[0].length)
843 }
844
845 const marker: Span =
846 task !== null
847 ? { text: isChecked ? '✓ ' : '○ ', color: isChecked ? C.done : C.muted }
848 : isOrdered
849 ? { text: `${`${start + n}.`.padStart(digits + 1)} `, color: C.muted }
850 : { text: `${BULLETS[ctx.depth % BULLETS.length] ?? '•'} `, color: C.muted }
851 const inner = blockRows(body, Math.max(8, cols - room), {
852 ...ctx,
853 depth: ctx.depth + 1,
854 isTight: !isLoose && !body.includes(''),
855 })
856 const rows = inner.length === 0 ? [blank()] : inner
857
858 if (isLoose && n > 0) {
859 gap(1, true)
860 }
861
862 rows.forEach((row, r) => {
863 const spans = isChecked ? row.spans.map(span => ({ ...span, dim: true })) : row.spans
864
865 out.push({ ...row, spans: [r === 0 ? marker : { text: ' '.repeat(room) }, ...spans] })
866 })
867 })
868
869 continue
870 }
871
872 // A paragraph, or a heading underlined with = or -.
873 const para: string[] = []
874 let setext = 0
875
876 while (i < lines.length) {
877 const text = lines[i] ?? ''
878
879 if (text.trim() === '') {
880 break
881 }
882
883 if (para.length > 0) {
884 const under = /^ {0,3}(=+|-+)[ ]*$/.exec(text)
885
886 if (under !== null) {
887 setext = (under[1] ?? '=').startsWith('=') ? 1 : 2
888 i += 1
889 break
890 }
891
892 if (startsBlock(text, lines[i + 1])) {
893 break
894 }
895 }
896
897 para.push(text)
898 i += 1
899 }
900
901 const joined = para
902 .map((text, n) => (n === para.length - 1 ? text.trim() : / {2,}$|\\$/.test(text) ? `${text.replace(/( {2,}|\\)$/, '').trim()}\n` : `${text.trim()} `))
903 .join('')
904
905 if (setext > 0) {
906 gap(2, true)
907 out.push(...headingRows(setext, joined, cols))
908 continue
909 }
910
911 gap()
912
913 for (const spans of wrapSpans(inline(joined), cols)) {
914 const link = spans.find(span => span.href !== undefined && isLocalLink(span.href))?.href
915
916 out.push(link === undefined ? { spans } : { spans, link })
917 }
918 }
919
920 return out
921}
922
923const frontMatter = (lines: readonly string[], cols: number): Row[] => {
924 const pairs = lines
925 .map(line => /^\s*([A-Za-z0-9_-]+):\s*(.*)$/.exec(line))
926 .filter((found): found is RegExpExecArray => found !== null && (found[2] ?? '').trim() !== '')
927 const keyWidth = Math.min(16, Math.max(0, ...pairs.map(pair => (pair[1] ?? '').length)))
928 const out: Row[] = []
929
930 for (const pair of pairs) {
931 const value = (pair[2] ?? '').replace(/^["']|["']$/g, '')
932 const rows = wrapSpans([{ text: value === '' ? '—' : value, color: C.muted }], Math.max(8, cols - keyWidth - 2))
933
934 rows.forEach((spans, r) => {
935 out.push({ spans: [{ text: (r === 0 ? (pair[1] ?? '') : '').padEnd(keyWidth + 2), color: C.faint }, ...spans] })
936 })
937 }
938
939 if (out.length > 0) {
940 out.push({ spans: [{ text: RULE.repeat(cols), color: C.faint }] })
941 }
942
943 return out
944}
945
946/** A markdown document as rows `cols` cells wide. */
947export const renderMarkdown = (source: string, cols: number): Row[] => {
948 const room = Math.max(12, cols)
949 let lines = clean(source).split('\n')
950 const out: Row[] = []
951
952 if ((lines[0] ?? '').trim() === '---') {
953 const end = lines.findIndex((line, n) => n > 0 && /^(---|\.\.\.)\s*$/.test(line))
954
955 if (end > 0) {
956 out.push(...frontMatter(lines.slice(1, end), room))
957 lines = lines.slice(end + 1)
958 }
959 }
960
961 const body = blockRows(lines, room, { depth: 0, isTight: false, blocks: { n: 1 } })
962
963 if (out.length > 0 && body.length > 0) {
964 out.push(blank())
965 }
966
967 out.push(...body)
968
969 while (isBlank(out[0])) {
970 out.shift()
971 }
972
973 while (isBlank(out[out.length - 1])) {
974 out.pop()
975 }
976
977 return out
978}
979hooks/text.ts 356 lines1// Text measured in terminal cells: widths, cuts and word wrap over styled spans.
2
3export type Span = {
4 text: string
5 bold?: boolean
6 italic?: boolean
7 underline?: boolean
8 strike?: boolean
9 dim?: boolean
10 color?: string
11 bg?: string
12 href?: string
13}
14
15export type Style = Omit<Span, 'text'>
16
17/** One line of a fenced block or of a source file, drawn by the engine's highlighter. */
18export type CodeLine = { line: string; block: number; language?: string; number?: number }
19
20/** One terminal row. A code row keeps its indent in `spans`. */
21export type Row = { spans: Span[]; code?: CodeLine; link?: string }
22
23const WIDE: ReadonlyArray<readonly [number, number]> = [
24 [0x1100, 0x115f],
25 [0x231a, 0x231b],
26 [0x23e9, 0x23ec],
27 [0x23f0, 0x23f0],
28 [0x23f3, 0x23f3],
29 [0x25fd, 0x25fe],
30 [0x2614, 0x2615],
31 [0x2648, 0x2653],
32 [0x267f, 0x267f],
33 [0x2693, 0x2693],
34 [0x26a1, 0x26a1],
35 [0x26aa, 0x26ab],
36 [0x26bd, 0x26be],
37 [0x26c4, 0x26c5],
38 [0x26ce, 0x26ce],
39 [0x26d4, 0x26d4],
40 [0x26ea, 0x26ea],
41 [0x26f2, 0x26f3],
42 [0x26f5, 0x26f5],
43 [0x26fa, 0x26fa],
44 [0x26fd, 0x26fd],
45 [0x2705, 0x2705],
46 [0x270a, 0x270b],
47 [0x2728, 0x2728],
48 [0x274c, 0x274c],
49 [0x274e, 0x274e],
50 [0x2753, 0x2755],
51 [0x2757, 0x2757],
52 [0x2795, 0x2797],
53 [0x27b0, 0x27b0],
54 [0x27bf, 0x27bf],
55 [0x2b1b, 0x2b1c],
56 [0x2b50, 0x2b50],
57 [0x2b55, 0x2b55],
58 [0x2e80, 0xa4cf],
59 [0xac00, 0xd7a3],
60 [0xf900, 0xfaff],
61 [0xfe30, 0xfe6f],
62 [0xff00, 0xff60],
63 [0xffe0, 0xffe6],
64 [0x1f300, 0x1f64f],
65 [0x1f680, 0x1f6ff],
66 [0x1f900, 0x1f9ff],
67 [0x1fa70, 0x1faff],
68 [0x20000, 0x3fffd],
69]
70
71const ZERO: ReadonlyArray<readonly [number, number]> = [
72 [0x0300, 0x036f],
73 [0x1ab0, 0x1aff],
74 [0x200b, 0x200f],
75 [0x2060, 0x2060],
76 [0x20d0, 0x20ff],
77 [0xfe00, 0xfe0f],
78 [0xfeff, 0xfeff],
79]
80
81const within = (ranges: ReadonlyArray<readonly [number, number]>, cp: number) => {
82 for (const [low, high] of ranges) {
83 if (cp >= low && cp <= high) {
84 return true
85 }
86 }
87
88 return false
89}
90
91export const charWidth = (cp: number): number => {
92 if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) {
93 return 0
94 }
95
96 if (cp < 0x300) {
97 return 1
98 }
99
100 if (within(ZERO, cp)) {
101 return 0
102 }
103
104 return within(WIDE, cp) ? 2 : 1
105}
106
107export const width = (text: string): number => {
108 let total = 0
109 let last = 0
110
111 for (const ch of text) {
112 const cp = ch.codePointAt(0) ?? 0
113
114 // An emoji selector makes a narrow symbol draw wide.
115 if (cp === 0xfe0f && last === 1) {
116 total += 1
117 last = 2
118 continue
119 }
120
121 last = charWidth(cp)
122 total += last
123 }
124
125 return total
126}
127
128/** The longest head of `text` that fits `cells`. */
129export const head = (text: string, cells: number): string => {
130 let total = 0
131 let out = ''
132
133 for (const ch of text) {
134 const w = charWidth(ch.codePointAt(0) ?? 0)
135
136 if (total + w > cells) {
137 break
138 }
139
140 total += w
141 out += ch
142 }
143
144 return out
145}
146
147/** Cuts `text` to `cells`, an ellipsis at the cut. */
148export const truncate = (text: string, cells: number): string => {
149 if (cells <= 0) {
150 return ''
151 }
152
153 return width(text) <= cells ? text : `${head(text, cells - 1)}…`
154}
155
156/** Cuts in the middle, so a file name keeps its ending. */
157export const truncateMiddle = (text: string, cells: number): string => {
158 if (width(text) <= cells) {
159 return text
160 }
161
162 if (cells <= 3) {
163 return truncate(text, cells)
164 }
165
166 const tail = Math.floor((cells - 1) / 3)
167 const chars = [...text]
168
169 return `${head(text, cells - 1 - tail)}…${chars.slice(chars.length - tail).join('')}`
170}
171
172export const pad = (text: string, cells: number): string => text + ' '.repeat(Math.max(0, cells - width(text)))
173
174/** Tabs to spaces, and no control character but a newline: what a Text may hold. */
175export const clean = (text: string): string =>
176 text
177 .replace(/\r\n?/g, '\n')
178 .replace(/\t/g, ' ')
179 // eslint-disable-next-line no-control-regex
180 .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '')
181
182export const plain = (spans: readonly Span[]): string => spans.map(span => span.text).join('')
183
184export const spansWidth = (spans: readonly Span[]): number => spans.reduce((sum, span) => sum + width(span.text), 0)
185
186type Piece = { text: string; style: Style; cells: number }
187
188const styleOf = (span: Span): Style => {
189 const { text: _text, ...style } = span
190
191 return style
192}
193
194/**
195 * Word wrap over styled spans. A newline in a span's text is a hard break.
196 * A word wider than the room is cut across rows.
197 */
198export const wrapSpans = (spans: readonly Span[], cells: number): Span[][] => {
199 const room = Math.max(1, cells)
200 const lines: Span[][] = []
201 let line: Span[] = []
202 let used = 0
203 let word: Piece[] = []
204 let wordCells = 0
205 let gap: Piece[] = []
206 let gapCells = 0
207
208 const push = (piece: Piece) => {
209 const lastSpan = line[line.length - 1]
210
211 if (lastSpan !== undefined && sameStyle(lastSpan, piece.style)) {
212 lastSpan.text += piece.text
213 } else {
214 line.push({ ...piece.style, text: piece.text })
215 }
216
217 used += piece.cells
218 }
219
220 const breakLine = () => {
221 lines.push(line)
222 line = []
223 used = 0
224 }
225
226 const placeWord = () => {
227 if (word.length === 0) {
228 return
229 }
230
231 if (used > 0 && used + gapCells + wordCells > room) {
232 breakLine()
233 gap = []
234 gapCells = 0
235 }
236
237 if (used > 0) {
238 for (const piece of gap) {
239 push(piece)
240 }
241 }
242
243 gap = []
244 gapCells = 0
245
246 for (const piece of word) {
247 if (used + piece.cells <= room) {
248 push(piece)
249 continue
250 }
251
252 // The word is wider than a whole row: cut it.
253 let rest = piece.text
254
255 while (rest !== '') {
256 if (used >= room) {
257 breakLine()
258 }
259
260 let part = head(rest, room - used) || [...rest][0] || ''
261 let isSoft = false
262
263 // A path or a long name breaks after a separator when one is near the edge.
264 if (part.length < rest.length) {
265 const least = Math.floor(part.length * 0.4)
266 const slash = part.lastIndexOf('/', part.length - 2)
267 const soft = slash >= least ? slash : Math.max(part.lastIndexOf('-'), part.lastIndexOf('_'), part.lastIndexOf('='))
268
269 if (soft >= least && soft < part.length - 1) {
270 part = part.slice(0, soft + 1)
271 isSoft = true
272 }
273 }
274
275 push({ text: part, style: piece.style, cells: width(part) })
276 rest = rest.slice(part.length)
277
278 if (isSoft) {
279 breakLine()
280 }
281 }
282 }
283
284 word = []
285 wordCells = 0
286 }
287
288 for (const span of spans) {
289 const style = styleOf(span)
290
291 for (const token of span.text.split(/(\n|[ ]+)/)) {
292 if (token === '') {
293 continue
294 }
295
296 if (token === '\n') {
297 placeWord()
298 breakLine()
299 gap = []
300 gapCells = 0
301 } else if (token.startsWith(' ')) {
302 placeWord()
303 gap.push({ text: token, style, cells: token.length })
304 gapCells += token.length
305 } else {
306 const cellsOf = width(token)
307 word.push({ text: token, style, cells: cellsOf })
308 wordCells += cellsOf
309 }
310 }
311 }
312
313 placeWord()
314
315 if (line.length > 0 || lines.length === 0) {
316 lines.push(line)
317 }
318
319 return lines
320}
321
322const sameStyle = (span: Span, style: Style): boolean =>
323 span.bold === style.bold &&
324 span.italic === style.italic &&
325 span.underline === style.underline &&
326 span.strike === style.strike &&
327 span.dim === style.dim &&
328 span.color === style.color &&
329 span.bg === style.bg &&
330 span.href === style.href
331
332/** Cuts a row of spans to `cells`, an ellipsis at the cut. */
333export const fitSpans = (spans: readonly Span[], cells: number): Span[] => {
334 if (spansWidth(spans) <= cells) {
335 return [...spans]
336 }
337
338 const out: Span[] = []
339 let left = Math.max(0, cells - 1)
340
341 for (const span of spans) {
342 const w = width(span.text)
343
344 if (w <= left) {
345 out.push(span)
346 left -= w
347 continue
348 }
349
350 out.push({ ...span, text: `${head(span.text, left)}…` })
351 break
352 }
353
354 return out
355}
356hooks/theme.ts 18 lines1// The few colors the pane uses. Each is a theme key, so the pane follows the
2// person's light or dark theme.
3export const C = {
4 /** The one accent: the open file's mark, inline code, the active mode. */
5 accent: 'suggestion',
6 /** Inline code and paths in a document. */
7 code: 'professionalBlue',
8 /** Secondary text: markers, meta, breadcrumbs. */
9 muted: 'inactive',
10 /** Rules and guides. */
11 faint: 'subtle',
12 /** The soft fill under a hovered or open row. */
13 fill: 'userMessageBackground',
14 /** A file Claude wrote in this session. */
15 touched: 'claude',
16 done: 'success',
17} as const
18types/index.d.ts 27 lines1export type FolioMode = 'read' | 'source'
2
3/** What the pane shows: the open folders, the open file and where each list stands. */
4export type FolioUi = {
5 expanded: string[]
6 selected: string
7 mode: FolioMode
8 docTop: number
9 treeTop: number
10 /** Files that can hold secrets and that the person asked to see. */
11 revealed: string[]
12 /** Goes up when files change on disk, so the pane reads them again. */
13 tick: number
14 /** Where the tree stands in the pane. */
15 side: 'left' | 'right'
16 isRestored: boolean
17}
18
19/** Paths Claude wrote in this session, relative to the project. */
20export type FolioTouched = string[]
21
22declare module 'claude-code' {
23 interface PluginState {
24 folio: { ui: FolioUi; touched: FolioTouched }
25 }
26}
27