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

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
/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.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).
| System | Click | Hover |
|---|---|---|
| macOS | Finder, the file selected | A window by the pointer, with the Quick Look thumbnail |
| Linux | The file manager, the file selected (org.freedesktop.FileManager1), else its folder with xdg-open | A one-line card over the path: name, size, age |
| Windows | Explorer, the file selected (explorer /select,) | The card |
| WSL | Windows' Explorer, the path turned into its Windows form by wslpath -w | The 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.
/plugin marketplace add midudev/better-claude
/plugin install quick-peek@better-claude
Read(…), a command's output) and in your prompts stay as they are.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):
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.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.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.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.Box holding a card drawn display: "none", which the terminal reveals while the pointer is on the path, with no hook running.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)
hooks/register.tsx 410 lines1import 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}
410hooks/markdown.ts 396 lines1// 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
396hooks/paths.ts 78 lines1// 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}
78hooks/window.ts 175 lines1// 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`
175hooks/link.tsx 48 lines1// 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