SLOPSHOPPER

link-bar

Links in the bar under the prompt to the artifacts and pull requests mentioned most recently in the conversation.

newspinnerprocess
★ 1v0.1.0no licenseupdated 2026-10-02cjavdev/mods/link-bar
A shopper browsing a rack in a slop shop
README

link-bar

A Claude Code mod that keeps the artifacts and pull requests you've been talking about one click away. It adds a row of links under the prompt: the 3 claude.ai artifacts and the 5 GitHub pull requests mentioned most recently in the conversation.

The links row under the prompt, under the hint line

  ⏸ manual mode on · ? for shortcuts · ← for agents
    Artifacts UX picks   PRs mods#3 · mods#2 · mods#1
  • Artifacts are claude.ai/artifact/… and claude.ai/code/artifact/… links. One written as a markdown link, like UX picks, shows its label. A bare one shows the start of its id, like artifact DhK2K2.
  • PRs are github.com/<owner>/<repo>/pull/<n> links, shown as repo#n.
  • Most recent first. Mentioning a link again moves it to the front.
  • Real hyperlinks. Each one is an OSC 8 link, so cmd-click or ctrl-click opens it in terminals that support them (iTerm2, Ghostty, kitty, WezTerm, VS Code). Elsewhere the URL is drawn after the label.

Only your prompts and Claude's replies count. Tool output (a gh pr list, say), system reminders and subagents' messages don't, so the row holds what the conversation was actually about. Nothing shows until the first link is mentioned.

Install

You need Claude Code 2.1.287 or newer.

git clone https://github.com/cjavdev/mods.git ~/mods
claude --plugin-dir ~/mods/link-bar

Or add ~/mods/link-bar to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

It shares the space under the prompt with context-meter and cache-shot-clock: they stay at the end of the hint line, and the links get the row below it.

Settings

In /config, or under pluginConfigs["link-bar"].options in settings:

SettingDefault
enabledtrueTurns the mod off without unloading it.
artifacts3How many of the most recently mentioned artifacts to link. 0 shows none.
pullRequests5How many of the most recently mentioned pull requests to link. 0 shows none.

How it works

The mod is one hooks module, hooks/register.tsx, with the link finding in hooks/links.ts:

  • A session.append hook sees every row the conversation keeps. For your prompts and Claude's replies on the main thread, it pulls out artifact and PR links and moves them to the front of two lists in session state ($.state, declared in types/index.d.ts). A change redraws the row.
  • --resume and --continue load a conversation without appending it, so on a resume the mod reads the recent end of the transcript file and picks the links back up. /clear empties the lists.
  • A ui.render hook on the PromptHint component draws the engine's own hint line and adds a row under it with a Link element per entry. The hint line's tail is plain text cut at the row's end, which is why the links get a row of their own.

Develop

claude plugin validate ~/mods/link-bar   # manifest and hooks check
claude plugin test ~/mods/link-bar       # runs tests/*.test.tsx

The screenshot is a real capture of Claude Code running the mod (with context-meter beside it) in tmux (tmux capture-pane -p -e), turned into a page with scripts/ansi2html.py and screenshotted.

Source 3 files
hooks/register.tsx 92 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Links } from '../types'
5import { findMentions, remember, textOf, transcriptTexts } from './links'
6
7const links = atom({ plugin: 'link-bar', key: 'links' } as const, { artifacts: [], prs: [] })
8
9const EMPTY: Links = { artifacts: [], prs: [] }
10// How much of a resumed transcript to read back: the recent end, never the whole file.
11const TAIL_BYTES = '4194304'
12
13// How many of each the bar keeps. Module variables start over on a reload;
14// register sets them again from the settings.
15const max = { artifacts: 3, prs: 5 }
16
17// Adds what one message mentions to the front of the lists.
18async function note($: EngineInterface, text: string) {
19  const found = findMentions(text)
20  if (found.length === 0) return
21  await update($, links, prev => {
22    const cur = prev ?? EMPTY
23    return {
24      artifacts: remember(cur.artifacts, found.filter(f => f.kind === 'artifact'), max.artifacts),
25      prs: remember(cur.prs, found.filter(f => f.kind === 'pr'), max.prs),
26    }
27  })
28}
29
30export const register: Register = (on, options) => {
31  // Off in /config: hook nothing at all.
32  if (options.enabled === false) return
33
34  max.artifacts = Math.max(0, Math.floor(Number(options.artifacts ?? 3)))
35  max.prs = Math.max(0, Math.floor(Number(options.pullRequests ?? 5)))
36
37  // Every row the conversation keeps passes here. The person's prompts and
38  // the model's replies count as mentions; tool output, reminders and
39  // subagents' rows do not.
40  // A row is never refused below a plugin, so it is noted on the way down.
41  on('session.append', async ($, e, next) => {
42    const isMention =
43      e.agentId === undefined && !e.message.isMeta && (e.door === 'prompt' || e.door === 'response')
44    if (isMention) await note($, textOf(e.message.content))
45    return next(e)
46  })
47
48  // A resumed session's rows are loaded, not appended: read them back from
49  // the transcript. /clear starts the lists over.
50  on('classic.SessionStart', async ($, e, next) => {
51    const result = await next(e)
52    if (e.source === 'clear') {
53      await update($, links, () => EMPTY)
54    } else if (e.source === 'resume' || e.source === 'fork') {
55      const tail = await $.process
56        .run(['tail', '-c', TAIL_BYTES, e.transcript_path], { timeoutMs: 5000 })
57        .catch(() => null)
58      if (tail?.exitCode === 0) await note($, transcriptTexts(tail.stdout).join('\n'))
59    }
60    return result
61  })
62
63  // The bar under the prompt: the engine's own line, then a row of links
64  // under it. `tail` is plain text cut at the row's end, so the links get a
65  // row of their own beside the engine's drawing, each a real hyperlink.
66  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
67    const below = await next(e)
68    const l = (await read($, links)) ?? EMPTY
69    if (l.artifacts.length === 0 && l.prs.length === 0) return below
70
71    const { Box, Text, Link } = $.ui.resolve(e)
72    const group = (title: string, items: Links['prs']) => [
73      <Text dimColor>{`${title} `}</Text>,
74      ...items.flatMap((m, i) => [...(i > 0 ? [<Text dimColor>{' · '}</Text>] : []), <Link href={m.url}>{m.label}</Link>]),
75    ]
76    const groups = [
77      ...(l.artifacts.length ? [group('Artifacts', l.artifacts)] : []),
78      ...(l.prs.length ? [group('PRs', l.prs)] : []),
79    ]
80    const row = groups.flatMap((g, i) => [...(i > 0 ? [<Text dimColor>{'   '}</Text>] : []), ...g])
81
82    return (
83      <Box flexDirection="column">
84        {below}
85        <Box flexDirection="row" paddingLeft={2}>
86          {row}
87        </Box>
88      </Box>
89    )
90  })
91}
92
hooks/links.ts 91 lines
1import type { Mention } from '../types'
2
3export type Kind = 'artifact' | 'pr'
4
5// One link found in a message. `named` says the label came from the
6// message's own markdown (`[UX picks](https://claude.ai/artifact/…)`).
7export type Found = Mention & { kind: Kind; named: boolean }
8
9const PR = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
10const ARTIFACT = /^https:\/\/claude\.ai\/(?:code\/artifact|artifact|public\/artifacts)\/([\w-]+)/
11const URL_RE = /https:\/\/[^\s<>()[\]{}"'`|\\]+/g
12const MD_LINK = /\[([^\]\n]{1,200})\]\((https:\/\/[^)\s]+)\)/g
13
14const LABEL_MAX = 24
15
16// Shortens a markdown label to fit the bar.
17export const cut = (s: string, max = LABEL_MAX) => {
18  const t = s.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
19  return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`
20}
21
22// What a URL is, its canonical form and the label drawn when the message
23// gave it none: `mods#12` for a pull request, `artifact DhK2K2` for an artifact.
24export const classify = (url: string): { kind: Kind; url: string; label: string } | null => {
25  const pr = PR.exec(url)
26  if (pr) return { kind: 'pr', url: pr[0], label: `${pr[2]}#${pr[3]}` }
27  const art = ARTIFACT.exec(url)
28  if (art) return { kind: 'artifact', url: art[0], label: `artifact ${(art[1] ?? '').slice(0, 6)}` }
29  return null
30}
31
32// Every artifact and pull request link in a message, in the order written.
33export const findMentions = (text: string): Found[] => {
34  const labels = new Map<string, string>()
35  for (const m of text.matchAll(MD_LINK)) {
36    const c = classify(m[2] ?? '')
37    const label = cut(m[1] ?? '')
38    // A label that is just the URL again says nothing.
39    if (c && c.kind === 'artifact' && label && !/^https?:/.test(label)) labels.set(c.url, label)
40  }
41
42  const out: Found[] = []
43  for (const m of text.matchAll(URL_RE)) {
44    const c = classify(m[0])
45    if (!c) continue
46    const named = labels.get(c.url)
47    out.push({ kind: c.kind, url: c.url, label: named ?? c.label, named: named !== undefined })
48  }
49  return out
50}
51
52// Moves each found link to the front, the last one written ending up first,
53// and keeps `max`. A link mentioned again without a label keeps the one it had.
54export const remember = (list: readonly Mention[], found: readonly Found[], max: number): Mention[] => {
55  let next = [...list]
56  for (const f of found) {
57    const prev = next.find(m => m.url === f.url)
58    const label = !f.named && prev ? prev.label : f.label
59    next = [{ url: f.url, label }, ...next.filter(m => m.url !== f.url)]
60  }
61  return next.slice(0, Math.max(0, max))
62}
63
64// The text a transcript row carries: a string, or its text blocks.
65export const textOf = (content: unknown): string => {
66  if (typeof content === 'string') return content
67  if (!Array.isArray(content)) return ''
68  return content
69    .map(b => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : ''))
70    .join('\n')
71}
72
73// The person's prompts and the model's replies in a transcript file's JSONL,
74// oldest first; meta rows, tool results and subagent rows are left out.
75export const transcriptTexts = (jsonl: string): string[] => {
76  const out: string[] = []
77  for (const line of jsonl.split('\n')) {
78    if (!line.startsWith('{')) continue
79    let row: { type?: string; isMeta?: boolean; isSidechain?: boolean; message?: { content?: unknown } }
80    try {
81      row = JSON.parse(line)
82    } catch {
83      continue
84    }
85    if ((row.type !== 'user' && row.type !== 'assistant') || row.isMeta || row.isSidechain) continue
86    const text = textOf(row.message?.content)
87    if (text) out.push(text)
88  }
89  return out
90}
91
types/index.d.ts 12 lines
1// One link in the bar: where it goes and the short text drawn for it.
2export type Mention = { url: string; label: string }
3
4// Newest first, each list already cut to its setting.
5export type Links = { artifacts: Mention[]; prs: Mention[] }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'link-bar': { links: Links }
10  }
11}
12