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

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.

⏸ manual mode on · ? for shortcuts · ← for agents
Artifacts UX picks PRs mods#3 · mods#2 · mods#1
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.github.com/<owner>/<repo>/pull/<n> links, shown as repo#n.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.
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.
In /config, or under pluginConfigs["link-bar"].options in settings:
| Setting | Default | |
|---|---|---|
enabled | true | Turns the mod off without unloading it. |
artifacts | 3 | How many of the most recently mentioned artifacts to link. 0 shows none. |
pullRequests | 5 | How many of the most recently mentioned pull requests to link. 0 shows none. |
The mod is one hooks module, hooks/register.tsx, with the link finding in hooks/links.ts:
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.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.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.
hooks/register.tsx 92 lines1import { 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}
92hooks/links.ts 91 lines1import 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}
91types/index.d.ts 12 lines1// 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