SLOPSHOPPER

quick-peek

The paths in Claude's replies become links: hover one to preview it, click it to show it in Finder.

newrowspromptprocess
★ 210v0.1.0MITupdated 2026-10-26midudev/better-claude/plugins/quick-peek
A shopper browsing a rack in a slop shop
README

Quick Peek

A Claude Code mod: the paths in Claude's replies become links. Hover one and a preview opens by the pointer; click it and Finder opens with the file selected.

⏺ He hecho dos versiones del principio. Elige la que más te guste:

  - A, desde dentro de la lámpara: empieza pegado al cristal y la cámara sale disparada.
    /Users/midudev/Dev/video/revisar/borrador.mp4        ← hover: preview · click: Finder
  - B, el viaje: la cámara vuela pegada a la pared.
    /Users/midudev/Dev/video/revisar/borrador-01-viaje.mp4
  • Which paths: absolute (/Users/…), from home (~/…), relative to the session's folder (./x, src/app.ts, README.md), with or without backticks, and with a :line or :line:column after them. Only the paths that lead to a file or a folder become links; the rest stay as Claude wrote them. Inside backticks a path may hold spaces.
  • Hover: the path is underlined and a small window opens by the pointer, above it and to its right, without taking the focus: the file's Quick Look thumbnail (a video's frame, an image, a PDF's or a document's first page), else its icon; its name, its kind, size and age as your system words them, and the folder it is in. A folder shows its icon and its first entries. Move off the path and it closes.
  • Click: Finder opens the folder with the file selected (open -R). Option-click opens the file itself, with its app.

A path a row cannot hold is drawn in parts, cut after a /, and each part is the link. Hovering needs the mouse, so it needs Claude Code's fullscreen interface ("tui": "fullscreen" in your settings, or /config).

Other systems

SystemClickHover
macOSFinder, the file selectedA window by the pointer, with the Quick Look thumbnail
LinuxThe file manager, the file selected (org.freedesktop.FileManager1), else its folder with xdg-openA one-line card over the path: name, size, age
WindowsExplorer, the file selected (explorer /select,)The card
WSLWindows' Explorer, the path turned into its Windows form by wslpath -wThe card

Turn the window off with the Preview in a window option (/config) to get the card on macOS too. Over SSH the window would open on the other machine, so there the card shows.

Install

/plugin marketplace add midudev/better-claude
/plugin install quick-peek@better-claude

Limits

  • Only Claude's replies: paths in tool rows (Read(…), a command's output) and in your prompts stay as they are.
  • A reply with a path that leads somewhere is drawn by the mod: its paragraphs and lists that hold such a path are laid out by the mod as Claude Code lays them out (bold, italics, code, links, nested and numbered lists), and every other block (headings, code blocks, tables, quotes) by Claude Code's own Markdown. A path in a heading, a table, a quote or a code block is no link.
  • In plain text a path ends at a space or a bracket: a path with spaces is a link only inside backticks.
  • A path is looked at when the reply is drawn; one created later becomes a link the next time the reply is drawn (scrolling it into view).
  • Dragging to select text that starts on a path does not select: the path takes the pointer.
  • A window stays a minute at most, and closes when another app comes to the front or the pointer goes a few rows away (the terminal does not say when the pointer leaves it).
  • The card is drawn over the row above the path: on the first row on screen it is cut.
  • Option-click needs the terminal to report the Option key with the click; some keep Option-click for themselves.

How it works

One hooks module (hooks/register.tsx), a surface module for the links (hooks/link.tsx), the markdown it lays out (hooks/markdown.ts), the paths it finds (hooks/paths.ts) and the window's program (hooks/window.ts):

  • A ui.render hook on AssistantMessage cuts the reply into blocks and finds the paths in its paragraphs and list items, inline code included. Each is resolved (~, the session's folder) and looked at with $.fs.stat, the answers kept a few seconds.
  • With none that leads somewhere, Claude Code draws the reply. Otherwise the mod draws it: the bullet, the blocks with no path as one Markdown each, and the others wrapped into rows the way Claude Code wraps a reply, each part of a path a Client drawing that part in the color of inline code. The tree replaces only Claude Code's own node, so what other mods draw around a reply stays.
  • Each Client posts when the pointer comes onto it, moves on it and leaves it, and when it is clicked; a ui.message hook opens and closes the preview, or shows the path in the file manager.
  • On macOS a hover starts osascript with a JavaScript for Automation program, told the path (QUICK_PEEK_FILE), the picture's largest side and how long it may stay. It draws an AppKit panel that never takes the focus or the pointer, with the thumbnail from QLThumbnailImageCreate and the kind, size and date from the file's resource values, formatted by the system. It says shown, then a . every tenth of a second, so the mod wakes to end it when the pointer leaves.
  • Without a window, each part of a path is a keyed Box holding a card drawn display: "none", which the terminal reveals while the pointer is on the path, with no hook running.

Development

claude plugin validate ./plugins/quick-peek   # from the repository root
claude plugin test ./plugins/quick-peek       # runs hooks/register.test.tsx against the engine

MIT © Miguel Ángel Durán (midudev)

Source 5 files
hooks/register.tsx 410 lines
1import type { Elements, EngineInterface, EventOf, FsStat, Register, RenderElement } from 'claude-code'
2
3import { blocks, inline, items, markerOf, widthOf, wrap } from './markdown'
4import type { Item, Row, Run } from './markdown'
5import { pathOfSpan, pathsIn, resolve } from './paths'
6import { MACOS } from './window'
7
8type Engine = EngineInterface
9type Ui = Elements['terminal']
10type Message = Extract<EventOf['ui.render'], { component: 'AssistantMessage', surface: 'terminal' }>
11type Platform = 'macos' | 'linux' | 'windows' | 'other'
12
13/** A path a reply names that leads somewhere: where it lands, and what is there. */
14type Target = { path: string; stat: FsStat }
15/** An item of a list the mod draws: its marker, its runs, the items under it, blank lines before it. */
16type DrawnItem = { marker: string; runs: Run[]; children: DrawnItem[]; gap: number }
17/** A part of a reply: drawn by the engine's Markdown, or by the mod, its paths links. */
18type Part =
19  | { kind: 'markdown'; text: string; gap: number }
20  | { kind: 'paragraph'; runs: Run[]; gap: number }
21  | { kind: 'list'; items: DrawnItem[]; gap: number }
22/** How the mod draws a reply: its parts, and the paths its runs link to (`Run.link` indexes them). */
23type Plan = { parts: Part[]; targets: Target[] }
24
25// The bullet the engine draws before a reply, by system, and the columns it takes.
26const BULLET = { macos: '⏺', other: '●' }
27const BULLET_COLUMNS = 2
28// How long a path is taken to be there, or not there, before it is looked at again; and a plan.
29const FOUND_MS = 30_000
30const MISSING_MS = 3_000
31const PLAN_MS = 10_000
32const PLANS = 300
33// The longest side of the preview's picture, in points, and the most seconds the window stays.
34const WINDOW_SIZE = 360
35const WINDOW_LIFE = 60
36
37const stats = new Map<string, { stat: FsStat | null; at: number }>()
38const plans = new Map<string, { plan: Plan | null; at: number }>()
39let platform: Promise<Platform> | undefined
40let wsl: Promise<boolean> | undefined
41// Whether a hover opens a window by the pointer (macOS); else each path carries a card the
42// terminal reveals over it. The path the pointer is on, and the part it is on; the window open.
43let windowing = false
44let hovered: { path: string; target: string } | undefined
45let window: { path: string; isOpen: boolean } | undefined
46
47/** The system the session runs on, asked once. */
48function platformOf ($: Engine) {
49  platform ??= (async (): Promise<Platform> => {
50    if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
51    const name = (await $.process.run(['uname', '-s']).catch(() => undefined))?.stdout.trim()
52    return name === 'Darwin' ? 'macos' : name === 'Linux' ? 'linux' : 'other'
53  })()
54  return platform
55}
56
57/** Whether the session runs under WSL: Linux, on a Windows desktop, whose file manager shows the path. */
58function wslOf ($: Engine) {
59  wsl ??= (async () => (await platformOf($)) === 'linux' && (await $.env.get('WSL_DISTRO_NAME')) !== undefined)()
60  return wsl
61}
62
63/** What is at `path`, looked at again once its answer is old; null where nothing is. */
64async function statOf ($: Engine, path: string) {
65  const now = Date.now()
66  const known = stats.get(path)
67  if (known && now - known.at < (known.stat ? FOUND_MS : MISSING_MS)) return known.stat
68  const stat = await $.fs.stat(path).catch(() => null)
69  stats.set(path, { stat: stat && stat.kind !== 'other' ? stat : null, at: now })
70  return stats.get(path)!.stat
71}
72
73/** A run cut where it names a path, each path a run of its own carrying the target it links to. */
74function link (run: Run, found: { start: number; end: number; link: number }[]): Run[] {
75  const out: Run[] = []
76  let at = 0
77  for (const { start, end, link } of found) {
78    if (start > at) out.push({ text: run.text.slice(at, start), style: run.style })
79    out.push({ text: run.text.slice(start, end), style: run.style, link })
80    at = end
81  }
82  if (at < run.text.length) out.push({ text: run.text.slice(at), style: run.style })
83  return out
84}
85
86/**
87 * How the mod draws `text`: its paragraphs and lists that name a path that leads somewhere drawn
88 * by the mod, each such path a link; every other block, and the runs between, by the engine's
89 * own Markdown. Null when no path in it leads anywhere: the engine draws the reply.
90 */
91async function planOf ($: Engine, text: string): Promise<Plan | null> {
92  const cwd = await $.session.cwd()
93  const key = `${cwd}\n${text}`
94  const known = plans.get(key)
95  if (known && Date.now() - known.at < PLAN_MS) return known.plan
96
97  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
98  type Pending = { run: Run; found: { start: number; end: number; path: string }[] }
99  const pending: Pending[] = []
100  const runsOf = (source: string) =>
101    inline(source).map(run => {
102      const found: Pending['found'] = []
103      if (run.style.href === undefined) {
104        const named = run.style.code ? [pathOfSpan(run.text)].flatMap(target => (target ? [{ start: 0, end: run.text.length, target }] : [])) : pathsIn(run.text)
105        for (const { start, end, target } of named) {
106          const path = resolve(target, cwd, home)
107          if (path) found.push({ start, end, path })
108        }
109      }
110      const entry = { run, found }
111      pending.push(entry)
112      return entry
113    })
114
115  type Drafted = { kind: 'markdown'; text: string; gap: number } | { kind: 'paragraph'; runs: Pending[]; gap: number } | { kind: 'list'; items: DraftItem[]; gap: number }
116  type DraftItem = { marker: string; runs: Pending[]; children: DraftItem[]; gap: number }
117  const draftItems = (list: Item[], depth: number): DraftItem[] =>
118    list.map(item => ({ marker: markerOf(item, depth), runs: runsOf(item.text), children: draftItems(item.children, depth + 1), gap: item.gap }))
119  const cut = blocks(text)
120  const drafted: Drafted[] = cut.map(block =>
121    block.kind === 'paragraph' ? { kind: 'paragraph', runs: runsOf(block.text), gap: block.gap }
122      : block.kind === 'list' ? { kind: 'list', items: draftItems(items(block.text), 0), gap: block.gap }
123        : { kind: 'markdown', text: block.text, gap: block.gap })
124
125  const paths = [...new Set(pending.flatMap(entry => entry.found.map(found => found.path)))]
126  const found = new Map((await Promise.all(paths.map(async path => [path, await statOf($, path)] as const))).filter(([, stat]) => stat !== null) as [string, FsStat][])
127  const targets: Target[] = []
128  const finish = (runs: Pending[]) =>
129    runs.flatMap(({ run, found: named }) => {
130      const kept = named.filter(entry => found.has(entry.path)).map(entry => ({ ...entry, link: targets.push({ path: entry.path, stat: found.get(entry.path)! }) - 1 }))
131      return link(run, kept)
132    })
133  const linked = (runs: Run[]) => runs.some(run => run.link !== undefined)
134  const finishItems = (list: DraftItem[]): DrawnItem[] => list.map(item => ({ marker: item.marker, runs: finish(item.runs), children: finishItems(item.children), gap: item.gap }))
135  const anyLinked = (list: DrawnItem[]): boolean => list.some(item => linked(item.runs) || anyLinked(item.children))
136
137  const parts: Part[] = []
138  drafted.forEach((draft, i) => {
139    let part: Part = { kind: 'markdown', text: cut[i]!.text, gap: draft.gap }
140    if (draft.kind === 'paragraph') {
141      const runs = finish(draft.runs)
142      if (linked(runs)) part = { kind: 'paragraph', runs, gap: draft.gap }
143    } else if (draft.kind === 'list') {
144      const list = finishItems(draft.items)
145      if (anyLinked(list)) part = { kind: 'list', items: list, gap: draft.gap }
146    }
147    // The blocks the engine draws next to each other are one Markdown, as written between them.
148    const last = parts.at(-1)
149    if (part.kind === 'markdown' && last?.kind === 'markdown') last.text += '\n'.repeat(part.gap + 1) + part.text
150    else parts.push(part)
151  })
152  const plan = targets.length > 0 ? { parts, targets } : null
153  if (plans.size >= PLANS) plans.delete(plans.keys().next().value!)
154  plans.set(key, { plan, at: Date.now() })
155  return plan
156}
157
158const sizeOf = (bytes: number) =>
159  bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : bytes < 1024 ** 3 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${(bytes / 1024 ** 3).toFixed(1)} GB`
160
161const ago = (ms: number) => {
162  const seconds = Math.max(0, Math.round((Date.now() - ms) / 1000))
163  const [value, unit] = seconds < 60 ? [seconds, 's'] : seconds < 3600 ? [Math.round(seconds / 60), 'min'] : seconds < 86400 ? [Math.round(seconds / 3600), 'h'] : [Math.round(seconds / 86400), 'd']
164  return `${value} ${unit} ago`
165}
166
167/** The line the terminal shows over a path where no window can: what it is, and what a click does. */
168function cardOf (target: Target) {
169  const name = target.path.slice(target.path.lastIndexOf('/') + 1) || target.path
170  const what = target.stat.kind === 'dir' ? 'folder' : sizeOf(target.stat.size)
171  return ` ${name} · ${what} · ${ago(target.stat.mtimeMs)} · click: show in folder `
172}
173
174/**
175 * The rows of a wrapped paragraph, `width` columns: text in its style, each part of a path a
176 * `Client` that takes the pointer.
177 */
178function drawRows (ui: Ui, rows: Row[], width: number, targets: Target[], parts: Map<number, number>) {
179  const { Box, Client, Link, Text } = ui
180  return rows.map(row => {
181    let column = 0
182    return (
183      <Box flexDirection="row">
184        {row.length === 0 && <Text> </Text>}
185        {row.map(run => {
186          const at = column
187          column += widthOf(run.text)
188          const { bold, italic, strike, code, href } = run.style
189          if (run.link === undefined) {
190            const style = { bold, italic, strikethrough: strike, ...(code && { color: 'permission' }) }
191            return href === undefined ? <Text {...style}>{run.text}</Text> : <Text {...style}><Link href={href}>{run.text}</Link></Text>
192          }
193          const part = parts.get(run.link) ?? 0
194          parts.set(run.link, part + 1)
195          const target = targets[run.link]!
196          const props = { label: run.text, path: target.path, ...(bold && { bold }), ...(italic && { italic }), ...(strike && { strike }) }
197          const client = <Client key={`path:${run.link}:${part}`} module="./link.tsx" props={props} />
198          if (windowing) return client
199          // No window: a card over the path, which the terminal reveals while the pointer is on it,
200          // moved left as far as it needs to end inside the reply.
201          const card = cardOf(target)
202          const left = Math.max(-at, Math.min(0, width - at - widthOf(card)))
203          return (
204            <Box key={`card:${run.link}:${part}`}>
205              {client}
206              <Box position="absolute" top={-1} left={left} width={Math.min(widthOf(card), width)} display="none" hover={{ display: 'flex' }}>
207                <Text inverse wrap="truncate-end">{card}</Text>
208              </Box>
209            </Box>
210          )
211        })}
212      </Box>
213    )
214  })
215}
216
217function drawList (ui: Ui, list: DrawnItem[], width: number, targets: Target[], parts: Map<number, number>): RenderElement[] {
218  const { Box, Text } = ui
219  return list.map((item, i) => {
220    const indent = widthOf(item.marker) + 1
221    return (
222      <Box flexDirection="row" marginTop={i > 0 && item.gap > 0 ? 1 : 0}>
223        <Box minWidth={indent} flexShrink={0}><Text>{item.marker}</Text></Box>
224        <Box flexDirection="column" width={width - indent}>
225          {drawRows(ui, wrap(item.runs, width - indent), width - indent, targets, parts)}
226          {drawList(ui, item.children, width - indent, targets, parts)}
227        </Box>
228      </Box>
229    )
230  })
231}
232
233/** The reply as the engine lays it out, its bullet and its blocks, with the paths in it links. */
234function draw (ui: Ui, e: Message, plan: Plan, bullet: string) {
235  const { Box, Markdown, Text } = ui
236  const width = Math.max(10, (e.viewport?.columns ?? 80) - BULLET_COLUMNS)
237  const parts = new Map<number, number>()
238  return (
239    <Box flexDirection="row">
240      <Box minWidth={BULLET_COLUMNS} flexShrink={0}>{e.props.isFirstOfReply && <Text color="text">{bullet}</Text>}</Box>
241      <Box flexDirection="column" width={width}>
242        {plan.parts.map((part, i) => {
243          const marginTop = i > 0 && part.gap > 0 ? 1 : 0
244          if (part.kind === 'markdown') return <Box marginTop={marginTop}><Markdown text={part.text} /></Box>
245          const drawn = part.kind === 'paragraph' ? drawRows(ui, wrap(part.runs, width), width, plan.targets, parts) : drawList(ui, part.items, width, plan.targets, parts)
246          return <Box flexDirection="column" marginTop={marginTop}>{drawn}</Box>
247        })}
248      </Box>
249    </Box>
250  )
251}
252
253/** The tree `next` drew with the engine's node in it swapped for `mine`: what other plugins drew around it stays. */
254function swap (drawn: RenderElement, mine: RenderElement): RenderElement {
255  let isSwapped = false
256  const walk = (node: unknown): unknown => {
257    if (isSwapped || node === null || typeof node !== 'object') return node
258    if (Array.isArray(node)) return node.map(walk)
259    const element = node as { type?: unknown; children?: unknown[] }
260    if (element.type === 'engine') {
261      isSwapped = true
262      return mine
263    }
264    return Array.isArray(element.children) ? { ...element, children: element.children.map(walk) } : node
265  }
266  const out = walk(drawn) as RenderElement
267  return isSwapped ? out : mine
268}
269
270/** Shows `path` in the system's file manager, selected; with `isOpening`, opens it instead. */
271async function reveal ($: Engine, path: string, isOpening: boolean) {
272  const run = (argv: string[]) => $.process.run(argv).catch(() => undefined)
273  if (await wslOf($)) {
274    const windows = (await run(['wslpath', '-w', path]))?.stdout.trim()
275    if (windows) await run(isOpening ? ['explorer.exe', windows] : ['explorer.exe', `/select,${windows}`])
276    return
277  }
278  const system = await platformOf($)
279  if (system === 'macos') {
280    await run(isOpening ? ['open', path] : ['open', '-R', path])
281    return
282  }
283  if (system === 'windows') {
284    const windows = path.replaceAll('/', '\\')
285    await run(isOpening ? ['explorer', windows] : ['explorer', `/select,${windows}`])
286    return
287  }
288  if (isOpening) {
289    await run(['xdg-open', path])
290    return
291  }
292  // The desktop's file manager selects it where it answers FileManager1; else its folder opens.
293  const uri = `file://${path.split('/').map(encodeURIComponent).join('/')}`
294  const shown = await run(['dbus-send', '--session', '--print-reply', '--dest=org.freedesktop.FileManager1', '/org/freedesktop/FileManager1', 'org.freedesktop.FileManager1.ShowItems', `array:string:${uri}`, 'string:'])
295  if (shown?.exitCode !== 0) await run(['xdg-open', path.slice(0, path.lastIndexOf('/')) || '/'])
296}
297
298/**
299 * Shows `path` in a window by the pointer till `closeWindow`. The program says `.` ten times a
300 * second, so the loop wakes to see it was closed, and leaving the loop ends the program. One that
301 * ends without having shown a window, on a path that is there, says the system shows none: from
302 * then on the terminal shows the cards.
303 */
304async function openWindow ($: Engine, path: string) {
305  closeWindow()
306  const opening = { path, isOpen: true }
307  window = opening
308  const env = { QUICK_PEEK_FILE: path, QUICK_PEEK_SIZE: String(WINDOW_SIZE), QUICK_PEEK_LIFE: String(WINDOW_LIFE), QUICK_PEEK_HINT: 'click: show in Finder · ⌥ click: open' }
309  let isShown = false
310  let said = ''
311  try {
312    for await (const { text } of $.process.spawn({ argv: ['osascript', '-l', 'JavaScript', '-e', MACOS], env })) {
313      if (said.length < 2000) said += text
314      if (/^shown$/m.test(text)) isShown = true
315      if (!opening.isOpen) break
316    }
317  } catch (error) {
318    said += error instanceof Error ? error.message : String(error)
319  }
320  if (window === opening) window = undefined
321  if (isShown || !opening.isOpen || /^error: not there/m.test(said)) return
322  $.ui.log(`quick-peek: no window (${said.replace(/\.\n/g, '').trim().slice(0, 300) || 'the program could not start'}); the terminal shows the cards`, { to: 'debug' })
323  windowing = false
324  plans.clear()
325}
326
327function closeWindow () {
328  if (window) window.isOpen = false
329  window = undefined
330}
331
332/** The pointer came onto a part of `path` (`target`, the part's address): its preview opens, unless it is open. */
333function over ($: Engine, path: string, target: string) {
334  const isOpen = hovered?.path === path && window?.path === path && window.isOpen
335  hovered = { path, target }
336  if (isOpen || !windowing) return
337  void openWindow($, path)
338}
339
340/** The pointer left a part: the preview closes, unless the pointer is already on another part of it. */
341function off (target: string) {
342  if (hovered?.target !== target) return
343  hovered = undefined
344  closeWindow()
345}
346
347export const register: Register = (on, options) => {
348  on('session.start', async ($, e, next) => {
349    const result = await next(e)
350    const [system, sshConnection, sshTty] = await Promise.all([platformOf($), $.env.get('SSH_CONNECTION'), $.env.get('SSH_TTY')])
351    // A window opens on the desktop the session runs on: not over SSH, where that is another machine's.
352    windowing = options.window !== false && e.isInteractive && system === 'macos' && sshConnection === undefined && sshTty === undefined
353    plans.clear()
354    stats.clear()
355    return result
356  })
357
358  on('session.end', ($, e, next) => {
359    hovered = undefined
360    closeWindow()
361    return next(e)
362  })
363
364  // Typing or sending a prompt closes a preview: what is under the pointer may move.
365  on('prompt.edit', ($, e, next) => {
366    if (hovered) {
367      hovered = undefined
368      closeWindow()
369    }
370    return next(e)
371  })
372
373  on('prompt.submit', ($, e, next) => {
374    hovered = undefined
375    closeWindow()
376    return next(e)
377  }).catch(($, e, next) => next(e))
378
379  // A part of a path says when the pointer comes onto it and leaves it: its preview opens and
380  // closes. Clicked, the path shows in the file manager; alt-clicked, it opens.
381  on('ui.message', async ($, e, next) => {
382    const result = await next(e)
383    if (!e.element.startsWith('path:')) return result
384    const data = e.data as { path?: unknown; isOver?: unknown; isClicked?: unknown; isAlt?: unknown } | null
385    if (typeof data?.path !== 'string') return result
386    const target = `${e.requestId}/${e.element}`
387    if (data.isClicked === true) {
388      hovered = undefined
389      closeWindow()
390      void reveal($, data.path, data.isAlt === true)
391      return result
392    }
393    if (data.isOver === true) over($, data.path, target)
394    else off(target)
395    return result
396  })
397
398  // A reply that names a path that leads somewhere: the mod draws it, its bullet, its blocks the
399  // engine's Markdown draws, and the paragraphs and lists that name paths, the paths links. Off
400  // fullscreen the terminal reports no pointer: the engine draws it as it is.
401  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
402    if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || e.props.isSummary) return next(e)
403    const plan = await planOf($, e.props.text).catch(() => null)
404    if (!plan) return next(e)
405    const drawn = await next(e)
406    const bullet = (await platformOf($)) === 'macos' ? BULLET.macos : BULLET.other
407    return swap(drawn, draw($.ui.resolve(e), e, plan, bullet))
408  })
409}
410
hooks/markdown.ts 396 lines
1// The part of markdown the mod draws itself: a reply cut into blocks, the inline runs of a
2// paragraph or a list item, and those runs wrapped into rows as the engine wraps a reply's text.
3// Every other block (a heading, a fence, a table, a quote) is drawn by the engine's own Markdown.
4
5/** How a run is drawn. `href`, a link's address. */
6export type Style = { bold?: boolean; italic?: boolean; strike?: boolean; code?: boolean; href?: string }
7
8/** A run of inline text in one style; `link`, the path it names, by its index in the reply. */
9export type Run = { text: string; style: Style; link?: number }
10
11/** A row of a wrapped paragraph. */
12export type Row = Run[]
13
14/** A block of the reply: `prose` the mod may draw itself, `other` the engine draws. `gap`, blank lines before it. */
15export type Block = { kind: 'paragraph' | 'list' | 'other'; text: string; gap: number }
16
17/** A list item: its marker as written, its text (lines joined), the items under it, blank lines before it. */
18export type Item = { ordered: boolean; number: number; text: string; children: Item[]; gap: number }
19
20const FENCE = /^ {0,3}(`{3,}|~{3,})/
21const HEADING = /^ {0,3}#{1,6}(\s|$)/
22const QUOTE = /^ {0,3}>/
23const RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/
24const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
25const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(?:(\s+)(.*))?$/
26const HTML = /^ {0,3}<\/?[a-zA-Z]/
27
28const isBlank = (line: string) => line.trim() === ''
29/** What kind of list a marker opens: its bullet, or an ordered list's delimiter. */
30const kindOf = (marker: string) => (/\d/.test(marker) ? `1${marker.at(-1)}` : marker)
31const indentOf = (line: string) => line.length - line.trimStart().length
32
33/** Whether `line` starts a block of its own, so it ends a paragraph. */
34function interrupts (line: string, next: string | undefined) {
35  if (FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || RULE.test(line) || HTML.test(line)) return true
36  if (next !== undefined && line.includes('|') && TABLE_RULE.test(next) && next.includes('-')) return true
37  const item = ITEM.exec(line)
38  return !!item && item[4] !== undefined && item[4].trim() !== '' && indentOf(line) < 4 && (!/\d/.test(item[2]!) || /^1[.)]$/.test(item[2]!))
39}
40
41/** The reply cut into blocks, in order; joined with their gaps, they give the text back. */
42export function blocks (text: string): Block[] {
43  const lines = text.split('\n')
44  const found: Block[] = []
45  let gap = 0
46  let at = 0
47  const push = (kind: Block['kind'], end: number) => {
48    found.push({ kind, text: lines.slice(at, end).join('\n'), gap })
49    gap = 0
50    at = end
51  }
52  while (at < lines.length) {
53    const line = lines[at]!
54    if (isBlank(line)) {
55      gap++
56      at++
57      continue
58    }
59    let end = at + 1
60    const fence = FENCE.exec(line)
61    if (fence) {
62      const close = new RegExp(`^ {0,3}${fence[1]![0]}{${fence[1]!.length},}\\s*$`)
63      while (end < lines.length && !close.test(lines[end]!)) end++
64      push('other', Math.min(end + 1, lines.length))
65      continue
66    }
67    if (line.includes('|') && end < lines.length && TABLE_RULE.test(lines[end]!) && lines[end]!.includes('-')) {
68      while (end < lines.length && !isBlank(lines[end]!) && lines[end]!.includes('|')) end++
69      push('other', end)
70      continue
71    }
72    if (QUOTE.test(line)) {
73      while (end < lines.length && !isBlank(lines[end]!)) end++
74      push('other', end)
75      continue
76    }
77    if (HEADING.test(line) || RULE.test(line) || HTML.test(line) || indentOf(line) >= 4) {
78      if (indentOf(line) >= 4 || HTML.test(line)) while (end < lines.length && !isBlank(lines[end]!)) end++
79      push('other', end)
80      continue
81    }
82    const item = ITEM.exec(line)
83    if (item && item[4] !== undefined) {
84      // A list runs on through blank lines while what follows is an item of its kind or indented
85      // under one; an item of another kind (`-` after `*`, `1.` after `-`) starts another list.
86      const indent = indentOf(line)
87      const kind = kindOf(item[2]!)
88      const isOther = (next: string) => {
89        const other = ITEM.exec(next)
90        return !!other && indentOf(next) <= indent && kindOf(other[2]!) !== kind
91      }
92      while (end < lines.length) {
93        const next = lines[end]!
94        if (!isBlank(next)) {
95          if (isOther(next)) break
96          if (indentOf(next) === 0 && !ITEM.test(next) && (isBlank(lines[end - 1]!) || interrupts(next, lines[end + 1]))) break
97          end++
98          continue
99        }
100        let after = end
101        while (after < lines.length && isBlank(lines[after]!)) after++
102        const following = lines[after]
103        if (following === undefined || isOther(following) || !(ITEM.test(following) || indentOf(following) >= 2)) break
104        end = after
105      }
106      push('list', end)
107      continue
108    }
109    while (end < lines.length && !isBlank(lines[end]!) && !interrupts(lines[end]!, lines[end + 1])) end++
110    push('paragraph', end)
111  }
112  return found
113}
114
115/** A list block's items, nested as their indents say. */
116export function items (text: string): Item[] {
117  type Open = Item & { indent: number; content: number; lines: string[] }
118  const roots: Open[] = []
119  const stack: Open[] = []
120  let gap = 0
121  for (const line of text.split('\n')) {
122    if (isBlank(line)) {
123      gap++
124      continue
125    }
126    const item = ITEM.exec(line)
127    const indent = indentOf(line)
128    if (!item) {
129      // A line under an item continues the deepest one it is indented under, or the last (a lazy line).
130      const open = [...stack].reverse().find(open => open.content <= indent) ?? stack.at(-1)
131      open?.lines.push(line.trim())
132      gap = 0
133      continue
134    }
135    while (stack.length > 0 && indent < stack.at(-1)!.content) stack.pop()
136    const marker = item[2]!
137    const spaces = item[3]?.length ?? 1
138    const open: Open = {
139      ordered: /\d/.test(marker),
140      number: Number.parseInt(marker, 10) || 1,
141      text: '',
142      children: [],
143      gap,
144      indent,
145      content: indent + marker.length + (spaces > 4 ? 1 : spaces),
146      lines: [item[4] ?? '']
147    }
148    const parent = stack.at(-1)
149    ;(parent ? parent.children : roots).push(open)
150    stack.push(open)
151    gap = 0
152  }
153  const close = (list: Open[]): Item[] => {
154    const start = list[0]?.number ?? 1
155    return list.map((open, i) => ({
156      ordered: open.ordered,
157      number: start + i,
158      text: open.lines.join('\n'),
159      children: close(open.children as Open[]),
160      gap: open.gap
161    }))
162  }
163  return close(roots)
164}
165
166const LETTERS = (n: number) => {
167  let out = ''
168  for (let left = n; left > 0; left = Math.floor((left - 1) / 26)) out = String.fromCharCode(97 + ((left - 1) % 26)) + out
169  return out
170}
171const ROMAN: [number, string][] = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']]
172const roman = (n: number) => {
173  let out = ''
174  let left = n
175  for (const [value, letters] of ROMAN) while (left >= value) (out += letters), (left -= value)
176  return out
177}
178
179/** The marker the engine draws for an item: `-`, or `1.`, `a.`, `i.` by depth. */
180export function markerOf (item: Item, depth: number) {
181  if (!item.ordered) return '-'
182  const level = depth % 3
183  return `${level === 0 ? item.number : level === 1 ? LETTERS(item.number) : roman(item.number)}.`
184}
185
186/** The inline runs of a paragraph's markdown: code spans, emphasis, strikes, links; a hard break as `\n`. */
187export function inline (source: string, style: Style = {}): Run[] {
188  const runs: Run[] = []
189  let text = ''
190  const flush = () => {
191    if (text) runs.push({ text, style })
192    text = ''
193  }
194  const emit = (inner: Run[]) => {
195    flush()
196    runs.push(...inner)
197  }
198  let i = 0
199  while (i < source.length) {
200    const c = source[i]!
201    const rest = source.slice(i)
202    if (c === '\\' && /^\\[!-/:-@[-`{-~]/.test(rest)) {
203      text += source[i + 1]
204      i += 2
205      continue
206    }
207    // A hard break: a backslash or two spaces before the newline. A soft one is kept as written.
208    if (/^(\\| {2,})\n/.test(rest)) {
209      text += '\n'
210      i += rest.indexOf('\n') + 1
211      continue
212    }
213    if (c === '`') {
214      const ticks = /^`+/.exec(rest)![0]
215      const close = new RegExp(`(?<!\`)${ticks}(?!\`)`).exec(source.slice(i + ticks.length))
216      if (close) {
217        let code = source.slice(i + ticks.length, i + ticks.length + close.index).replace(/\n/g, ' ')
218        if (/^ .*[^ ].* $/.test(code)) code = code.slice(1, -1)
219        emit([{ text: code, style: { ...style, code: true } }])
220        i += ticks.length * 2 + close.index
221        continue
222      }
223      text += ticks
224      i += ticks.length
225      continue
226    }
227    if (c === '[') {
228      const link = /^\[([^\]]+)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
229      if (link) {
230        emit(inline(link[1]!, { ...style, href: link[2]! }))
231        i += link[0].length
232        continue
233      }
234    }
235    const auto = /^<((?:https?|file):\/\/[^>\s]+)>/.exec(rest)
236    if (auto) {
237      emit([{ text: auto[1]!, style: { ...style, href: auto[1]! } }])
238      i += auto[0].length
239      continue
240    }
241    if (c === '*' || c === '_' || c === '~') {
242      const run = new RegExp(`^\\${c}+`).exec(rest)![0]
243      const before = source[i - 1] ?? ' '
244      const after = source[i + run.length] ?? ' '
245      const isIntraword = c === '_' && /\w/.test(before)
246      const size = c === '~' ? (run.length === 2 ? 2 : 0) : Math.min(run.length, 3)
247      if (size > 0 && !isIntraword && !/\s/.test(after)) {
248        const marks = c.repeat(size)
249        const from = i + size
250        let close = -1
251        for (let at = source.indexOf(marks, from + 1); at !== -1; at = source.indexOf(marks, at + 1)) {
252          const isFlanked = !/\s/.test(source[at - 1]!) && (c !== '_' || !/\w/.test(source[at + size] ?? ' '))
253          if (isFlanked && source[at + size] !== c) {
254            close = at
255            break
256          }
257        }
258        if (close !== -1) {
259          const inner = source.slice(from, close)
260          const next: Style = c === '~' ? { ...style, strike: true } : { ...style, ...(size >= 2 && { bold: true }), ...(size !== 2 && { italic: true }) }
261          emit(inline(inner, next))
262          i = close + size
263          continue
264        }
265      }
266      text += run
267      i += run.length
268      continue
269    }
270    text += c
271    i++
272  }
273  flush()
274  return runs
275}
276
277const ZERO = /^[̀-ͯ҃-҉֑-ֽؐ-ًؚ-ٟ​-‏⃐-⃿︀-️︠-︯]$/u
278const WIDE = /^[ᄀ-ᅟ⌚⌛〈〉⏩-⏬⏰⏳◽◾☔☕♈-♓♿⚓⚡⚪⚫⚽⚾⛄⛅⛎⛔⛪⛲⛳⛵⛺⛽✅✊✋✨❌❎❓-❕❗➕-➗➰➿⬛⬜⭐⭕⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1f300}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{1f900}-\u{1f9ff}\u{20000}-\u{3fffd}]$/u
279
280/** The columns a character takes in the terminal. */
281const charWidth = (char: string) => (ZERO.test(char) ? 0 : WIDE.test(char) ? 2 : 1)
282
283/** The columns a text takes in the terminal. */
284export const widthOf = (text: string) => {
285  let width = 0
286  for (const char of text) width += charWidth(char)
287  return width
288}
289
290/**
291 * The first part of `text` that fits in `room` columns, cut at most that wide; a path is cut
292 * after a `/` near the end where it has one, so each row ends at a folder.
293 */
294function cut (text: string, room: number, isPath: boolean) {
295  let width = 0
296  let end = 0
297  for (const char of text) {
298    const w = charWidth(char)
299    if (width + w > room) break
300    width += w
301    end += char.length
302  }
303  if (isPath && end < text.length) {
304    const slash = text.lastIndexOf('/', end - 1)
305    if (slash > 0 && end - slash <= 16) end = slash + 1
306  }
307  return end === 0 ? Math.max(1, [...text][0]!.length) : end
308}
309
310/**
311 * The runs wrapped into rows of `width` columns, as the engine wraps a reply: at spaces, a word
312 * that does not fit moved to the next row, one longer than a row cut where it saves a row. The
313 * spaces a wrap lands on are dropped.
314 */
315export function wrap (runs: Run[], width: number): Row[] {
316  type Piece = Run & { width: number }
317  type Token = { kind: 'word' | 'space' | 'break'; pieces: Piece[]; width: number }
318  const tokens: Token[] = []
319  for (const run of runs) {
320    for (const part of run.text.split(/(\n| +)/)) {
321      if (part === '') continue
322      const kind = part === '\n' ? 'break' : part.startsWith(' ') ? 'space' : 'word'
323      const piece: Piece = { ...run, text: part, width: widthOf(part) }
324      const last = tokens.at(-1)
325      if (kind === 'word' && last?.kind === 'word') {
326        last.pieces.push(piece)
327        last.width += piece.width
328      } else {
329        tokens.push({ kind, pieces: [piece], width: piece.width })
330      }
331    }
332  }
333
334  const rows: Row[] = [[]]
335  let column = 0
336  let spaces: Piece[] = []
337  const put = (piece: Piece) => {
338    const row = rows.at(-1)!
339    const last = row.at(-1)
340    if (last && last.link === piece.link && sameStyle(last.style, piece.style)) last.text += piece.text
341    else row.push({ text: piece.text, style: piece.style, ...(piece.link !== undefined && { link: piece.link }) })
342    column += piece.width
343  }
344  const newRow = () => {
345    rows.push([])
346    column = 0
347    spaces = []
348  }
349  for (const token of tokens) {
350    if (token.kind === 'break') {
351      newRow()
352      continue
353    }
354    if (token.kind === 'space') {
355      if (column > 0) spaces.push(...token.pieces)
356      continue
357    }
358    const gap = spaces.reduce((sum, piece) => sum + piece.width, 0)
359    if (column + gap + token.width <= width) {
360      spaces.forEach(put)
361      spaces = []
362      token.pieces.forEach(put)
363      continue
364    }
365    if (token.width <= width) {
366      newRow()
367      token.pieces.forEach(put)
368      continue
369    }
370    // Longer than a row: started here only where that takes no more rows than starting below.
371    const room = width - column - gap
372    const startingHere = 1 + Math.floor((token.width - room - 1) / width)
373    const startingBelow = Math.floor((token.width - 1) / width)
374    if (column > 0 && (room <= 0 || startingBelow < startingHere)) newRow()
375    else {
376      spaces.forEach(put)
377      spaces = []
378    }
379    for (const piece of token.pieces) {
380      let left = piece.text
381      while (left !== '') {
382        if (column >= width) newRow()
383        const end = cut(left, width - column, piece.link !== undefined)
384        const part = left.slice(0, end)
385        put({ ...piece, text: part, width: widthOf(part) })
386        left = left.slice(end)
387        if (left !== '') newRow()
388      }
389    }
390  }
391  return rows.filter((row, i) => row.length > 0 || i < rows.length - 1)
392}
393
394const sameStyle = (a: Style, b: Style) =>
395  !!a.bold === !!b.bold && !!a.italic === !!b.italic && !!a.strike === !!b.strike && !!a.code === !!b.code && a.href === b.href
396
hooks/paths.ts 78 lines
1// Finding the paths a text names: absolute (`/Users/…`), from home (`~/…`), relative (`./x`,
2// `src/x.ts`, `README.md`), each with an optional `:line` or `:line:column` after it. A path is
3// only a candidate here: the hooks module links the ones that lead to a file or folder.
4
5/** A path a text names: where it starts and ends in the text, and the path itself, `:line` cut. */
6export type Found = { start: number; end: number; target: string }
7
8// What a sentence puts after a path, and a line (and column) after a file.
9const TRAILING = /[.,;:!?)\]}>'"»”’]+$/
10const LINE = /:\d+(?::\d+)?$/
11// An address: never a path, nor the paths inside it.
12const URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>()]+/gi
13// Absolute, from home or explicitly relative; a relative path with a folder in it; a file name with
14// an extension. None starts inside a word, an address or another path.
15const CANDIDATE = new RegExp(
16  [
17    String.raw`(?:~|\.{1,2})?\/[^\s\`'"<>()[\]{}|*]+`,
18    String.raw`[A-Za-z]:\\[^\s\`'"<>()[\]{}|*]+`,
19    String.raw`[\w@+-][\w@.+-]*(?:\/[\w@.+-]+)+\/?`,
20    String.raw`[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,9}`
21  ].map(source => `(?<![\\w/.~:\\\\@-])(?:${source})(?::\\d+(?::\\d+)?)?`).join('|'),
22  'g'
23)
24// A whole inline code span that is a path: it may hold spaces when it starts as a path does.
25const SPAN = /^(?:(?:~|\.{1,2})?\/|[A-Za-z]:\\)[^\n`]*[^\s`]$/
26
27/** The path `found` names, with what a sentence or a line number adds after it cut; none when nothing is left. */
28function clean (text: string) {
29  let target = text.replace(TRAILING, '')
30  target = target.replace(LINE, '')
31  target = target.replace(TRAILING, '')
32  return /[\w~]/.test(target) && target !== '~' ? target : undefined
33}
34
35/** The paths named in plain text, in order. */
36export function pathsIn (text: string): Found[] {
37  const masked = text.replace(URL, match => ' '.repeat(match.length))
38  const found: Found[] = []
39  for (const match of masked.matchAll(CANDIDATE)) {
40    const whole = match[0]
41    const trimmed = whole.replace(TRAILING, '')
42    const target = clean(whole)
43    if (target === undefined) continue
44    found.push({ start: match.index, end: match.index + trimmed.length, target })
45  }
46  return found
47}
48
49/** The path an inline code span holds whole (`` `~/a b/c.mp4` `` or `` `src/x.ts:12` ``), if it holds one. */
50export function pathOfSpan (code: string): string | undefined {
51  const text = code.trim()
52  if (SPAN.test(text)) return text.replace(LINE, '')
53  const found = pathsIn(text)
54  return found.length === 1 && found[0]!.start === 0 && found[0]!.end === text.length ? found[0]!.target : undefined
55}
56
57/**
58 * Where `target` lands: home for `~`, the working folder for a relative path; `.` and `..`
59 * folded, no trailing `/`. A Windows path stays as written.
60 */
61export function resolve (target: string, cwd: string, home: string | undefined) {
62  if (/^[A-Za-z]:\\/.test(target)) return target
63  let path = target
64  if (path === '~' || path.startsWith('~/')) {
65    if (!home) return undefined
66    path = home + path.slice(1)
67  } else if (!path.startsWith('/')) {
68    path = `${cwd}/${path}`
69  }
70  const parts: string[] = []
71  for (const part of path.split('/')) {
72    if (part === '' || part === '.') continue
73    if (part === '..') parts.pop()
74    else parts.push(part)
75  }
76  return `/${parts.join('/')}`
77}
78
hooks/window.ts 175 lines
1// The program that previews a path in a window of its own, by the pointer, outside the terminal:
2// macOS's, run by `osascript` with what it needs in the environment (QUICK_PEEK_FILE, the path;
3// QUICK_PEEK_LIFE, how many seconds at most it stays; QUICK_PEEK_HINT, the line that says what a
4// click does). It writes `shown` once the window is up, or a line starting `error`, then a `.`
5// every tenth of a second, so the one reading it is woken to end it, and it ends when nobody
6// reads. The window never takes the focus or the pointer: the terminal keeps both.
7
8/**
9 * macOS: JavaScript for Automation drawing an AppKit panel. A file shows its Quick Look thumbnail
10 * (a video's frame, an image, a document's first page), else its icon; a folder its icon and its
11 * first entries. Under it, the name, the kind, size and age as the system words them, and where it is.
12 */
13export const MACOS = String.raw`
14ObjC.import('Cocoa')
15ObjC.import('QuickLook')
16
17function run () {
18  const env = $.NSProcessInfo.processInfo.environment
19  const read = key => { const value = env.objectForKey(key); return value.isNil() ? '' : ObjC.unwrap(value) }
20  const say = text => $.NSFileHandle.fileHandleWithStandardOutput.writeData($(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding))
21  const path = read('QUICK_PEEK_FILE')
22  const files = $.NSFileManager.defaultManager
23  const isFolder = Ref()
24  if (!files.fileExistsAtPathIsDirectory(path, isFolder)) { say('error: not there'); return }
25  const folder = !!isFolder[0]
26  // An accessory app: no Dock icon, never the active one.
27  $.NSApplication.sharedApplication.setActivationPolicy(1)
28
29  const mouse = $.NSEvent.mouseLocation
30  const screens = $.NSScreen.screens
31  let screen = $.NSScreen.mainScreen
32  for (let i = 0; i < screens.count; i++) {
33    const frame = screens.objectAtIndex(i).frame
34    if (mouse.x >= frame.origin.x && mouse.x < frame.origin.x + frame.size.width && mouse.y >= frame.origin.y && mouse.y < frame.origin.y + frame.size.height) screen = screens.objectAtIndex(i)
35  }
36  const area = screen.visibleFrame
37  const scale = screen.backingScaleFactor
38  const largest = Math.min(Number(read('QUICK_PEEK_SIZE')) || 360, area.size.width * 0.5, area.size.height * 0.5)
39
40  const url = $.NSURL.fileURLWithPath(path)
41  const keys = [$.NSURLLocalizedTypeDescriptionKey, $.NSURLFileSizeKey, $.NSURLContentModificationDateKey]
42  const values = url.resourceValuesForKeysError($(keys), $())
43  const value = key => (values.isNil() ? $() : values.objectForKey(key))
44
45  // The picture: Quick Look's thumbnail, else the icon Finder shows.
46  let image = $()
47  let width = 0
48  let height = 0
49  if (!folder) {
50    const thumbnail = $.QLThumbnailImageCreate($.kCFAllocatorDefault, url, $.CGSizeMake(largest * scale, largest * scale), $())
51    if (thumbnail && !(thumbnail.isNil && thumbnail.isNil())) {
52      image = $.NSImage.alloc.initWithCGImageSize(thumbnail, $.NSZeroSize)
53      width = image.size.width / scale
54      height = image.size.height / scale
55    }
56  }
57  if (image.isNil() || width === 0) {
58    image = $.NSWorkspace.sharedWorkspace.iconForFile(path)
59    width = height = folder ? 64 : 128
60    image.size = $.NSMakeSize(width, height)
61  }
62
63  // The lines under it.
64  const name = ObjC.unwrap(files.displayNameAtPath(path))
65  const details = []
66  const kind = value($.NSURLLocalizedTypeDescriptionKey)
67  if (!kind.isNil()) details.push(ObjC.unwrap(kind))
68  let entries = []
69  if (folder) {
70    const listed = files.contentsOfDirectoryAtPathError(path, $())
71    if (!listed.isNil()) {
72      entries = ObjC.unwrap(listed.sortedArrayUsingSelector('localizedStandardCompare:')).map(entry => ObjC.unwrap(entry)).filter(entry => !entry.startsWith('.'))
73      details.push(entries.length === 1 ? '1 item' : entries.length + ' items')
74    }
75  } else {
76    const size = value($.NSURLFileSizeKey)
77    if (!size.isNil()) details.push(ObjC.unwrap($.NSByteCountFormatter.stringFromByteCountCountStyle(size.longLongValue, 0)))
78  }
79  const modified = value($.NSURLContentModificationDateKey)
80  if (!modified.isNil()) {
81    const ago = $.NSRelativeDateTimeFormatter.alloc.init
82    details.push(ObjC.unwrap(ago.localizedStringForDateRelativeToDate(modified, $.NSDate.date)))
83  }
84  const home = ObjC.unwrap($.NSHomeDirectory())
85  let where = ObjC.unwrap($(path).stringByDeletingLastPathComponent)
86  if (where === home || where.startsWith(home + '/')) where = '~' + where.slice(home.length)
87
88  const pad = 12
89  const gap = 18
90  const inner = Math.max(width, 280)
91  const label = (text, font, color, lines) => {
92    const field = $.NSTextField.labelWithString($(text))
93    field.font = font
94    field.textColor = color
95    field.lineBreakMode = lines > 1 ? 0 : 5
96    if (lines > 1) field.maximumNumberOfLines = lines
97    field.preferredMaxLayoutWidth = inner
98    const fit = field.sizeThatFits($.NSMakeSize(inner, 1000))
99    field.frame = $.NSMakeRect(0, 0, inner, Math.ceil(fit.height))
100    return field
101  }
102  const rows = []
103  rows.push(label(name, $.NSFont.boldSystemFontOfSize(13), $.NSColor.labelColor, 2))
104  if (details.length > 0) rows.push(label(details.join(' · '), $.NSFont.systemFontOfSize(11), $.NSColor.secondaryLabelColor, 1))
105  if (folder && entries.length > 0) {
106    const shown = entries.slice(0, 10).map(entry => {
107      const inside = Ref()
108      files.fileExistsAtPathIsDirectory(path + '/' + entry, inside)
109      return entry + (inside[0] ? '/' : '')
110    })
111    if (entries.length > shown.length) shown.push('…')
112    rows.push(label(shown.join('\n'), $.NSFont.monospacedSystemFontOfSizeWeight(11, 0), $.NSColor.secondaryLabelColor, shown.length))
113  }
114  rows.push(label(where, $.NSFont.systemFontOfSize(11), $.NSColor.tertiaryLabelColor, 1))
115  const hint = read('QUICK_PEEK_HINT')
116  if (hint) rows.push(label(hint, $.NSFont.systemFontOfSize(10), $.NSColor.tertiaryLabelColor, 1))
117
118  const spacing = 3
119  const texts = rows.reduce((sum, row) => sum + row.frame.size.height, 0) + spacing * (rows.length - 1)
120  const outer = { width: inner + pad * 2, height: pad + height + 10 + texts + pad }
121  const top = area.origin.y + area.size.height
122  const right = area.origin.x + area.size.width
123  // Above the pointer and to its right; under it, or to its left, where there is no room: never
124  // over the pointer, which stays on the terminal.
125  let x = mouse.x + gap
126  let y = mouse.y + gap
127  if (y + outer.height > top) y = mouse.y - gap - outer.height
128  if (x + outer.width > right) x = mouse.x - gap - outer.width
129  x = Math.max(area.origin.x, Math.min(x, right - outer.width))
130  y = Math.max(area.origin.y, Math.min(y, top - outer.height))
131
132  // Borderless and non-activating; over every app window, on every space, full-screen ones too.
133  const panel = $.NSPanel.alloc.initWithContentRectStyleMaskBackingDefer($.NSMakeRect(x, y, outer.width, outer.height), 128, 2, false)
134  panel.level = 25
135  panel.ignoresMouseEvents = true
136  panel.hasShadow = true
137  panel.opaque = false
138  panel.backgroundColor = $.NSColor.clearColor
139  panel.collectionBehavior = 1 | 16 | 256
140  panel.appearance = $.NSAppearance.appearanceNamed('NSAppearanceNameVibrantDark')
141  const backdrop = $.NSVisualEffectView.alloc.initWithFrame($.NSMakeRect(0, 0, outer.width, outer.height))
142  backdrop.material = 13
143  backdrop.state = 1
144  backdrop.wantsLayer = true
145  backdrop.layer.cornerRadius = 10
146  backdrop.layer.masksToBounds = true
147  // AppKit counts from the bottom: the lines first, the picture over them.
148  let at = pad
149  for (let i = rows.length - 1; i >= 0; i--) {
150    const row = rows[i]
151    row.frame = $.NSMakeRect(pad, at, inner, row.frame.size.height)
152    backdrop.addSubview(row)
153    at += row.frame.size.height + spacing
154  }
155  at += 10 - spacing
156  const view = $.NSImageView.alloc.initWithFrame($.NSMakeRect(pad + Math.round((inner - width) / 2), at, width, height))
157  view.image = image
158  view.imageScaling = 3
159  backdrop.addSubview(view)
160  panel.contentView = backdrop
161  if (read('QUICK_PEEK_DRY') === '1') { say('dry ' + Math.round(outer.width) + 'x' + Math.round(outer.height) + ' ' + rows.length + ' rows'); return }
162  panel.orderFrontRegardless
163  say('shown')
164  // A pointer that leaves the terminal says nothing to it: the window also ends when another app
165  // comes to the front, or the pointer goes rows away from the path.
166  const app = $.NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier
167  const end = Date.now() + (Number(read('QUICK_PEEK_LIFE')) || 60) * 1000
168  while (Date.now() < end) {
169    $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1))
170    if (Math.abs($.NSEvent.mouseLocation.y - mouse.y) > 120 || $.NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier !== app) return
171    say('.')
172  }
173}
174`
175
hooks/link.tsx 48 lines
1// The surface module of a path in a reply: it draws its part of the path (a path a row wraps is
2// drawn in parts, one a row), and says when the pointer comes onto it and leaves it, so the hooks
3// module shows that path's preview and hides it; and when it is clicked, so the hooks module
4// shows the path in the file manager (opens it, with alt).
5import type { ClientModule } from 'claude-code'
6
7/** The part drawn, the path it belongs to, and the style of the text around it. */
8type Props = { label: string; path: string; bold?: boolean; italic?: boolean; strike?: boolean }
9
10const REPEAT_MS = 50
11
12const Link: ClientModule<Props, { isOver: boolean; isPressed?: boolean }> = (props, surface) => {
13  surface.onPointer(event => {
14    // A click is the left button let go over the part it went down on.
15    if (event.type === 'down') {
16      if (event.button === 'left') surface.setState({ isOver: true, isPressed: true })
17      return
18    }
19    if (event.type === 'up') {
20      // A region not laid out yet (0 by 0) says nothing of where the pointer is.
21      const isInside = surface.columns === 0 || (event.x >= 0 && event.y >= 0 && event.x < surface.columns && event.y < surface.rows)
22      if (surface.state?.isPressed && isInside) surface.post({ path: props.path, isOver: true, isClicked: true, ...(event.alt && { isAlt: true }) })
23      surface.setState({ isOver: isInside })
24      return
25    }
26    // A move over the part says so again: a preview hidden while the pointer stayed comes back.
27    const isOver = event.type !== 'leave'
28    surface.post({ path: props.path, isOver })
29    if (isOver !== (surface.state?.isOver ?? false)) surface.setState({ isOver, isPressed: surface.state?.isPressed })
30    // Going straight from one part to the next, the leave and the enter post in one frame, and the
31    // later replaces the other: the enter says so again a frame on.
32    if (event.type === 'enter') {
33      const stop = surface.every(REPEAT_MS, () => {
34        stop()
35        if (surface.state?.isOver) surface.post({ path: props.path, isOver: true })
36      })
37    }
38  })
39  const { Text } = surface.elements
40  return (
41    <Text color="permission" bold={props.bold} italic={props.italic} strikethrough={props.strike} underline={surface.state?.isOver === true}>
42      {props.label}
43    </Text>
44  )
45}
46
47export default Link
48