SLOPSHOPPER

links-bar

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…

newpanebandtoastpromptprocess
v0.2.0Apache-2.0updated 2026-10-08tripolskiydigital/claude-links
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · links-bar
│ ┃ links-bar ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./text-field.tsx │ ┃ ⏺ Read(src/auth.ts) │ ┃ Project (0) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nothing pinned. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ This session (0) ⎿ 3 pass, 1 fail │ ┃ │ ┃ Nothing pinned. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ⟨Claude Code's own drawing⟩ [ Project ] Session [ + ] No project links pinned [ Recent ] [ All ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ Project ] Session [ + ] No project links pinned [ Recent ] [ All ]
Pane · links-bar
▣ client module ./text-field.tsx Project (0) Nothing pinned. This session (0) Nothing pinned.
README

Claude Links — a links bar for Claude Desktop

English · Русский

The links bar above the prompt in Claude Desktop

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:

  • Pinned links with favicons, up to 4 on the bar. Pin a link to the project (every session in that folder sees it) or to this session only; the small Project / Session switch on the left picks which set the bar shows.
  • Favicons and titles found for you: the icon is read from the page itself (the icons it declares, then /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..
  • The links share the bar: one link takes all the free room, four a quarter each; a cut name shows in full, with its URL, when you hover it.
  • + opens one row: name, link, Project / Session, Cancel, Save.
  • Recent: hover it for the session's 10 latest links, from what you and Claude wrote and the pages Claude opened. Pin any of them to the project or the session, unpin, rename.
  • All opens a side pane with every pinned link: search, sort by name or by link, drag to reorder (the first 4 of each list are on the bar), rename, unpin.
  • Speaks your language: English, Deutsch, Français, Italiano, Español, Українська, Русский, following Claude Desktop's own language setting; English for every other language.

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%">

The + form: name, link, Project / Session, Cancel, Save

<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>

Requirements

  • macOS and Claude Desktop with mods support (Claude Code engine 2.1.289 or later).
  • curl (part of macOS) for favicons in PNG or ICO.
  • The mod draws in Claude Desktop's Code tab. It also loads in the terminal claude, where it draws a simpler text version.

Install

From the marketplace (recommended)

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

From a clone

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.

Using it

  1. Press +, type a name (or leave it empty: the page's title is used) and a link, choose Project or Session, and Save. Or hover Recent and press 📌 in the Project or Session column of a link the session already mentioned.
  2. Click a link on the bar to open it in your default browser. Project / Session switches which pinned links the bar shows.
  3. All opens the side pane: type in the search field to filter, press Name or Link to sort (↓ A→Z; press again for ↑ Z→A), drag the ⠿ handle to reorder, ✎ to rename, ✕ to unpin.

The fields are the mod's own: click one to type, Enter saves, and ⌘A, ⌘C, ⌘X, ⌘V and ⌘⌫ work in them.

How it works

WhatWhere it comes from
Recent linksthe 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 positionthe plugin's own store (Claude Code keeps it), keyed by project folder or session id
Favicons and titlesthe 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 languagethe 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 linkopen <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 reads, writes and runs

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

  • The current session's transcript, through the mod API ($.session.messages).
  • The locale field of Claude Desktop's settings, through grep (see the table above).
  • The clipboard, with pbpaste, only when you press ⌘V in one of the mod's fields.
  • The environment variables 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:

ProgramArgumentsWhenWhy
openthe linkyou click a linkopens it in your default browser
curlthe icon's address, a 6-second limit, a 24 KB limit, and the temp file to writea pinned or mentioned link's site has no favicon in the store yet, and its icon is a PNG or ICOdownloads that picture; it is read as an image and kept in the store, never run
pbpastenone (UTF-8 is asked for through its environment)you press ⌘V in one of the mod's fieldsreads the clipboard to paste it
grep-o -m 1 and a pattern for "locale", in Claude Desktop's config.jsonat session startpicks 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.

Limits

  • Mods can't style the app's own fields, links or buttons, so the mod draws its own text fields and drag handles: the cursor stays at the end of the text, and part of a text can't be selected with the mouse.
  • The look follows Claude Desktop's current UI; a large redesign of the app may need an update of the mod.
  • A page behind a login or a bot check gives no title and may give no icon, and a site that names no icon has none: the bar shows the link's address and the site's first letter instead.

Uninstall

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.

Development

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.

License

Apache 2.0

Source 6 files
hooks/register.tsx 1113 lines
1import { 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}
1113
hooks/i18n.ts 255 lines
1import 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}
255
hooks/lib.ts 312 lines
1import 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, `&#39;`, `&#x27;`. */
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}
312
hooks/drag-handle.tsx 43 lines
1import 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
43
hooks/text-field.tsx 128 lines
1import 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
128
types/index.d.ts 53 lines
1export 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