A links bar above the prompt in Claude Desktop's Code tab: up to 4 pinned links per project or session with favicons, the session's 10 latest links, and a pane…

English · Русский

A mod for the Code tab of Claude Desktop (macOS) that keeps the links you work with one click away, in a bar above the prompt:
/favicon.ico), and a link pinned without a name takes its page's title. A link with no name shows as its URL without https:// and www..It is built on Claude Code's mods (plugins of function hooks), so it does not patch the Claude app: updates of Claude don't break it, and removing the plugin removes it. It shares the band above the prompt with other mods, such as Claude Tabs: the links row sits under what they draw.
<img src="docs/screenshots/en/recent.png" alt="Hovering Recent shows the session's latest links, with Project and Session columns to pin them" width="64%"> <img src="docs/screenshots/en/pane.png" alt="The All pane: search, sort by name or link, drag to reorder, rename, unpin" width="34%">

<sub>Demo links. The favicons, the letter drawn for a site without one and the words come from the mod's own code; docs/demo/render.ts renders these pictures.</sub>
curl (part of macOS) for favicons in PNG or ICO.claude, where it draws a simpler text version.claude plugin marketplace add tripolskiydigital/claude-links
claude plugin install links-bar@claude-links
Then open a new session in Claude Desktop (or quit Claude with ⌘Q and open it again for sessions that were already open). To update later:
claude plugin update links-bar@claude-links
git clone https://github.com/tripolskiydigital/claude-links.git ~/claude-links
Then add the folder to env in ~/.claude/settings.json (several folders are separated with :):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-links"
}
}
Use one way or the other, not both: with both, the mod loads twice.
The fields are the mod's own: click one to type, Enter saves, and ⌘A, ⌘C, ⌘X, ⌘V and ⌘⌫ work in them.
| What | Where it comes from |
|---|---|
| Recent links | the current session's transcript: what you and Claude wrote, and the links tools were called with (WebFetch, a browser's navigate); tool output is not scanned |
| Pinned links, the chosen sort, the switch position | the plugin's own store (Claude Code keeps it), keyed by project folder or session id |
| Favicons and titles | the link's page, fetched through the mod API's http call ($.http.fetch): its <title> and <link rel="icon">s, then the site's /favicon.ico. SVG icons come the same way; PNG and ICO ones are downloaded with curl, as the http call reads text only. Only the link's own site is asked. Favicons are kept in the plugin's store |
| Interface language | the locale field of ~/Library/Application Support/Claude/config.json, picked out by grep: the mod never reads the file itself, which also holds account data |
| Opening a link | open <url>, your default browser |
Pinned links are read again from the store every 5 seconds, so a project's links pinned in one session show up in its other sessions.
What the mod sends, and where. To find a favicon and a title the mod requests, from the link's own site only, the page of a link you pinned or that the session mentioned, the icons that page names, and the site's /favicon.ico. These are plain GET requests for public pages: they carry no data of yours, no cookies and no credentials, and nothing read from the conversation or your files goes into them beyond the link itself. No other service is asked, and nothing else leaves your Mac.
Reads
$.session.messages).locale field of Claude Desktop's settings, through grep (see the table above).pbpaste, only when you press ⌘V in one of the mod's fields.HOME and TMPDIR.Writes no file of its own outside the temp folder: pinned links, sorts, the switch position and favicons (data URIs of at most 24 KB) go to the plugin store that Claude Code keeps for every plugin. curl writes the PNG and ICO favicons it downloads to $TMPDIR/links-bar-<host>.icon, which the mod then reads (fs.read) and keeps in the store. ⌘C and ⌘X write the clipboard through the app ($.ui.copy).
Runs four programs, each by name; nothing it downloads is ever run:
| Program | Arguments | When | Why |
|---|---|---|---|
open | the link | you click a link | opens it in your default browser |
curl | the icon's address, a 6-second limit, a 24 KB limit, and the temp file to write | a pinned or mentioned link's site has no favicon in the store yet, and its icon is a PNG or ICO | downloads that picture; it is read as an image and kept in the store, never run |
pbpaste | none (UTF-8 is asked for through its environment) | you press ⌘V in one of the mod's fields | reads the clipboard to paste it |
grep | -o -m 1 and a pattern for "locale", in Claude Desktop's config.json | at session start | picks out the interface language without the mod reading the file |
Pages and SVG icons are requested through the mod API's http call, not a program.
Hooks
session.start: reads the pins, the language and the session's recent links, and starts the 5-second refresh of the pins.prompt.submit and turn.complete: read the session's links again. Neither changes the prompt or the turn.ui.render for the band above the prompt and for the mod's own pane; ui.message, ui.focus and ui.close for the mod's own fields, drag handles and pane.The mod adds no commands, tools or agents for Claude, and changes nothing Claude reads.
claude plugin uninstall links-bar@claude-links
Pinned links and favicons go with the plugin's store. The pictures curl downloaded are in $TMPDIR/links-bar-*, which macOS clears by itself.
claude plugin validate .
claude plugin test .
The module is hooks/register.tsx, helpers are in hooks/lib.ts, translations in hooks/i18n.ts, the state contract in types/index.d.ts; the mod's own text field and drag handle are hooks/text-field.tsx and hooks/drag-handle.tsx. The types of the mod API are written by Claude Code into .claude-plugin/types/ when it loads the plugin. The README's pictures and the plugin's icon are rendered by node docs/demo/render.ts (Node 23.6+ and Google Chrome). To iterate with hot reload, ask Claude in a Code session to work on the mod with the plugin-authoring skill, pointing the session's mods folder at your clone.
Issues and pull requests are welcome.
hooks/register.tsx 1113 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren, RenderElement, ResolveInput } from 'claude-code'
3
4import type { Drag, Draft, Lang, LinkSort, PinnedLink, Scope } from '../types'
5import { langOf, t } from './i18n'
6import {
7 BAR_LINKS,
8 BUTTON_HEIGHT,
9 ICON_BOX,
10 asLinks,
11 faviconMime,
12 faviconSvg,
13 hostOf,
14 labelBudget,
15 labelOf,
16 dropIndexY,
17 matches,
18 move,
19 normalizeUrl,
20 parseHead,
21 recentLinks,
22 shortUrl,
23 sortLinks,
24 sortedAs,
25 truncate,
26} from './lib'
27import type { PageMeta, SortBy } from './lib'
28
29type Engine = EngineInterface
30
31const EMPTY_DRAFT: Draft = { url: '', title: '', scope: 'project', edit: null, rev: 0 }
32
33const recent = atom({ plugin: 'links-bar', key: 'recent' } as const, [])
34const projectLinks = atom({ plugin: 'links-bar', key: 'projectLinks' } as const, [])
35const sessionLinks = atom({ plugin: 'links-bar', key: 'sessionLinks' } as const, [])
36const scope = atom({ plugin: 'links-bar', key: 'scope' } as const, 'project')
37const section = atom({ plugin: 'links-bar', key: 'section' } as const, null)
38const draft = atom({ plugin: 'links-bar', key: 'draft' } as const, EMPTY_DRAFT)
39const favicons = atom({ plugin: 'links-bar', key: 'favicons' } as const, {})
40const lang = atom({ plugin: 'links-bar', key: 'lang' } as const, 'en')
41const paneOpen = atom({ plugin: 'links-bar', key: 'paneOpen' } as const, false)
42const search = atom({ plugin: 'links-bar', key: 'search' } as const, '')
43const activeField = atom({ plugin: 'links-bar', key: 'activeField' } as const, null)
44const drag = atom({ plugin: 'links-bar', key: 'drag' } as const, null)
45const sorts = atom({ plugin: 'links-bar', key: 'sorts' } as const, { project: null, session: null })
46
47/** Pins and the scope switch are read again this often: another session may have changed them. */
48const SYNC_MS = 5_000
49/** A favicon past this is not kept: every one lives in the plugin's store (4 MiB in all). */
50const MAX_FAVICON_BYTES = 24_000
51/** A site with no favicon we could read is asked again after this long. */
52const FAVICON_RETRY_MS = 3 * 24 * 60 * 60 * 1000
53
54/** Whether a value differs from the one held, so a quiet refresh writes and redraws nothing. */
55function changed(prev: unknown, next: unknown): boolean {
56 return JSON.stringify(prev) !== JSON.stringify(next)
57}
58
59async function storeKey($: Engine, s: Scope): Promise<string> {
60 return s === 'project' ? `project:${await $.session.root()}` : `session:${await $.session.id()}`
61}
62
63/** Puts a scope's pins in its value; a write that changes nothing writes nothing. */
64async function setPins($: Engine, s: Scope, list: PinnedLink[]): Promise<void> {
65 const held = s === 'project' ? await read($, projectLinks) : await read($, sessionLinks)
66 if (!changed(held, list)) return
67 if (s === 'project') await update($, projectLinks, () => list)
68 else await update($, sessionLinks, () => list)
69 void ensureFavicons($, list.map(l => l.url))
70}
71
72/** A scope's pins as the store holds them: the store, not this session, is the truth. */
73async function pinsOf($: Engine, s: Scope): Promise<PinnedLink[]> {
74 return asLinks(await $.store.get(await storeKey($, s)))
75}
76
77async function savePins($: Engine, s: Scope, list: PinnedLink[]): Promise<void> {
78 await $.store.set(await storeKey($, s), list)
79 await setPins($, s, list)
80}
81
82function asSort(value: unknown): LinkSort | null {
83 if (typeof value !== 'object' || value === null) return null
84 const { by, dir } = value as Record<string, unknown>
85 return (by === 'title' || by === 'url') && (dir === 'asc' || dir === 'desc') ? { by, dir } : null
86}
87
88async function loadPins($: Engine): Promise<void> {
89 for (const s of ['project', 'session'] as const) await setPins($, s, await pinsOf($, s))
90 const stored = {
91 project: asSort(await $.store.get(`sort:${await storeKey($, 'project')}`)),
92 session: asSort(await $.store.get(`sort:${await storeKey($, 'session')}`)),
93 }
94 if (changed(await read($, sorts), stored)) await update($, sorts, () => stored)
95}
96
97async function refreshRecent($: Engine): Promise<void> {
98 const messages = await $.session.messages()
99 if ('deny' in messages) return
100 const links = recentLinks(messages)
101 if (changed(await read($, recent), links)) await update($, recent, () => links)
102 void ensureFavicons($, links.map(l => l.url))
103}
104
105/** The desktop's settings file: its `locale` is the interface language. */
106async function loadLang($: Engine): Promise<Lang> {
107 let found: Lang = 'en'
108 try {
109 const path = `${(await $.env.get('HOME')) ?? ''}/Library/Application Support/Claude/config.json`
110 // The file holds other things (encrypted tokens among them): grep hands back
111 // the one `"locale": "…"` pair and nothing else reaches the mod.
112 const grep = await $.process.run(['grep', '-o', '-m', '1', '"locale"[[:space:]]*:[[:space:]]*"[^"]*"', path])
113 const locale = /"locale"\s*:\s*"([^"]*)"/.exec(grep.stdout)?.[1]
114 if (locale !== undefined) found = langOf(locale)
115 } catch {
116 // No desktop app: English.
117 }
118 if (found !== (await read($, lang))) await update($, lang, () => found)
119 return found
120}
121
122// ── Favicons ────────────────────────────────────────────────────────────────
123
124const fetching = new Set<string>()
125
126type StoredFavicon = { uri: string; at: number }
127
128function asStoredFavicon(value: unknown): StoredFavicon | null {
129 if (typeof value !== 'object' || value === null) return null
130 const v = value as Record<string, unknown>
131 return typeof v.uri === 'string' && typeof v.at === 'number' ? { uri: v.uri, at: v.at } : null
132}
133
134async function tempPath($: Engine, name: string): Promise<string> {
135 const dir = ((await $.env.get('TMPDIR')) ?? '/tmp/').replace(/\/?$/, '/')
136 return `${dir}links-bar-${name.replace(/[^\w.-]/g, '_').slice(0, 80)}`
137}
138
139/** Pages read this load, by URL: a favicon and a title are read off one fetch. */
140const pages = new Map<string, Promise<PageMeta | null>>()
141
142/** Fetches a page through the host's http call and reads its title and declared icons; null when it does not answer. */
143function fetchPage($: Engine, url: string): Promise<PageMeta | null> {
144 const known = pages.get(url)
145 if (known !== undefined) return known
146 const loading = (async () => {
147 try {
148 const res = await $.http.fetch(url)
149 const type = res.headers['content-type'] ?? ''
150 if (!res.ok || (type !== '' && !/html|xml/i.test(type))) return null
151 return parseHead(res.text, url)
152 } catch {
153 return null
154 }
155 })()
156 pages.set(url, loading)
157 return loading
158}
159
160/** An SVG favicon through the host's http call (it is text); a data URI, or null. */
161async function fetchSvgIcon($: Engine, source: string): Promise<string | null> {
162 try {
163 const res = await $.http.fetch(source)
164 const type = (res.headers['content-type'] ?? '').toLowerCase()
165 const isSvg = type.includes('svg') || /^\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/i.test(res.text)
166 if (!res.ok || !isSvg || res.text.length > MAX_FAVICON_BYTES) return null
167 return `data:image/svg+xml;utf8,${encodeURIComponent(res.text)}`
168 } catch {
169 return null
170 }
171}
172
173/**
174 * Downloads one raster favicon (PNG, ICO) with curl, to the temp folder: the
175 * host's http call reads a body as text, which a picture's bytes do not
176 * survive. A data URI when it is a small image, else null.
177 */
178async function fetchImage($: Engine, source: string, tmp: string): Promise<string | null> {
179 try {
180 const got = await $.process.run(
181 ['curl', '-sL', '--max-time', '6', '--max-filesize', String(MAX_FAVICON_BYTES), '-o', tmp, '-w', '%{http_code} %{content_type}', source],
182 { timeoutMs: 10_000 },
183 )
184 const [code, ...type] = got.stdout.trim().split(' ')
185 const mime = faviconMime(type.join(' '))
186 if (got.exitCode !== 0 || code !== '200' || mime === null) return null
187 const stat = await $.fs.stat(tmp)
188 if (stat.size === 0 || stat.size > MAX_FAVICON_BYTES) return null
189 const { base64 } = await $.fs.read(tmp, { as: 'bytes' })
190 return `data:${mime};base64,${base64}`
191 } catch {
192 // curl missing, a timeout, an unreadable file.
193 return null
194 }
195}
196
197/**
198 * A link's favicon: the icons its page declares (`<link rel="icon">`, SVG
199 * first), then the site's /favicon.ico. Only the site itself is asked.
200 */
201async function fetchFavicon($: Engine, url: string, host: string): Promise<string | null> {
202 const origin = new URL(url).origin
203 let page = await fetchPage($, url)
204 // A page that does not answer, or names no icon (a 404), leaves the site's own.
205 if ((page?.icons.length ?? 0) === 0 && `${origin}/` !== url) page = await fetchPage($, `${origin}/`)
206 const sources = [...(page?.icons ?? []), `${origin}/favicon.ico`]
207 const tmp = await tempPath($, `${host}.icon`)
208 for (const source of sources) {
209 const uri = /\.svg(\?|#|$)/i.test(source) ? await fetchSvgIcon($, source) : await fetchImage($, source, tmp)
210 if (uri !== null) return uri
211 }
212 return null
213}
214
215async function loadFavicon($: Engine, url: string, host: string): Promise<void> {
216 // v2: favicons read off the page's head; the first cache held none for such sites.
217 const key = `favicon2:${host}`
218 const stored = asStoredFavicon(await $.store.get(key))
219 const now = await $.clock.now()
220 let uri: string | null
221 if (stored !== null && (stored.uri !== '' || now - stored.at < FAVICON_RETRY_MS)) {
222 uri = stored.uri === '' ? null : stored.uri
223 } else {
224 uri = await fetchFavicon($, url, host)
225 await $.store.set(key, { uri: uri ?? '', at: now })
226 }
227 await update($, favicons, f => ({ ...f, [host]: uri }))
228}
229
230async function ensureFavicons($: Engine, urls: readonly string[]): Promise<void> {
231 const known = await read($, favicons)
232 for (const url of urls) {
233 const host = hostOf(url)
234 if (host === '' || host in known || fetching.has(host)) continue
235 fetching.add(host)
236 void loadFavicon($, url, host).finally(() => fetching.delete(host))
237 }
238}
239
240// ── Actions ─────────────────────────────────────────────────────────────────
241
242async function openUrl($: Engine, url: string): Promise<void> {
243 const { exitCode, stderr } = await $.process.run(['open', url])
244 if (exitCode !== 0) $.ui.toast(t(await read($, lang), 'openFailed', { error: stderr.trim() || url }))
245}
246
247async function setScope($: Engine, s: Scope): Promise<void> {
248 await $.store.set('scope', s)
249 await update($, scope, () => s)
250}
251
252/** `+`: opens the add form under the bar, empty and set to the bar's scope, or closes it. */
253async function toggleAdd($: Engine): Promise<void> {
254 const sc = await read($, scope)
255 await update($, draft, now => ({ ...EMPTY_DRAFT, scope: sc, rev: now.rev + 1 }))
256 await update($, section, now => (now === 'add' ? null : 'add'))
257}
258
259/** «All»: opens the side pane, or closes it when it is open. */
260async function togglePane($: Engine): Promise<void> {
261 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
262 await $.ui.close({ id: PANE })
263 await update($, paneOpen, () => false)
264 return
265 }
266 await update($, section, () => null)
267 const opened = await $.ui.open({
268 id: PANE,
269 title: t(await read($, lang), 'allTitle'),
270 focus: true,
271 closeOnEscape: true,
272 columns: 64,
273 })
274 await update($, paneOpen, () => opened.isPlaced)
275}
276
277/**
278 * Pins `link` to scope `s`, at the end; with `edit`, replaces that pin (in
279 * place when the scope stays). False when the scope already holds the URL.
280 */
281async function pin(
282 $: Engine,
283 s: Scope,
284 link: PinnedLink,
285 edit: Draft['edit'] = null,
286): Promise<boolean> {
287 const l = await read($, lang)
288 const already = () => {
289 $.ui.toast(t(l, s === 'project' ? 'alreadyProject' : 'alreadySession'))
290 return false
291 }
292 let list = await pinsOf($, s)
293 if (edit !== null) {
294 const from = (await pinsOf($, edit.scope)).filter((_, j) => j !== edit.index)
295 if (edit.scope === s) {
296 if (from.some(p => p.url === link.url)) return already()
297 from.splice(edit.index, 0, link)
298 await savePins($, s, from)
299 return true
300 }
301 if (list.some(p => p.url === link.url)) return already()
302 await savePins($, edit.scope, from)
303 list = await pinsOf($, s)
304 } else if (list.some(p => p.url === link.url)) {
305 return already()
306 }
307 const next = [...list, link]
308 await savePins($, s, next)
309 if (link.title === '') void fillTitle($, s, link.url)
310 $.ui.toast(
311 next.length > BAR_LINKS
312 ? t(l, 'pinnedHidden', { max: BAR_LINKS })
313 : t(l, s === 'project' ? 'pinnedProject' : 'pinnedSession', { name: labelOf(link) }),
314 )
315 return true
316}
317
318/** A pin made with no name takes its page's title, unless a name was given meanwhile. */
319async function fillTitle($: Engine, s: Scope, url: string): Promise<void> {
320 const title = (await fetchPage($, url))?.title.slice(0, 120) ?? ''
321 if (title === '') return
322 const list = await pinsOf($, s)
323 const i = list.findIndex(p => p.url === url && p.title === '')
324 if (i < 0) return
325 await savePins($, s, list.map((p, j) => (j === i ? { url, title } : p)))
326}
327
328async function unpin($: Engine, s: Scope, index: number): Promise<void> {
329 await savePins($, s, (await pinsOf($, s)).filter((_, j) => j !== index))
330}
331
332async function moveTo($: Engine, s: Scope, from: number, to: number): Promise<void> {
333 if (from === to) return
334 await savePins($, s, move(await pinsOf($, s), from, to - from))
335}
336
337/** A header's «Name» / «Link»: sorts the list A→Z, or Z→A when it already stands A→Z. */
338async function sortBy($: Engine, s: Scope, by: SortBy): Promise<void> {
339 const list = await pinsOf($, s)
340 // The pressed sort pressed again reverses; any other press starts A→Z.
341 const now = (await read($, sorts))[s]
342 const isLit = now !== null && now.by === by && sortedAs(list, by) === now.dir
343 const next: LinkSort = { by, dir: isLit && now.dir === 'asc' ? 'desc' : 'asc' }
344 await savePins($, s, sortLinks(list, by, next.dir))
345 await $.store.set(`sort:${await storeKey($, s)}`, next)
346 await update($, sorts, all => ({ ...all, [s]: next }))
347}
348
349/** Rows an entry of the «All» pane takes with the gap after it: name, URL, gap. */
350const PANE_ENTRY_ROWS = 3
351
352/** A drag handle reports: a press starts a drag, moves aim it, the release drops it. */
353async function onDrag($: Engine, element: string, data: unknown): Promise<void> {
354 const m = /^drag-(pane)-(project|session)-(\d+)$/.exec(element)
355 if (m === null || typeof data !== 'object' || data === null) return
356 const where = m[1] as Drag['where']
357 const s = m[2] as Scope
358 const from = Number(m[3])
359 const { kind, dy = 0 } = data as { kind?: string; dx?: number; dy?: number }
360 const count = (s === 'project' ? await read($, projectLinks) : await read($, sessionLinks)).length
361 const to = dropIndexY(from, dy, count, PANE_ENTRY_ROWS)
362 if (kind === 'start' || kind === 'move') {
363 const next: Drag = { where, scope: s, from, to }
364 if (changed(await read($, drag), next)) await update($, drag, () => next)
365 return
366 }
367 if (kind === 'drop') {
368 await update($, drag, () => null)
369 await moveTo($, s, from, to)
370 }
371}
372
373async function closeDraft($: Engine): Promise<void> {
374 await update($, draft, now => ({ ...EMPTY_DRAFT, rev: now.rev + 1 }))
375 await update($, section, () => null)
376}
377
378async function submitDraft($: Engine, patch: Partial<Draft>): Promise<void> {
379 const d = { ...(await read($, draft)), ...patch }
380 const url = normalizeUrl(d.url)
381 if (url === null) {
382 $.ui.toast(t(await read($, lang), 'badUrl', { url: d.url.trim() || '—' }))
383 return
384 }
385 if (await pin($, d.scope, { url, title: d.title.trim() }, d.edit)) await closeDraft($)
386}
387
388/** ✎ in the pane: the form opens under that row, filled in. */
389async function editPin($: Engine, s: Scope, index: number, link: PinnedLink): Promise<void> {
390 await update($, section, () => null)
391 await update($, draft, now =>
392 now.edit?.scope === s && now.edit.index === index
393 ? { ...EMPTY_DRAFT, rev: now.rev + 1 }
394 : { url: link.url, title: link.title, scope: s, edit: { scope: s, index }, rev: now.rev + 1 },
395 )
396}
397
398/** ✎ in «Recent»: the form under the bar opens on that pin, filled in. */
399async function renamePin($: Engine, s: Scope, index: number): Promise<void> {
400 const link = (await pinsOf($, s))[index]
401 if (link === undefined) return
402 await update($, draft, now => ({ url: link.url, title: link.title, scope: s, edit: { scope: s, index }, rev: now.rev + 1 }))
403 await update($, section, () => 'add')
404}
405
406// ── Drawing ─────────────────────────────────────────────────────────────────
407
408const PANE = 'links-bar'
409
410function isEmptyTree(tree: RenderElement | null | undefined): boolean {
411 if (tree == null) return true
412 return tree.type === 'Box' && (tree.children === undefined || tree.children.length === 0)
413}
414
415type Kit = {
416 Box: Elements['desktop']['Box']
417 Text: Elements['desktop']['Text']
418 Button: Elements['desktop']['Button']
419 Svg?: Elements['desktop']['Svg']
420 Input?: Elements['desktop']['Input']
421 /** The desktop's rows start transparent, so a hover has a color to paint over. */
422 base: { backgroundColor?: string }
423 hover: string
424 /** A Button inside a hovered row: the row's fill is enough. */
425 quiet: { hover?: { backgroundColor: string } }
426 /** A pane entry's frame: on the desktop a thin rounded grey border, so its hover fill is rounded too. */
427 card: { borderStyle?: string; borderColor?: string; paddingX?: number }
428 icons: Record<string, string | null>
429}
430
431function kitOf(els: ReturnType<EngineInterface['ui']['resolve']>, surface: string, icons: Kit['icons']): Kit {
432 const isTerminal = surface === 'terminal'
433 return {
434 Box: els.Box,
435 Text: els.Text,
436 Button: els.Button,
437 ...('Svg' in els ? { Svg: els.Svg } : {}),
438 ...('Input' in els ? { Input: els.Input } : {}),
439 base: isTerminal ? {} : { backgroundColor: 'transparent' },
440 hover: isTerminal ? 'userMessageBackground' : 'rgba(128, 128, 128, 0.14)',
441 quiet: isTerminal ? {} : { hover: { backgroundColor: 'transparent' } },
442 card: isTerminal ? {} : { borderStyle: 'round', borderColor: 'inactive', paddingX: 1 },
443 icons,
444 }
445}
446
447function linkIcon(k: Kit, url: string) {
448 if (k.Svg === undefined) return null
449 const { Svg } = k
450 const host = hostOf(url)
451 return <Svg source={faviconSvg(k.icons[host], host)} alt={host} width={ICON_BOX} height={ICON_BOX} />
452}
453
454/**
455 * A drag handle, the mod's own drag-handle.tsx, which reports the pointer's
456 * moves; nothing on a surface that draws no surface module. The element is
457 * taken from the table here and its module named in the tag, as the plugin
458 * directory reads them.
459 */
460function dragHandle($: Engine, e: ResolveInput, key: string, isDragging: boolean, width = 2) {
461 if (e.surface !== 'desktop' && e.surface !== 'terminal') return null
462 const els = $.ui.resolve(e)
463 return <els.Client key={key} module="./drag-handle.tsx" props={{ glyph: '⠿', isDragging }} width={width} height={1} />
464}
465
466type FieldProps = { value: string; placeholder: string; isActive: boolean; icon: string; rev: number }
467
468/** One of the mod's text fields (text-field.tsx), as wide as `width`; nothing where none is drawn. */
469function textField($: Engine, e: ResolveInput, key: string, props: FieldProps, width: string) {
470 if (e.surface !== 'desktop' && e.surface !== 'terminal') return null
471 const els = $.ui.resolve(e)
472 return <els.Client key={key} width={width} module="./text-field.tsx" props={props} />
473}
474
475/** Whether the surface draws the mod's own fields and handles. */
476function drawsModules(e: ResolveInput): boolean {
477 return e.surface === 'desktop' || e.surface === 'terminal'
478}
479
480/**
481 * A link's entry in the pane: favicon, name (opens it), ✎ and ✕ on the first
482 * line; the URL across the whole second line, cut at its end, shown whole in a
483 * card under the pointer. On the desktop the entry is a rounded card that
484 * lights under the pointer.
485 */
486function linkRow(
487 $: Engine,
488 k: Kit,
489 key: string,
490 link: PinnedLink,
491 width: number,
492 actions: RenderChildren,
493 isDim = false,
494 handle: RenderChildren = null,
495 isTarget = false,
496) {
497 const { Box, Text, Button } = k
498 const fill = isTarget ? 'userMessageBackground' : undefined
499 return (
500 <Box
501 key={key}
502 flexDirection="row"
503 columnGap={1}
504 {...k.card}
505 {...(fill !== undefined ? { backgroundColor: fill } : k.base)}
506 hover={{ backgroundColor: fill ?? k.hover }}
507 >
508 <Box flexDirection="column" alignItems="center" flexShrink={0} width={3}>
509 {linkIcon(k, link.url)}
510 {handle}
511 </Box>
512 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
513 <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={1}>
514 <Box flexShrink={1} minWidth={0} overflow="hidden">
515 <Button
516 key={`${key}-open`}
517 label={truncate(labelOf(link), width, false)}
518 plain
519 {...(isDim ? { dimColor: true } : {})}
520 {...k.quiet}
521 onPress={() => void openUrl($, link.url)}
522 />
523 </Box>
524 <Box flexDirection="row" alignItems="center" flexShrink={0} columnGap={1}>
525 {actions}
526 </Box>
527 </Box>
528 {/* A Button as the name is: the same inset, so both start at one edge. */}
529 <Box key={`${key}-url`} position="relative" minWidth={0}>
530 <Button
531 key={`${key}-url-open`}
532 label={truncate(shortUrl(link.url), width + 6)}
533 plain
534 dimColor
535 {...k.quiet}
536 onPress={() => void openUrl($, link.url)}
537 />
538 <Box
539 position="absolute"
540 top={1}
541 left={0}
542 display="none"
543 hover={{ display: 'flex' }}
544 paddingX={1}
545 borderStyle="round"
546 borderColor="inactive"
547 backgroundColor="userMessageBackground"
548 >
549 <Text>{link.url}</Text>
550 </Box>
551 </Box>
552 </Box>
553 </Box>
554 )
555}
556
557/**
558 * The clipboard's text, read with macOS's pbpaste when the person presses ⌘V
559 * in one of the mod's fields (the API writes a clipboard but reads none);
560 * newlines become spaces, as the fields hold one line.
561 */
562async function readClipboard($: Engine): Promise<string> {
563 try {
564 // pbpaste writes in the user's text encoding (MacCyrillic here, say), and
565 // stdout is read as UTF-8: Cyrillic came out as �. UTF-8 is asked for.
566 const got = await $.process.run(['pbpaste'], {
567 timeoutMs: 3_000,
568 env: { __CF_USER_TEXT_ENCODING: '0x1F5:0x8000100:0x8000100', LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
569 })
570 return got.exitCode === 0 ? got.stdout.replace(/[\r\n\t]+/g, ' ').trim() : ''
571 } catch {
572 return ''
573 }
574}
575
576/** Room before a bar chip's name for the favicon laid over its button: figure spaces keep their width. */
577const ICON_ROOM = '\u2007\u2007\u2007'
578
579/** The text fields the mod draws: a field's key is where its posts go. */
580const FIELDS = ['search', 'draft-title', 'draft-url'] as const
581
582/** What the form's buttons do; made in the hooks, where `$` is. */
583type FormActions = { scope: (s: Scope) => void; cancel: () => void; save: () => void }
584
585/**
586 * The add / edit form: name, link, Project / Session, Cancel, Save. Under the
587 * bar it is one row across the band, the fields sharing what the buttons
588 * leave; in the pane (`isNarrow`) the fields take a row each, the choice and
589 * the buttons the last. It only lays out: the fields (text-field.tsx) and
590 * the actions come from the hook that draws it.
591 */
592function formLayout(
593 k: Kit,
594 l: Lang,
595 d: Draft,
596 title: RenderChildren,
597 url: RenderChildren,
598 act: FormActions,
599 isNarrow = false,
600) {
601 const { Box, Button } = k
602 // One height for both: the chosen scope is lit (primary, as «All» is), the
603 // other dim; a new link starts on the bar's scope, «Project» by default.
604 const scopes = (
605 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
606 {(['project', 'session'] as const).map(s => (
607 <Button
608 key={`draft-${s}`}
609 label={t(l, s)}
610 {...(d.scope === s ? { variant: 'primary' as const } : { dimColor: true })}
611 onPress={() => act.scope(s)}
612 />
613 ))}
614 </Box>
615 )
616 const buttons = (
617 <Box flexDirection="row" alignItems="center" justifyContent="flex-end" columnGap={1} flexShrink={0}>
618 <Button key="draft-cancel" label={t(l, 'cancel')} onPress={act.cancel} />
619 <Button key="draft-save" label={t(l, 'saveEdit')} variant="primary" onPress={act.save} />
620 </Box>
621 )
622 if (isNarrow) {
623 return (
624 <Box flexDirection="column" alignItems="stretch" rowGap={1}>
625 {title}
626 {url}
627 <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={1} flexWrap="wrap">
628 {scopes}
629 {buttons}
630 </Box>
631 </Box>
632 )
633 }
634 // Under the bar each field fills a Box that grows from no width: half each.
635 return (
636 <Box flexDirection="row" alignItems="center" columnGap={1}>
637 <Box key="draft-title-box" flexGrow={1} flexShrink={1} width={0} minWidth={0}>
638 {title}
639 </Box>
640 <Box key="draft-url-box" flexGrow={1} flexShrink={1} width={0} minWidth={0}>
641 {url}
642 </Box>
643 {scopes}
644 {buttons}
645 </Box>
646 )
647}
648
649/** The form's field props: its text, hint and whether it holds the keys. */
650function fieldProps(l: Lang, d: Draft, key: 'draft-title' | 'draft-url', active: string | null): FieldProps {
651 return key === 'draft-title'
652 ? { value: d.title, placeholder: t(l, 'titlePlaceholder'), isActive: active === key, icon: '', rev: d.rev }
653 : { value: d.url, placeholder: t(l, 'urlPlaceholder'), isActive: active === key, icon: '', rev: d.rev }
654}
655
656export const register: Register = on => {
657 on('session.start', async ($, e, next) => {
658 // Favicons are read again from the store at each load: a host the session
659 // marked as having none (by an older load) is looked up afresh.
660 await update($, favicons, () => ({}))
661 // A form left open by the last load starts closed: the band always draws.
662 await update($, section, () => null)
663 await update($, draft, now => ({ ...EMPTY_DRAFT, rev: now.rev + 1 }))
664 await loadLang($)
665 const stored = await $.store.get('scope')
666 if (stored === 'project' || stored === 'session') await update($, scope, () => stored)
667 await loadPins($)
668 void refreshRecent($)
669 $.clock.every(SYNC_MS, () => void loadPins($))
670 return next(e)
671 })
672
673 on('prompt.submit', async ($, e, next) => {
674 const result = await next(e)
675 $.clock.after(500, () => void refreshRecent($))
676 return result
677 })
678
679 on('turn.complete', async ($, e, next) => {
680 if (e.agentId === undefined) void refreshRecent($)
681 return next(e)
682 })
683
684 // The pane's close mark or Escape closes it too: «All» lights only while it is open.
685 on('ui.message', async ($, e, next) => {
686 if (e.module.endsWith('drag-handle.tsx')) await onDrag($, e.element, e.data)
687 if (e.module.endsWith('text-field.tsx') && typeof e.data === 'object' && e.data !== null) {
688 const { kind, value, isSelected } = e.data as { kind?: string; value?: unknown; isSelected?: unknown }
689 let text = typeof value === 'string' ? value : null
690 if ((kind === 'copy' || kind === 'cut') && text !== null && text !== '') await $.ui.copy({ text, surface: e.surface })
691 // ⌘X: copied above, and the field is emptied.
692 if (kind === 'cut') text = ''
693 if (kind === 'paste' && text !== null) {
694 // ⌘V in a field of the mod's: the clipboard, one line, over the selection or at the end.
695 const clip = await readClipboard($)
696 text = clip === '' ? text : `${isSelected === true ? '' : text}${clip}`
697 }
698 if (kind === 'focus' || kind === 'change' || kind === 'paste' || kind === 'cut') await update($, activeField, () => e.element)
699 if (e.element === 'search' && text !== null) await update($, search, () => text)
700 if (e.element === 'draft-title' && text !== null) await update($, draft, now => ({ ...now, title: text }))
701 if (e.element === 'draft-url' && text !== null) await update($, draft, now => ({ ...now, url: text }))
702 if (kind === 'submit' && e.element !== 'search') await submitDraft($, {})
703 }
704 return next(e)
705 })
706
707 // The ring moving to another element takes the keys from the mod's fields.
708 on('ui.focus', async ($, e, next) => {
709 if (!FIELDS.some(f => f === e.element)) await update($, activeField, () => null)
710 return next(e)
711 })
712
713 on('ui.close', async ($, e, next) => {
714 const result = await next(e)
715 if (e.id === PANE) await update($, paneOpen, () => false)
716 return result
717 })
718
719 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
720 // The band is one site: what the plugins beneath draw (project tabs, say)
721 // stays, and the links row goes under it.
722 const below = await next(e)
723 if (e.props.hasSurvey) return below
724 // A fault in the links row costs the row alone: what the plugins beneath
725 // drew (the tabs) is drawn as it is.
726 try {
727
728 const [rec, pLinks, sLinks, sc, sec, d, icons, l, isPaneOpen, dragged, typing] = await Promise.all([
729 read($, recent),
730 read($, projectLinks),
731 read($, sessionLinks),
732 read($, scope),
733 read($, section),
734 read($, draft),
735 read($, favicons),
736 read($, lang),
737 read($, paneOpen),
738 read($, drag),
739 read($, activeField),
740 ])
741 const k = kitOf($.ui.resolve(e), e.surface, icons)
742 const { Box, Text, Button } = k
743 // The add form's parts that need `$`, made here: the fields and the buttons' actions.
744 // Made only while the form is open: a surface module made and not drawn
745 // left the whole band undrawn, the tabs too.
746 const formTitle = sec === 'add' ? textField($, e, 'draft-title', fieldProps(l, d, 'draft-title', typing), '100%') : null
747 const formUrl = sec === 'add' ? textField($, e, 'draft-url', fieldProps(l, d, 'draft-url', typing), '100%') : null
748 const formActions: FormActions = {
749 scope: s => void update($, draft, now => ({ ...now, scope: s })),
750 cancel: () => void closeDraft($),
751 save: () => void submitDraft($, {}),
752 }
753 const budget = labelBudget(e.props.bodyColumns, Math.min(BAR_LINKS, (sc === 'project' ? pLinks : sLinks).length))
754
755 const links = sc === 'project' ? pLinks : sLinks
756 const onBar = links.slice(0, BAR_LINKS)
757 const total = pLinks.length + sLinks.length
758
759 const scopeSwitch = (
760 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
761 {/* The chosen scope is the desktop's own (rounded) button; the other one is
762 plain and dim. A Box fill behind a button draws square corners. */}
763 {(['project', 'session'] as const).map(s =>
764 s === sc ? (
765 <Button key={`scope-${s}`} label={t(l, s)} onPress={() => void setScope($, s)} />
766 ) : (
767 <Button key={`scope-${s}`} label={t(l, s)} plain dimColor onPress={() => void setScope($, s)} />
768 ),
769 )}
770 </Box>
771 )
772
773 const chips =
774 onBar.length === 0 ? (
775 <Text dimColor wrap="truncate">
776 {t(l, sc === 'project' ? 'emptyProject' : 'emptySession')}
777 </Text>
778 ) : (
779 onBar.map((link, i) => {
780 const name = labelOf(link)
781 const shown = truncate(name, budget)
782 // The chip is the desktop's own button, so it lights rounded and as
783 // tall as «Recent» and «All». The favicon lies over the button's left
784 // end, room kept for it by figure spaces (plain ones would collapse).
785 const host = hostOf(link.url)
786 return (
787 <Box key={`chip-${i}`} position="relative" flexShrink={0}>
788 <Button
789 key={`open-${i}`}
790 label={k.Svg !== undefined ? `${ICON_ROOM}${shown}` : shown}
791 plain
792 onPress={() => void openUrl($, link.url)}
793 />
794 {k.Svg !== undefined && (
795 <Box position="absolute" top={0} left={1}>
796 <k.Svg
797 source={faviconSvg(icons[host], host, BUTTON_HEIGHT)}
798 alt={host}
799 width={ICON_BOX}
800 height={BUTTON_HEIGHT}
801 />
802 </Box>
803 )}
804 {/* The full name and the URL, over the chip under the pointer. */}
805 <Box
806 position="absolute"
807 bottom={2}
808 left={0}
809 display="none"
810 hover={{ display: 'flex' }}
811 flexDirection="column"
812 minWidth={Math.min(64, Math.max([...name].length, shortUrl(link.url).length) + 4)}
813 paddingX={1}
814 borderStyle="round"
815 borderColor="inactive"
816 backgroundColor="userMessageBackground"
817 >
818 <Text>{name}</Text>
819 <Text dimColor wrap="truncate">
820 {shortUrl(link.url)}
821 </Text>
822 </Box>
823 </Box>
824 )
825 })
826 )
827
828 // «Recent» as a tab's recent sessions in the tabs mod: hovering the button
829 // shows the list above it, sized to its rows; the pointer may move into it.
830 const recentWidth = Math.max(16, Math.min(40, Math.floor(e.props.bodyColumns / 3)))
831 // Two columns, «Project» and «Session», named once in the header: 📌 pins the
832 // link there, ✓ says it is pinned there and unpins it; ✎ renames a pinned one.
833 // Every row is as wide as the header: the name has a column of its own, so the
834 // marks stand under their titles whatever the name's length.
835 const columnWidth = Math.max([...t(l, 'project')].length, [...t(l, 'session')].length) + 3
836 // The window's width is set, not left to its rows, and it fits the band: its
837 // right edge at the button's, it opens leftward over the bar.
838 const columnsWidth = 2 * columnWidth + 3
839 const popupChrome = 2 + 2 + 2
840 const nameWidth = Math.max(12, Math.min(recentWidth + 4, e.props.bodyColumns - 4 - columnsWidth - popupChrome))
841 const popupWidth = nameWidth + columnsWidth + popupChrome
842 const recentButtonWidth = [...t(l, 'recent')].length + 4
843 const columns = (cells: [RenderChildren, RenderChildren, RenderChildren]) => (
844 <Box flexDirection="row" alignItems="center" flexShrink={0}>
845 <Box width={columnWidth} justifyContent="center">
846 {cells[0]}
847 </Box>
848 <Box width={columnWidth} justifyContent="center">
849 {cells[1]}
850 </Box>
851 <Box width={3} justifyContent="center">
852 {cells[2]}
853 </Box>
854 </Box>
855 )
856 const recentList = (
857 <Box
858 position="absolute"
859 bottom={2}
860 left={recentButtonWidth - popupWidth}
861 width={popupWidth}
862 display="none"
863 hover={{ display: 'flex' }}
864 flexDirection="column"
865 paddingX={1}
866 borderStyle="round"
867 borderColor="inactive"
868 backgroundColor="userMessageBackground"
869 >
870 <Box flexDirection="row" alignItems="center" columnGap={2}>
871 <Box width={nameWidth} flexShrink={0} overflow="hidden">
872 <Text dimColor wrap="truncate">
873 {t(l, 'recentTitle')}
874 </Text>
875 </Box>
876 {rec.length > 0 &&
877 columns([
878 <Text dimColor>{t(l, 'project')}</Text>,
879 <Text dimColor>{t(l, 'session')}</Text>,
880 null,
881 ])}
882 </Box>
883 {rec.length === 0 && <Text dimColor>{t(l, 'recentEmpty')}</Text>}
884 {rec.map((link, i) => {
885 const projectIndex = pLinks.findIndex(p => p.url === link.url)
886 const sessionIndex = sLinks.findIndex(p => p.url === link.url)
887 const pinCell = (s: Scope, index: number) =>
888 index >= 0 ? (
889 <Button key={`recent-${s}-${i}`} label="✓" plain onPress={() => void unpin($, s, index)} />
890 ) : (
891 <Button key={`recent-${s}-${i}`} label="📌" plain dimColor onPress={() => void pin($, s, link)} />
892 )
893 // Rename the pin the bar shows now, else the other one.
894 const editable: [Scope, number] | null =
895 (sc === 'session' ? sessionIndex : projectIndex) >= 0
896 ? [sc, sc === 'session' ? sessionIndex : projectIndex]
897 : projectIndex >= 0
898 ? ['project', projectIndex]
899 : sessionIndex >= 0
900 ? ['session', sessionIndex]
901 : null
902 return (
903 <Box flexDirection="row" alignItems="center" columnGap={2}>
904 <Box width={nameWidth} flexShrink={0} flexDirection="row" alignItems="center" columnGap={1} overflow="hidden">
905 {linkIcon(k, link.url)}
906 <Button
907 key={`recent-${i}-open`}
908 label={truncate(labelOf(link), nameWidth - 4, false)}
909 plain
910 onPress={() => void openUrl($, link.url)}
911 />
912 </Box>
913 {columns([
914 pinCell('project', projectIndex),
915 pinCell('session', sessionIndex),
916 editable !== null ? (
917 <Button
918 key={`recent-edit-${i}`}
919 label="✎"
920 plain
921 dimColor
922 onPress={() => void renamePin($, editable[0], editable[1])}
923 />
924 ) : null,
925 ])}
926 </Box>
927 )
928 })}
929 </Box>
930 )
931
932 const bar = (
933 <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={1}>
934 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={1}>
935 {scopeSwitch}
936 <Box key="add-box" flexShrink={0}>
937 <Button
938 key="add"
939 label="+"
940 {...(sec === 'add' ? { variant: 'primary' as const } : {})}
941 onPress={() => void toggleAdd($)}
942 />
943 </Box>
944 {chips}
945 </Box>
946 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
947 <Box key="recent-anchor" position="relative" flexShrink={0}>
948 <Button key="recent" label={t(l, 'recent')} onPress={() => void refreshRecent($)} />
949 {recentList}
950 </Box>
951 <Button
952 key="all"
953 label={total > 0 ? `${t(l, 'all')} · ${total}` : t(l, 'all')}
954 {...(isPaneOpen ? { variant: 'primary' as const } : {})}
955 onPress={() => void togglePane($)}
956 />
957 </Box>
958 </Box>
959 )
960
961 const mine =
962 sec === 'add' ? (
963 <Box flexDirection="column" rowGap={1}>
964 {bar}
965 {formLayout(k, l, d, formTitle, formUrl, formActions)}
966 </Box>
967 ) : (
968 bar
969 )
970 return isEmptyTree(below) ? mine : (
971 <Box flexDirection="column" rowGap={1}>
972 {below}
973 {mine}
974 </Box>
975 )
976 } catch {
977 return below
978 }
979 })
980
981 // «All»: every pinned link of the project and the session, in a side pane:
982 // search, sort by name or link, drag by the handle, rename, move, unpin.
983 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
984 const [pLinks, sLinks, d, icons, l, query, dragged, typingIn, pressed] = await Promise.all([
985 read($, projectLinks),
986 read($, sessionLinks),
987 read($, draft),
988 read($, favicons),
989 read($, lang),
990 read($, search),
991 read($, drag),
992 read($, activeField),
993 read($, sorts),
994 ])
995 const k = kitOf($.ui.resolve(e), e.surface, icons)
996 const { Box, Text, Button, Input } = k
997 // A field of the pane holds the keys only while the pane has the focus.
998 const isTyping = e.props.isFocused ? typingIn : null
999 // The edit form's parts that need `$`, made here: the fields and the buttons' actions.
1000 // Made only while a link is edited: a surface module made and not drawn
1001 // left the whole site undrawn.
1002 const paneTitle = d.edit !== null ? textField($, e, 'draft-title', fieldProps(l, d, 'draft-title', isTyping), '100%') : null
1003 const paneUrl = d.edit !== null ? textField($, e, 'draft-url', fieldProps(l, d, 'draft-url', isTyping), '100%') : null
1004 const paneActions: FormActions = {
1005 scope: s => void update($, draft, now => ({ ...now, scope: s })),
1006 cancel: () => void closeDraft($),
1007 save: () => void submitDraft($, {}),
1008 }
1009 // The name has its line to itself, the URL and the buttons the next one.
1010 const width = Math.max(16, e.props.bodyColumns - 14)
1011 const isSearching = query.trim() !== ''
1012 const groups: [Scope, PinnedLink[], string][] = [
1013 ['project', pLinks, t(l, 'project')],
1014 ['session', sLinks, t(l, 'sessionSection')],
1015 ]
1016
1017 const sortButton = (s: Scope, list: PinnedLink[], by: SortBy) => {
1018 // Only the pressed sort lights, and only while the list stands in its order
1019 // (a drag undoes it); two lists may stand sorted both ways at once.
1020 const sort = pressed[s]
1021 const dir = sort !== null && sort.by === by && sortedAs(list, by) === sort.dir ? sort.dir : null
1022 const label = t(l, by === 'title' ? 'byTitle' : 'byUrl')
1023 // Both are the desktop's own button, so lighting one moves nothing: the
1024 // pressed sort is primary (lit as «All» is), the other dim. The arrow is
1025 // the order: ↓ A→Z, what a press gives first; ↑ Z→A.
1026 return (
1027 <Button
1028 key={`sort-${s}-${by}`}
1029 label={`${label} ${dir === 'desc' ? '↑' : '↓'}`}
1030 {...(dir === null ? { dimColor: true } : { variant: 'primary' as const })}
1031 onPress={() => void sortBy($, s, by)}
1032 />
1033 )
1034 }
1035
1036 return (
1037 <Box flexDirection="column" rowGap={1}>
1038 {drawsModules(e) ? (
1039 textField($, e, 'search', { value: query, placeholder: t(l, 'search'), isActive: isTyping === 'search', icon: '🔍', rev: 0 }, '100%')
1040 ) : (
1041 Input !== undefined && (
1042 <Input
1043 key="search"
1044 placeholder={t(l, 'search')}
1045 value={query}
1046 onInput={value => void update($, search, () => value)}
1047 onSubmit={value => void update($, search, () => value)}
1048 />
1049 )
1050 )}
1051 {groups.map(([s, list, title]) => {
1052 const shown = list.map((link, i) => ({ link, i })).filter(({ link }) => matches(link, query))
1053 const paneDrag = dragged?.where === 'pane' && dragged.scope === s ? dragged : null
1054 // Dragging needs the whole list in its place: not while searching or editing.
1055 const canDrag = !isSearching && d.edit === null && list.length > 1
1056 return (
1057 <Box key={`group-${s}`} flexDirection="column" rowGap={1}>
1058 <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={1}>
1059 <Text bold wrap="truncate-end">{`${title} (${list.length})`}</Text>
1060 {list.length > 1 && (
1061 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
1062 {sortButton(s, list, 'title')}
1063 {sortButton(s, list, 'url')}
1064 </Box>
1065 )}
1066 </Box>
1067 {list.length === 0 && <Text dimColor>{t(l, 'none')}</Text>}
1068 {list.length > 0 && shown.length === 0 && <Text dimColor>{t(l, 'noMatches')}</Text>}
1069 {shown.map(({ link, i }) => {
1070 const isEdited = d.edit?.scope === s && d.edit.index === i
1071 const row = linkRow(
1072 $,
1073 k,
1074 `all-${s}-${i}`,
1075 link,
1076 width,
1077 [
1078 <Button key={`all-edit-${s}-${i}`} label="✎" plain onPress={() => void editPin($, s, i, link)} />,
1079 <Button key={`all-unpin-${s}-${i}`} label="✕" plain onPress={() => void unpin($, s, i)} />,
1080 ],
1081 i >= BAR_LINKS,
1082 // One cell wide, so the column centres it under the favicon.
1083 canDrag ? dragHandle($, e, `drag-pane-${s}-${i}`, paneDrag?.from === i, 1) : null,
1084 paneDrag !== null && paneDrag.to === i && paneDrag.from !== i,
1085 )
1086 return isEdited ? (
1087 <Box key={`all-edit-box-${s}-${i}`} flexDirection="column" rowGap={1}>
1088 {row}
1089 <Box
1090 flexDirection="column"
1091 alignItems="stretch"
1092 marginLeft={3}
1093 marginBottom={1}
1094 paddingX={1}
1095 paddingY={1}
1096 borderStyle="round"
1097 borderColor="inactive"
1098 >
1099 {formLayout(k, l, d, paneTitle, paneUrl, paneActions, true)}
1100 </Box>
1101 </Box>
1102 ) : (
1103 row
1104 )
1105 })}
1106 </Box>
1107 )
1108 })}
1109 </Box>
1110 )
1111 })
1112}
1113hooks/i18n.ts 255 lines1import type { Lang } from '../types'
2
3type Strings = {
4 project: string
5 session: string
6 recent: string
7 all: string
8 allTitle: string
9 add: string
10 recentTitle: string
11 recentEmpty: string
12 urlPlaceholder: string
13 titlePlaceholder: string
14 saveEdit: string
15 cancel: string
16 badUrl: string
17 pinnedProject: string
18 pinnedSession: string
19 pinnedHidden: string
20 alreadyProject: string
21 alreadySession: string
22 sessionSection: string
23 none: string
24 search: string
25 noMatches: string
26 byTitle: string
27 byUrl: string
28 emptyProject: string
29 emptySession: string
30 openFailed: string
31}
32
33const STRINGS: Record<Lang, Strings> = {
34 en: {
35 project: 'Project',
36 session: 'Session',
37 recent: 'Recent',
38 all: 'All',
39 allTitle: 'All links',
40 add: 'Add a link',
41 recentTitle: 'Recent links',
42 recentEmpty: 'No links in this session yet.',
43 urlPlaceholder: 'https://…',
44 titlePlaceholder: 'Name (optional)',
45 saveEdit: 'Save',
46 cancel: 'Cancel',
47 badUrl: 'That is not a link: {url}',
48 pinnedProject: 'Pinned to the project: {name}',
49 pinnedSession: 'Pinned to the session: {name}',
50 pinnedHidden: 'Pinned. The bar shows the first {max}: reorder them in «All».',
51 alreadyProject: 'Already pinned to the project',
52 alreadySession: 'Already pinned to the session',
53 sessionSection: 'This session',
54 none: 'Nothing pinned.',
55 search: 'Search pinned links',
56 noMatches: 'Nothing found.',
57 byTitle: 'Name',
58 byUrl: 'Link',
59 emptyProject: 'No project links pinned',
60 emptySession: 'No session links pinned',
61 openFailed: 'Could not open the link: {error}',
62 },
63 ru: {
64 project: 'Проект',
65 session: 'Сессия',
66 recent: 'Последние',
67 all: 'Все',
68 allTitle: 'Все ссылки',
69 add: 'Добавить ссылку',
70 recentTitle: 'Последние ссылки',
71 recentEmpty: 'В этой сессии пока нет ссылок.',
72 urlPlaceholder: 'https://…',
73 titlePlaceholder: 'Название (необязательно)',
74 saveEdit: 'Сохранить',
75 cancel: 'Отмена',
76 badUrl: 'Это не похоже на ссылку: {url}',
77 pinnedProject: 'Закреплено в проекте: {name}',
78 pinnedSession: 'Закреплено в сессии: {name}',
79 pinnedHidden: 'Закреплено. На панели видны первые {max} — порядок меняется в «Все».',
80 alreadyProject: 'Уже закреплена в проекте',
81 alreadySession: 'Уже закреплена в сессии',
82 sessionSection: 'Эта сессия',
83 none: 'Ничего не закреплено.',
84 search: 'Поиск по закреплённым ссылкам',
85 noMatches: 'Ничего не найдено.',
86 byTitle: 'Название',
87 byUrl: 'Ссылка',
88 emptyProject: 'Нет ссылок проекта',
89 emptySession: 'Нет ссылок сессии',
90 openFailed: 'Не удалось открыть ссылку: {error}',
91 },
92 uk: {
93 project: 'Проєкт',
94 session: 'Сесія',
95 recent: 'Останні',
96 all: 'Усі',
97 allTitle: 'Усі посилання',
98 add: 'Додати посилання',
99 recentTitle: 'Останні посилання',
100 recentEmpty: 'У цій сесії ще немає посилань.',
101 urlPlaceholder: 'https://…',
102 titlePlaceholder: 'Назва (необовʼязково)',
103 saveEdit: 'Зберегти',
104 cancel: 'Скасувати',
105 badUrl: 'Це не схоже на посилання: {url}',
106 pinnedProject: 'Закріплено в проєкті: {name}',
107 pinnedSession: 'Закріплено в сесії: {name}',
108 pinnedHidden: 'Закріплено. На панелі видно перші {max} — порядок змінюється в «Усі».',
109 alreadyProject: 'Уже закріплено в проєкті',
110 alreadySession: 'Уже закріплено в сесії',
111 sessionSection: 'Ця сесія',
112 none: 'Нічого не закріплено.',
113 search: 'Пошук закріплених посилань',
114 noMatches: 'Нічого не знайдено.',
115 byTitle: 'Назва',
116 byUrl: 'Посилання',
117 emptyProject: 'Немає посилань проєкту',
118 emptySession: 'Немає посилань сесії',
119 openFailed: 'Не вдалося відкрити посилання: {error}',
120 },
121 de: {
122 project: 'Projekt',
123 session: 'Sitzung',
124 recent: 'Zuletzt',
125 all: 'Alle',
126 allTitle: 'Alle Links',
127 add: 'Link hinzufügen',
128 recentTitle: 'Letzte Links',
129 recentEmpty: 'In dieser Sitzung gibt es noch keine Links.',
130 urlPlaceholder: 'https://…',
131 titlePlaceholder: 'Name (optional)',
132 saveEdit: 'Speichern',
133 cancel: 'Abbrechen',
134 badUrl: 'Das ist kein Link: {url}',
135 pinnedProject: 'Am Projekt angeheftet: {name}',
136 pinnedSession: 'An der Sitzung angeheftet: {name}',
137 pinnedHidden: 'Angeheftet. Die Leiste zeigt die ersten {max}: Reihenfolge unter «Alle» ändern.',
138 alreadyProject: 'Schon am Projekt angeheftet',
139 alreadySession: 'Schon an der Sitzung angeheftet',
140 sessionSection: 'Diese Sitzung',
141 none: 'Nichts angeheftet.',
142 search: 'Angeheftete Links durchsuchen',
143 noMatches: 'Nichts gefunden.',
144 byTitle: 'Name',
145 byUrl: 'Link',
146 emptyProject: 'Keine Projekt-Links angeheftet',
147 emptySession: 'Keine Sitzungs-Links angeheftet',
148 openFailed: 'Link ließ sich nicht öffnen: {error}',
149 },
150 fr: {
151 project: 'Projet',
152 session: 'Session',
153 recent: 'Récents',
154 all: 'Tous',
155 allTitle: 'Tous les liens',
156 add: 'Ajouter un lien',
157 recentTitle: 'Liens récents',
158 recentEmpty: 'Aucun lien dans cette session pour le moment.',
159 urlPlaceholder: 'https://…',
160 titlePlaceholder: 'Nom (facultatif)',
161 saveEdit: 'Enregistrer',
162 cancel: 'Annuler',
163 badUrl: 'Ce n’est pas un lien : {url}',
164 pinnedProject: 'Épinglé au projet : {name}',
165 pinnedSession: 'Épinglé à la session : {name}',
166 pinnedHidden: 'Épinglé. La barre montre les {max} premiers : réordonnez-les dans « Tous ».',
167 alreadyProject: 'Déjà épinglé au projet',
168 alreadySession: 'Déjà épinglé à la session',
169 sessionSection: 'Cette session',
170 none: 'Rien d’épinglé.',
171 search: 'Rechercher dans les liens épinglés',
172 noMatches: 'Aucun résultat.',
173 byTitle: 'Nom',
174 byUrl: 'Lien',
175 emptyProject: 'Aucun lien de projet épinglé',
176 emptySession: 'Aucun lien de session épinglé',
177 openFailed: 'Impossible d’ouvrir le lien : {error}',
178 },
179 it: {
180 project: 'Progetto',
181 session: 'Sessione',
182 recent: 'Recenti',
183 all: 'Tutti',
184 allTitle: 'Tutti i link',
185 add: 'Aggiungi un link',
186 recentTitle: 'Link recenti',
187 recentEmpty: 'Ancora nessun link in questa sessione.',
188 urlPlaceholder: 'https://…',
189 titlePlaceholder: 'Nome (facoltativo)',
190 saveEdit: 'Salva',
191 cancel: 'Annulla',
192 badUrl: 'Questo non è un link: {url}',
193 pinnedProject: 'Fissato nel progetto: {name}',
194 pinnedSession: 'Fissato nella sessione: {name}',
195 pinnedHidden: 'Fissato. La barra mostra i primi {max}: riordinali in «Tutti».',
196 alreadyProject: 'Già fissato nel progetto',
197 alreadySession: 'Già fissato nella sessione',
198 sessionSection: 'Questa sessione',
199 none: 'Niente di fissato.',
200 search: 'Cerca nei link fissati',
201 noMatches: 'Nessun risultato.',
202 byTitle: 'Nome',
203 byUrl: 'Link',
204 emptyProject: 'Nessun link di progetto fissato',
205 emptySession: 'Nessun link di sessione fissato',
206 openFailed: 'Impossibile aprire il link: {error}',
207 },
208 es: {
209 project: 'Proyecto',
210 session: 'Sesión',
211 recent: 'Recientes',
212 all: 'Todos',
213 allTitle: 'Todos los enlaces',
214 add: 'Añadir un enlace',
215 recentTitle: 'Enlaces recientes',
216 recentEmpty: 'Aún no hay enlaces en esta sesión.',
217 urlPlaceholder: 'https://…',
218 titlePlaceholder: 'Nombre (opcional)',
219 saveEdit: 'Guardar',
220 cancel: 'Cancelar',
221 badUrl: 'Eso no es un enlace: {url}',
222 pinnedProject: 'Fijado en el proyecto: {name}',
223 pinnedSession: 'Fijado en la sesión: {name}',
224 pinnedHidden: 'Fijado. La barra muestra los primeros {max}: reordénalos en «Todos».',
225 alreadyProject: 'Ya está fijado en el proyecto',
226 alreadySession: 'Ya está fijado en la sesión',
227 sessionSection: 'Esta sesión',
228 none: 'Nada fijado.',
229 search: 'Buscar en los enlaces fijados',
230 noMatches: 'No se encontró nada.',
231 byTitle: 'Nombre',
232 byUrl: 'Enlace',
233 emptyProject: 'No hay enlaces de proyecto fijados',
234 emptySession: 'No hay enlaces de sesión fijados',
235 openFailed: 'No se pudo abrir el enlace: {error}',
236 },
237}
238
239export type Key = keyof Strings
240
241export const LANGS = Object.keys(STRINGS) as Lang[]
242
243/** The interface language for a desktop locale (`ru-RU`, `de`): one of LANGS, English otherwise. */
244export function langOf(locale: string | undefined): Lang {
245 const prefix = locale?.toLowerCase().split(/[-_]/)[0]
246 return LANGS.find(lang => lang === prefix) ?? 'en'
247}
248
249/** A string of the interface in `lang`, its `{name}` slots filled. */
250export function t(lang: Lang, key: Key, vars: Record<string, string | number> = {}): string {
251 return STRINGS[lang][key].replace(/\{(\w+)\}/g, (slot, name: string) =>
252 name in vars ? String(vars[name]) : slot,
253 )
254}
255hooks/lib.ts 312 lines1import type { PinnedLink, RecentLink } from '../types'
2
3/** How many pinned links the bar shows; the rest wait in «All links». */
4export const BAR_LINKS = 4
5/** How many of the session's latest links the «Recent links» list shows. */
6export const RECENT_LINKS = 10
7
8/** What a transcript message gives the scan: its text and its tool calls' arguments. */
9export type ScanMessage = {
10 role: 'user' | 'assistant'
11 text: string
12 toolUses: readonly { input: Record<string, unknown> }[]
13}
14
15const URL_RE = /https?:\/\/[^\s<>"'`()[\]{}|\\^]+/g
16const MD_LINK_RE = /\[([^\]\n]{1,200})\]\((https?:\/\/[^\s)]+)\)/g
17/** Blocks the engine adds to a user message: instructions and reminders, not the person's links. */
18const INJECTED_RE = /<(system-reminder|command-[\w-]+|local-command-[\w-]+)>[\s\S]*?<\/\1>/g
19/** Tool arguments that hold a link (WebFetch's `url`, a browser's `navigate`). */
20const URL_KEY_RE = /^(url|href|link|uri)$/i
21
22/** Cuts what a sentence or markdown leaves stuck to a URL's end. */
23export function cleanUrl(raw: string): string {
24 return raw.replace(/[.,;:!?*_~]+$/, '')
25}
26
27function isUrl(text: string): boolean {
28 try {
29 const u = new URL(text)
30 const isWeb = u.protocol === 'http:' || u.protocol === 'https:'
31 return isWeb && (u.hostname.includes('.') || u.hostname === 'localhost')
32 } catch {
33 return false
34 }
35}
36
37/** The links of one piece of text in the order written, with a markdown link's title. */
38export function linksIn(text: string): RecentLink[] {
39 const clean = text.replace(INJECTED_RE, ' ')
40 const titles = new Map<string, string>()
41 for (const m of clean.matchAll(MD_LINK_RE)) {
42 const url = cleanUrl(m[2]!)
43 const title = m[1]!.trim()
44 // `[https://x.y](https://x.y)` names nothing.
45 if (title !== '' && !/^https?:\/\//.test(title)) titles.set(url, title)
46 }
47 const found: RecentLink[] = []
48 for (const m of clean.matchAll(URL_RE)) {
49 const url = cleanUrl(m[0])
50 if (isUrl(url)) found.push({ url, title: titles.get(url) ?? '' })
51 }
52 return found
53}
54
55/**
56 * The session's latest links, newest first, each once: from what the person
57 * and Claude wrote and from the links tools were called with. Tool results are
58 * left out: a search or a page dump would bury the links the conversation is about.
59 */
60export function recentLinks(messages: readonly ScanMessage[], limit = RECENT_LINKS): RecentLink[] {
61 const seen: RecentLink[] = []
62 for (const message of messages) {
63 seen.push(...linksIn(message.text))
64 for (const use of message.toolUses) {
65 for (const [key, value] of Object.entries(use.input)) {
66 if (URL_KEY_RE.test(key) && typeof value === 'string' && isUrl(value)) {
67 seen.push({ url: value, title: '' })
68 }
69 }
70 }
71 }
72 const out: RecentLink[] = []
73 const titles = new Map<string, string>()
74 for (const link of seen) if (link.title !== '') titles.set(link.url, link.title)
75 for (let i = seen.length - 1; i >= 0 && out.length < limit; i--) {
76 const link = seen[i]!
77 if (out.some(o => o.url === link.url)) continue
78 out.push({ url: link.url, title: titles.get(link.url) ?? '' })
79 }
80 return out
81}
82
83/** What the person typed, as a URL: `example.com/x` gains `https://`; null when it is none. */
84export function normalizeUrl(input: string): string | null {
85 const text = input.trim()
86 if (text === '') return null
87 const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(text) ? text : `https://${text}`
88 return isUrl(withScheme) ? withScheme : null
89}
90
91/** A URL as the bar writes it: no `https://`, no `www.`, no lone trailing slash. */
92export function shortUrl(url: string): string {
93 return url.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/\/$/, '')
94}
95
96/** A link's name: its title, else its short URL. */
97export function labelOf(link: { url: string; title: string }): string {
98 return link.title.trim() !== '' ? link.title.trim() : shortUrl(link.url)
99}
100
101/** The text cut to `max` characters, with an ellipsis unless `ellipsis` is false; whole when it fits. */
102export function truncate(text: string, max: number, ellipsis = true): string {
103 const chars = [...text]
104 if (chars.length <= max) return text
105 return ellipsis ? `${chars.slice(0, Math.max(1, max - 1)).join('')}…` : chars.slice(0, max).join('')
106}
107
108/** A URL's host, `www.` kept (it is what the server answers to); '' for none. */
109export function hostOf(url: string): string {
110 try {
111 return new URL(url).host
112 } catch {
113 return ''
114 }
115}
116
117/** Hosts a public favicon service cannot see: only the site itself is asked. */
118export function isPrivateHost(host: string): boolean {
119 const name = host.replace(/:\d+$/, '')
120 return (
121 name === 'localhost' ||
122 name.endsWith('.localhost') ||
123 name.endsWith('.local') ||
124 name.endsWith('.test') ||
125 /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(name)
126 )
127}
128
129/**
130 * Cells the bar's own controls take beside the links: the scope switch, `+`,
131 * «Recent» and «All», with the gaps between them.
132 */
133const CONTROLS_CELLS = 40
134/** The favicon and the chip's padding and gap, in cells. */
135const CHIP_CHROME_CELLS = 4
136
137/**
138 * How many characters of a link's name fit when the bar's `count` links share
139 * the room the controls leave: one link takes it all, four a quarter each.
140 */
141export function labelBudget(bodyColumns: number, count = BAR_LINKS): number {
142 const block = Math.max(24, bodyColumns - CONTROLS_CELLS)
143 return Math.max(6, Math.floor(block / Math.max(1, count)) - CHIP_CHROME_CELLS)
144}
145
146/** Moves the entry at `index` by `delta` places, clamped to the list. */
147export function move<T>(list: readonly T[], index: number, delta: number): T[] {
148 const to = Math.max(0, Math.min(list.length - 1, index + delta))
149 const out = [...list]
150 const [item] = out.splice(index, 1)
151 if (item !== undefined) out.splice(to, 0, item)
152 return out
153}
154
155/** Reads a stored pin list, dropping whatever is not one. */
156export function asLinks(value: unknown): PinnedLink[] {
157 if (!Array.isArray(value)) return []
158 return value.flatMap(v =>
159 typeof v === 'object' && v !== null && typeof v.url === 'string'
160 ? [{ url: v.url, title: typeof v.title === 'string' ? v.title : '' }]
161 : [],
162 )
163}
164
165const PALETTE = ['#d97757', '#6a9bcc', '#788c5d', '#b07ad6', '#c98500', '#3f8f8a', '#c25e7a', '#6b7280']
166
167function escapeXml(text: string): string {
168 return text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`)
169}
170
171/** The favicon's square, in CSS pixels, and the row it sits in. */
172export const ICON_SIZE = 16
173export const ICON_BOX = 20
174
175/** A plain desktop button's height in CSS pixels (measured: 20): a bar chip's icon is centred in it. */
176export const BUTTON_HEIGHT = 20
177
178/**
179 * A link's icon: its favicon, or its host's first letter on a square of the
180 * host's color; `height` taller than the box centres it in that height.
181 */
182export function faviconSvg(uri: string | null | undefined, host: string, height = ICON_BOX): string {
183 const pad = (ICON_BOX - ICON_SIZE) / 2
184 const top = (height - ICON_SIZE) / 2
185 const box = `x="${pad}" y="${top}" width="${ICON_SIZE}" height="${ICON_SIZE}"`
186 let body: string
187 if (uri != null && uri !== '') {
188 body = `<image href="${escapeXml(uri)}" ${box} preserveAspectRatio="xMidYMid meet"/>`
189 } else {
190 const name = host.replace(/^www\./, '')
191 let hash = 0
192 for (const ch of name) hash = (hash * 31 + ch.codePointAt(0)!) >>> 0
193 const letter = (name.match(/[\p{L}\p{N}]/u)?.[0] ?? '?').toUpperCase()
194 const mid = ICON_BOX / 2
195 const midY = height / 2
196 body =
197 `<rect ${box} rx="4" fill="${PALETTE[hash % PALETTE.length]}"/>` +
198 `<text x="${mid}" y="${midY}" font-size="10" font-weight="600" font-family="-apple-system, Helvetica, Arial, sans-serif" text-anchor="middle" dominant-baseline="central" fill="#fff">${escapeXml(letter)}</text>`
199 }
200 return `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_BOX}" height="${height}" viewBox="0 0 ${ICON_BOX} ${height}">${body}</svg>`
201}
202
203/** The image types a favicon may come as, by curl's content type. */
204export function faviconMime(contentType: string): string | null {
205 const type = contentType.split(';')[0]!.trim().toLowerCase()
206 if (type === 'image/vnd.microsoft.icon' || type === 'image/x-icon' || type === 'image/ico') return 'image/x-icon'
207 return /^image\/(png|gif|jpeg|webp|svg\+xml)$/.test(type) ? type : null
208}
209
210export type SortBy = 'title' | 'url'
211export type SortDir = 'asc' | 'desc'
212
213/** What a sort compares: the name (or the short URL when none), or the short URL; any case. */
214function sortKey(link: PinnedLink, by: SortBy): string {
215 return (by === 'title' ? labelOf(link) : shortUrl(link.url)).toLowerCase()
216}
217
218/** The list sorted by name or link, A→Z or Z→A, numbers in their order; ties keep their place. */
219export function sortLinks(list: readonly PinnedLink[], by: SortBy, dir: SortDir): PinnedLink[] {
220 const sign = dir === 'asc' ? 1 : -1
221 return list
222 .map((link, i) => ({ link, i }))
223 .sort((a, b) => {
224 const order = sortKey(a.link, by).localeCompare(sortKey(b.link, by), undefined, { numeric: true })
225 return order !== 0 ? sign * order : a.i - b.i
226 })
227 .map(e => e.link)
228}
229
230/** How the list stands sorted by `by`, if it does: the arrow a header shows comes from the list itself. */
231export function sortedAs(list: readonly PinnedLink[], by: SortBy): SortDir | null {
232 if (list.length < 2) return null
233 const same = (other: PinnedLink[]) => other.every((l, i) => l === list[i])
234 if (same(sortLinks(list, by, 'asc'))) return 'asc'
235 if (same(sortLinks(list, by, 'desc'))) return 'desc'
236 return null
237}
238
239/** Whether a pinned link answers a search: its name or its short URL holds the text, any case. */
240export function matches(link: PinnedLink, query: string): boolean {
241 const q = query.trim().toLowerCase()
242 return q === '' || labelOf(link).toLowerCase().includes(q) || shortUrl(link.url).toLowerCase().includes(q)
243}
244
245/** Rows an entry of the «All» pane takes: its name, then its URL and buttons. */
246export const ENTRY_ROWS = 2
247
248/** Where an entry dragged `dy` rows from `from` lands in a list of `count`. */
249export function dropIndexY(from: number, dy: number, count: number, rows = ENTRY_ROWS): number {
250 return Math.max(0, Math.min(count - 1, from + Math.round(dy / rows)))
251}
252
253
254/** What a page's head says about it: its title and the icons it declares, best first. */
255export type PageMeta = { title: string; icons: string[] }
256
257const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', laquo: '«', raquo: '»' }
258
259/** Decodes the HTML entities a title holds: named ones, `'`, `'`. */
260export function decodeEntities(text: string): string {
261 return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
262 if (code[0] === '#') {
263 const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
264 return Number.isFinite(n) ? String.fromCodePoint(n) : whole
265 }
266 return ENTITIES[code.toLowerCase()] ?? whole
267 })
268}
269
270function attr(tag: string, name: string): string | null {
271 const m = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag)
272 return m === null ? null : (m[2] ?? m[3] ?? m[4] ?? '')
273}
274
275/**
276 * Reads a page's `<title>` and its `<link rel="icon">`s, resolved against
277 * `base` (the URL the page came from). Icons rank SVG first, then the small
278 * ones (≤ 64px), then the rest, then Apple's touch icon.
279 */
280export function parseHead(html: string, base: string): PageMeta {
281 const head = html.slice(0, 200_000)
282 const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(head)
283 const title = titleMatch === null ? '' : decodeEntities(titleMatch[1]!).replace(/\s+/g, ' ').trim()
284 const ranked: { href: string; rank: number }[] = []
285 for (const m of head.matchAll(/<link\b[^>]*>/gi)) {
286 const tag = m[0]
287 const rel = (attr(tag, 'rel') ?? '').toLowerCase()
288 const href = attr(tag, 'href')
289 if (href === null || href === '' || !/\bicon\b/.test(rel)) continue
290 let url: string
291 try {
292 url = new URL(decodeEntities(href), base).href
293 } catch {
294 continue
295 }
296 const type = (attr(tag, 'type') ?? '').toLowerCase()
297 const size = Number(/(\d+)x\d+/.exec(attr(tag, 'sizes') ?? '')?.[1] ?? 0)
298 const rank = rel.includes('apple')
299 ? 4
300 : type.includes('svg') || /\.svg(\?|$)/i.test(url)
301 ? 0
302 : size > 0 && size <= 64
303 ? 1
304 : size === 0
305 ? 2
306 : 3
307 ranked.push({ href: url, rank })
308 }
309 ranked.sort((a, b) => a.rank - b.rank)
310 return { title, icons: [...new Set(ranked.map(r => r.href))] }
311}
312hooks/drag-handle.tsx 43 lines1import type { ClientModule } from 'claude-code'
2
3/** What the hooks module hands a handle: the glyph, and whether its row is the one being moved. */
4type Props = { glyph: string; isDragging: boolean }
5type State = { isHeld: boolean }
6
7/** Surfaces whose pointer listener is set: one per instance, set on its first draw. */
8const wired = new WeakSet<object>()
9
10/**
11 * A drag handle. A press takes the pointer until it is let go (the surface
12 * holds it, past the region's edges too): every move reports how far it went,
13 * in cells, and the release where it ended. The hooks module does the rest.
14 */
15const DragHandle: ClientModule<Props, State> = (props, surface) => {
16 if (!wired.has(surface)) {
17 wired.add(surface)
18 let start: { x: number; y: number } | null = null
19 surface.onPointer(event => {
20 if (event.type === 'down' && event.button === 'left') {
21 start = { x: event.x, y: event.y }
22 surface.setState({ isHeld: true })
23 surface.post({ kind: 'start' })
24 return
25 }
26 if (start === null) return
27 const dx = event.x - start.x
28 const dy = event.y - start.y
29 if (event.type === 'move') surface.post({ kind: 'move', dx, dy })
30 if (event.type === 'up') {
31 start = null
32 surface.setState({ isHeld: false })
33 surface.post({ kind: 'drop', dx, dy })
34 }
35 })
36 }
37 const { Text } = surface.elements
38 const isActive = props.isDragging || surface.state?.isHeld === true
39 return isActive ? <Text bold>{props.glyph}</Text> : <Text dimColor>{props.glyph}</Text>
40}
41
42export default DragHandle
43hooks/text-field.tsx 128 lines1import type { ClientModule } from 'claude-code'
2
3/**
4 * What the hooks module hands a field: its text, the hint, whether it holds
5 * the keys, a glyph at its end, and `rev`, bumped when the hooks module
6 * replaces the text (a form reset or opened on another link).
7 */
8type Props = { value: string; placeholder: string; isActive: boolean; icon: string; rev: number }
9/** The text as typed, whether it is all selected (⌘A), and the props it was typed against. */
10type State = { value: string; isSelected: boolean; rev: number }
11
12/**
13 * A key as a chord: a surface may hand ⌘V or ⌃V over as the control
14 * character it types (`\x16`) with no modifier flag. Those, \x01 to \x1a,
15 * read as the letter held with the modifier; ⌫ as \x7f or \b reads as
16 * backspace. Null for a key that is no chord.
17 */
18function chordOf(key: string, isModified: boolean): string | null {
19 if (isModified) return key.toLowerCase()
20 const code = key.length === 1 ? key.charCodeAt(0) : -1
21 return code >= 1 && code <= 26 && code !== 8 && code !== 9 && code !== 13 ? String.fromCharCode(code + 96) : null
22}
23
24/** Whether a key types text: one printable character, no control or private-use one. */
25function isPrintable(key: string): boolean {
26 if ([...key].length !== 1) return false
27 const code = key.codePointAt(0)!
28 return code >= 32 && code !== 0x7f && !(code >= 0x80 && code < 0xa0) && !(code >= 0xe000 && code <= 0xf8ff)
29}
30
31/** Each instance's latest props and the values it posted lately, for its listeners. */
32const latest = new WeakMap<object, Props>()
33const posted = new WeakMap<object, string[]>()
34
35/**
36 * A one-line text field the mod draws, so it can take the width it is given
37 * and keep a plain border: grey, white while typed into. A click takes the
38 * keys; every change, Enter, and ⌘C / ⌘X / ⌘V are posted to the hooks module
39 * (which holds the clipboard). ⌘A selects all, ⌘⌫ clears.
40 */
41const TextField: ClientModule<Props, State> = (props, surface) => {
42 const isNew = !latest.has(surface)
43 latest.set(surface, props)
44 const sent = posted.get(surface) ?? []
45 posted.set(surface, sent)
46
47 // The props win when the hooks module put a text the field never posted
48 // (a paste, a reset): an echo of an earlier keystroke is not a change.
49 const s = surface.state
50 const isOwn = s !== undefined && s.rev === props.rev && (props.value === s.value || sent.includes(props.value))
51 const view: State = isOwn ? s : { value: props.value, isSelected: false, rev: props.rev }
52
53 if (isNew) {
54 const now = (): State => {
55 const p = latest.get(surface) ?? props
56 const st = surface.state
57 const own = st !== undefined && st.rev === p.rev && (p.value === st.value || (posted.get(surface) ?? []).includes(p.value))
58 return own ? st : { value: p.value, isSelected: false, rev: p.rev }
59 }
60 const change = (state: State, value: string) => {
61 const list = posted.get(surface) ?? []
62 list.push(value)
63 if (list.length > 40) list.shift()
64 surface.setState({ ...state, value, isSelected: false })
65 surface.post({ kind: 'change', value })
66 }
67 surface.onPointer(event => {
68 if (event.type === 'down') surface.post({ kind: 'focus' })
69 })
70 surface.onKey(event => {
71 const st = now()
72 if (event.key === '\x7f' || event.key === '\b') {
73 change(st, st.isSelected ? '' : [...st.value].slice(0, -1).join(''))
74 return
75 }
76 const chord = chordOf(event.key, event.meta === true || event.ctrl === true)
77 if (chord !== null) {
78 const key = chord
79 if (key === 'a') surface.setState({ ...st, isSelected: true })
80 else if (key === 'c') surface.post({ kind: 'copy', value: st.value })
81 else if (key === 'x') {
82 // One post: a later one in the same frame replaces an undelivered one.
83 const list = posted.get(surface) ?? []
84 list.push('')
85 surface.setState({ ...st, value: '', isSelected: false })
86 surface.post({ kind: 'cut', value: st.value })
87 } else if (key === 'v') surface.post({ kind: 'paste', value: st.value, isSelected: st.isSelected })
88 else if (key === 'backspace' || key === 'delete') change(st, '')
89 return
90 }
91 if (event.key === 'return') {
92 surface.post({ kind: 'submit', value: st.value })
93 return
94 }
95 const base = st.isSelected ? '' : st.value
96 if (event.key === 'backspace' || event.key === 'delete') change(st, st.isSelected ? '' : [...st.value].slice(0, -1).join(''))
97 else if (event.key === 'space') change(st, `${base} `)
98 else if (isPrintable(event.key)) change(st, base + event.key)
99 })
100 }
101
102 const { Box, Text } = surface.elements
103 const caret = props.isActive ? '▏' : ''
104 return (
105 <Box
106 flexDirection="row"
107 justifyContent="space-between"
108 alignItems="center"
109 paddingX={1}
110 borderStyle="round"
111 borderColor={props.isActive ? 'text' : 'inactive'}
112 >
113 {view.value === '' ? (
114 <Text dimColor wrap="truncate-end">{`${caret}${props.placeholder}`}</Text>
115 ) : view.isSelected && props.isActive ? (
116 <Text inverse wrap="truncate-end">
117 {view.value}
118 </Text>
119 ) : (
120 <Text wrap="truncate-end">{`${view.value}${caret}`}</Text>
121 )}
122 {props.icon !== '' && <Text>{props.icon}</Text>}
123 </Box>
124 )
125}
126
127export default TextField
128types/index.d.ts 53 lines1export type Scope = 'project' | 'session'
2export type Lang = 'en' | 'ru' | 'uk' | 'de' | 'fr' | 'it' | 'es'
3
4/** A link pinned to a project or a session; `title` empty when none was given. */
5export type PinnedLink = { url: string; title: string }
6
7/** A link seen in the session's transcript, newest first; `title` from a markdown link. */
8export type RecentLink = { url: string; title: string }
9
10/** What the band shows under its row. */
11export type Section = 'add' | null
12
13/** The add form: the fields as typed, the scope picked, and the pin edited, if any. */
14export type Draft = {
15 url: string
16 title: string
17 scope: Scope
18 edit: { scope: Scope; index: number } | null
19 /** Bumped whenever the form is filled anew: the fields drop what was typed. */
20 rev: number
21}
22
23/** The sort last pressed on a list: by name or link, A→Z or Z→A. */
24export type LinkSort = { by: 'title' | 'url'; dir: 'asc' | 'desc' }
25
26/** A link being dragged in the «All» pane: in which list, from where to where. */
27export type Drag = { where: 'pane'; scope: Scope; from: number; to: number }
28
29declare module 'claude-code' {
30 interface PluginState {
31 'links-bar': {
32 recent: RecentLink[]
33 projectLinks: PinnedLink[]
34 sessionLinks: PinnedLink[]
35 scope: Scope
36 section: Section
37 draft: Draft
38 /** Favicons by host: a data URI, or null when the site has none we can read. */
39 favicons: Record<string, string | null>
40 lang: Lang
41 /** Whether the «All» pane is open: the button is lit while it is. */
42 paneOpen: boolean
43 /** The «All» pane's search text. */
44 search: string
45 /** Which of the mod's text fields holds the keys (its border is white then). */
46 activeField: string | null
47 drag: Drag | null
48 /** The sort pressed on each list; lit while the list still stands in that order. */
49 sorts: { project: LinkSort | null; session: LinkSort | null }
50 }
51 }
52}
53