Band above the prompt listing the GitHub PR and Linear links discussed in the session (your prompts and the assistant's answers); /gh-linear-parse-mod toggles…

A Claude Code mod that keeps the GitHub PR and Linear links discussed in a session in a band above the prompt. It reads your prompts and the assistant's answers (not tool results), and on first load it backfills links already in the transcript. Pressing a link opens it in your default browser, or in the browser the browser option names. The band has up to three sections, each under its own header row: Status (Claude's status line), GitHub, then Linear. PRs show as repo | #N; Linear issues show as ENG-123 | slug words, and other Linear pages as project | name, followed by the tab when the link names one (project | new onboarding · overview). Each PR is followed by its author's first two letters (mr for mreedr), its lines added and deleted (+120 -34, in green and red), and a short, gray form of its title: a conventional-commit scope becomes a tag, ticket references and clauses such as when ... or once ... are dropped, and the rest is cut at the band's edge. The line counts are never cut; on a band too narrow for them the row shows the title alone. Each Linear issue is followed by its workflow status (In progress, In review), read through the session's Linear MCP server. The whole row past its x opens the link. A /clear empties the band, the PR and issue statuses and the dismissed list; the color and watch settings stay. Each session's links and dismissals are saved (the 30 most recent sessions), so a resumed session gets its band back even after compaction took the links out of the conversation. The header shows how many links the session has and when the PRs and issues were last read, with its buttons at the right edge: Refresh reads them all again. On a narrow band the time is left out so the header stays on one line.
You need a recent Claude Code and the GitHub CLI (gh), signed in with gh auth login; the PR status emoji come from gh. Linear statuses need a Linear MCP server connected to Claude Code (one whose tools are named mcp__<server>__get_issue, such as Linear's own linear-server); without one, Linear rows show no status.
In a Claude Code terminal session:
/plugin install gh-linear-parse-mod --marketplace mreedr/gh-linear-parse-mod
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). If that form is not accepted, add the marketplace and install in two steps:
/plugin marketplace add mreedr/gh-linear-parse-mod
/plugin install gh-linear-parse-mod@gh-linear-parse-mod
Start a new session: PR and Linear links you or Claude mention show up in a band above the prompt. Links open in your default browser; see Browser to pick one. To get later versions, update the plugin from /plugin.
The browser option is empty by default, which opens links in the system default browser (open, xdg-open or start). On macOS it can name an app instead, such as Brave Browser or Google Chrome; set it in the plugin's config menu or in ~/.claude/settings.json:
"pluginConfigs": { "gh-linear-parse-mod": { "options": { "browser": "Brave Browser" } } }
With a Chromium browser (Brave, Chrome, Edge, Chromium, Vivaldi) the mod first switches to a tab already on the link, including a PR's /files page. The first time, macOS asks whether your terminal may control the browser; without that, links open in a new tab. If the named browser fails to open, the link opens in the default browser with a toast.
| Command | Effect | |
|---|---|---|
/gh-linear-parse-mod | Toggle the band (also on, off) | |
/gh-linear-parse-mod add <url> | Add a GitHub PR or Linear link to the band and check its status now, even one dismissed with x (also shows a hidden band) | |
/gh-linear-parse-mod clear | Empty the band for this session | |
/gh-linear-parse-mod refresh | Re-check the status of every PR and Linear issue in the band | |
| `/gh-linear-parse-mod watch on\ | off` | Turn the 5-minute status refresh on or off (this session; on by default) |
| `/gh-linear-parse-mod nudge on\ | off` | Ask agents in their system prompt to keep their status current, or stop asking (all sessions; on by default) |
/gh-linear-parse-mod reset | Let links dismissed with x show again (this session) | |
/gh-linear-parse-mod color <color> [fill] | Border (or fill) color for this session: a name, hex or theme key; the default is a gray border, color alone goes back to it, color none removes it | |
/gh-linear-parse-mod <color> [fill] | Same as color <color> [fill], for a color name, hex or theme key; any other unknown option shows the list of options and leaves the band alone |
In the band, x dismisses a link for the session, and Show N more / Show less expands the list.
PR rows carry three status columns, labelled PR, CI and Rv in the GitHub header row. Linear rows carry one, labelled St in the Linear header row.
| Column | Emoji |
|---|---|
| PR | 🟢 open · 📝 draft · 🟣 merged · ⚫ closed |
| CI | ✅ pass · ❌ fail · ⏳ running · 💥 merge conflict with the base branch (shown instead of the CI state) · blank when there are no checks; only the newest run of each check counts, so a run cancelled by a rerun or a newer push is ignored |
| Rv | 👍 approved · 👀 review needed · ✋ changes requested · 💬 a person other than the author commented, no approval yet (bots such as claude don't count) · 🦯 no reviewer assigned (open PRs; a draft is not asking for review yet, so it shows nothing) |
A dim … means not checked yet, and a dim ? means gh could not read the PR. Merged and closed PRs are dimmed. Status comes from gh pr view, so gh must be installed and signed in. A PR is checked when it is first mentioned, again when it is mentioned at least 15 seconds later, every 90 seconds while its CI runs (for up to 6 hours), and on refresh. While the watch is on and the band is showing, open and draft PRs in view are also re-checked every 5 minutes, so approvals, merges and new pushes show up on their own.
| Status type | Emoji |
|---|---|
| Triage | 📥 |
| Backlog | 💤 |
| Unstarted (todo) | ⚪ |
| Started (in progress, in review, blocked) | 🟡 |
| Completed | 🟣 |
| Canceled or duplicate | ⚫ |
The emoji follows the status's type, and the status's own name follows the issue's label, lined up down the band. Completed, canceled and duplicate issues are dimmed, and a dim ? means the Linear server could not read the issue. An issue is checked when it is first mentioned and on refresh; while the watch is on, unfinished issues in view are re-checked every 5 minutes. The mod calls only the server's read-only get_issue tool.
Claude can read the band through the mcp__gh-linear-parse-mod__links_band tool, which returns this session's links with each PR's status and each Linear issue's status, dismissed keys, hidden and expanded flags, and color. Ask something like "what's in my links band?".
Claude can set a status line in the band's Status section with the mcp__gh-linear-parse-mod__set_status tool, a short note of what it is doing or waiting on (waiting on CI for #812), followed by the time it was set. A new status replaces the old one, and an empty text clears it. Press its x to clear it yourself; a /clear clears it too. With no links in the band, the band shows the status line alone. By default the mod adds a short section to the system prompt asking the agent to keep its status current; /gh-linear-parse-mod nudge off removes it in every session from the next request, and nudge on puts it back.
claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 738 lines1import { atom, read, update } from 'claude-code'
2import type { BoxProps, EngineInterface, Register } from 'claude-code'
3
4import type { StatusNote, BandColor, IssueStatus, Item, PrStatus } from '../types'
5import { openLink } from './browser'
6import {
7 PR_FIELDS,
8 addKey,
9 authorInitials,
10 bandGroups,
11 cell,
12 clockTime,
13 diffParts,
14 extractLinks,
15 fitText,
16 isIssueDone,
17 isIssueKey,
18 isPrKey,
19 issueGlyph,
20 issueId,
21 issueWatchKeys,
22 isStale,
23 lastChecked,
24 linkLabel,
25 linksFromHistory,
26 mergeLinks,
27 nextStatus,
28 parseColorArgs,
29 parseIssueResult,
30 parsePrJson,
31 prRef,
32 shortTitle,
33 shouldPoll,
34 statusGlyphs,
35 textWidth,
36 watchKeys,
37} from './parse'
38
39type $ = EngineInterface
40
41const items = atom({ plugin: 'gh-linear-parse-mod', key: 'items' } as const, [] as Item[])
42const isHidden = atom({ plugin: 'gh-linear-parse-mod', key: 'isHidden' } as const, false)
43const dismissed = atom({ plugin: 'gh-linear-parse-mod', key: 'dismissed' } as const, [] as string[])
44const DEFAULT_COLOR: BandColor = { color: 'gray', isFill: false }
45const bandColor = atom({ plugin: 'gh-linear-parse-mod', key: 'bandColor' } as const, DEFAULT_COLOR as BandColor | null)
46const hasBackfilled = atom({ plugin: 'gh-linear-parse-mod', key: 'hasBackfilled' } as const, false)
47const isExpanded = atom({ plugin: 'gh-linear-parse-mod', key: 'isExpanded' } as const, false)
48const prStatuses = atom({ plugin: 'gh-linear-parse-mod', key: 'prStatuses' } as const, {} as Record<string, PrStatus>)
49const isWatching = atom({ plugin: 'gh-linear-parse-mod', key: 'isWatching' } as const, true)
50const issueStatuses = atom({ plugin: 'gh-linear-parse-mod', key: 'issueStatuses' } as const, {} as Record<string, IssueStatus>)
51const refreshing = atom({ plugin: 'gh-linear-parse-mod', key: 'refreshing' } as const, 0)
52const agentStatus = atom({ plugin: 'gh-linear-parse-mod', key: 'agentStatus' } as const, null as StatusNote | null)
53
54const SHOWN = 5
55const MAX_STORED = 40
56const MAX_TEXT = 200_000
57const MAX_DISMISSED = 500
58// a muted slate blue, readable on dark and light themes
59const TEXT_COLOR = '#8b95b8'
60const STATE_TOOL = 'mcp__gh-linear-parse-mod__links_band'
61const STATUS_TOOL = 'mcp__gh-linear-parse-mod__set_status'
62const MAX_STATUS = 120
63const RECHECK_MS = 15_000
64const CI_POLL_MS = 90_000
65const CI_POLL_WINDOW_MS = 6 * 60 * 60_000
66const WATCH_MS = 5 * 60_000
67// Where a Linear MCP server's `get_issue` is found when the tool list names none.
68const LINEAR_SERVERS = ['linear-server', 'claude.ai Linear', 'linear']
69const LINEAR_TOOL = /^mcp__(.*linear.*)__get_issue$/i
70
71async function record($: $, text: string): Promise<string[]> {
72 try {
73 const found = extractLinks(text.slice(0, MAX_TEXT))
74 if (found.length === 0) return []
75 const gone = await read($, dismissed)
76 await update($, items, list => mergeLinks(list, found, MAX_STORED, gone))
77 await persist($)
78 return found.map(l => l.key).filter(k => isChecked(k) && !gone.includes(k))
79 } catch {
80 // a failed update only costs the band an entry, never the prompt
81 return []
82 }
83}
84
85const isChecked = (key: string) => isPrKey(key) || isIssueKey(key)
86
87const HELP =
88 'Use no option to show or hide the band, or one of: add <url>, on, off, refresh, clear, reset, watch on|off, nudge on|off, color <color> [fill] (a color name alone works too).'
89
90const NUDGE = {
91 id: 'gh-linear-parse-mod:status-nudge',
92 scope: 'session',
93 text: `Keep a status line current in the links band above the prompt with ${STATUS_TOOL}: set it, in under 80 characters, when you start work that will take more than a minute or begin waiting on something (CI, a subagent, a review); update it when that changes; and clear it with an empty text when you finish. Only the main conversation sets it; subagents leave it alone.`,
94} as const
95
96// Whether each request's system prompt asks the agent to keep its status; on unless turned off.
97async function isNudging($: $): Promise<boolean> {
98 try {
99 return (await $.store.get('statusNudge')) !== false
100 } catch {
101 return true
102 }
103}
104
105const SAVED_SESSIONS = 30
106const isItem = (x: unknown): x is Item =>
107 typeof x === 'object' && x !== null && typeof (x as Item).key === 'string' && typeof (x as Item).url === 'string'
108
109// Each session's links and dismissals, kept across restarts so a resumed session gets
110// its band back even when compaction took the links out of the transcript.
111async function persist($: $) {
112 try {
113 const id = await $.session.id()
114 const [list, gone] = await Promise.all([read($, items), read($, dismissed)])
115 const saved = await $.store.get('sessions')
116 const index = Array.isArray(saved) ? saved.filter((s): s is string => typeof s === 'string') : []
117 const ids = [id, ...index.filter(s => s !== id)]
118 for (const old of ids.slice(SAVED_SESSIONS)) await $.store.delete(`links:${old}`)
119 await $.store.set(`links:${id}`, { items: list, dismissed: gone })
120 await $.store.set('sessions', ids.slice(0, SAVED_SESSIONS))
121 } catch {
122 // an unsaved band is rebuilt from the transcript on resume
123 }
124}
125
126async function restore($: $) {
127 if ((await read($, items)).length > 0) return
128 const saved = (await $.store.get(`links:${await $.session.id()}`)) as { items?: unknown; dismissed?: unknown } | undefined
129 if (!saved || !Array.isArray(saved.items)) return
130 const list = saved.items.filter(isItem)
131 const gone = Array.isArray(saved.dismissed) ? saved.dismissed.filter((k): k is string => typeof k === 'string') : []
132 await update($, dismissed, keys => (keys.length > 0 ? keys : gone))
133 await update($, items, current => (current.length > 0 ? current : list))
134 refreshSoon($, list.map(i => i.key).filter(isChecked))
135}
136
137const inflight = new Set<string>()
138let isPolling = false
139
140// The CI poll runs only while some PR's CI does; a reload drops the timer and the
141// flag together, and session.start restarts it when a PR is still running.
142function ensurePolling($: $) {
143 if (isPolling) return
144 isPolling = true
145 try {
146 $.clock.every(CI_POLL_MS, async () => {
147 const now = await $.clock.now()
148 const known = await read($, prStatuses)
149 const list = await read($, items)
150 refreshSoon($, list.filter(i => shouldPoll(known[i.key], now, CI_POLL_WINDOW_MS)).map(i => i.key), true)
151 })
152 } catch {
153 isPolling = false
154 }
155}
156
157let isWatchTimer = false
158
159// Every 5 minutes, while the watch is on and the band is up, the PRs in view that
160// can still change; started by the first status check, like the CI poll.
161function ensureWatching($: $) {
162 if (isWatchTimer) return
163 isWatchTimer = true
164 try {
165 $.clock.every(WATCH_MS, async () => {
166 if (!(await read($, isWatching)) || (await read($, isHidden))) return
167 const list = await read($, items)
168 const visible = list.slice(0, (await read($, isExpanded)) ? list.length : SHOWN)
169 const now = await $.clock.now()
170 const keys = visible.map(i => i.key)
171 const prs = watchKeys(keys, await read($, prStatuses), now, CI_POLL_WINDOW_MS)
172 refreshSoon($, [...prs, ...issueWatchKeys(keys, await read($, issueStatuses))], true)
173 })
174 } catch {
175 isWatchTimer = false
176 }
177}
178
179let linearServer: string | undefined
180
181// The Linear MCP server the session has, found once: the one the tool list names,
182// else the usual names; the issue's status, or undefined when no server answers.
183async function readIssue($: $, id: string) {
184 const args = { id, fields: ['status', 'statusType'] }
185 if (linearServer) {
186 const r = await $.mcp.call(linearServer, 'get_issue', args).catch(() => undefined)
187 if (r) return parseIssueResult(r)
188 linearServer = undefined
189 }
190 const tools = await $.tool.list().catch(() => [])
191 const listed = tools.map(t => LINEAR_TOOL.exec(t.name)?.[1]).filter((n): n is string => !!n)
192 for (const server of new Set([...listed, ...LINEAR_SERVERS])) {
193 const r = await $.mcp.call(server, 'get_issue', args).catch(() => undefined)
194 const status = r && parseIssueResult(r)
195 if (status) {
196 linearServer = server
197 return status
198 }
199 }
200 return undefined
201}
202
203async function refreshIssue($: $, key: string) {
204 const fetched = await readIssue($, issueId(key))
205 // with no Linear server in the session the row stays blank
206 if (!fetched && !linearServer) return
207 const now = await $.clock.now()
208 if (!(await read($, items)).some(i => i.key === key)) return
209 await update($, issueStatuses, map => ({ ...map, [key]: fetched ? { ...fetched, checkedAt: now } : { ...map[key], checkedAt: now } }))
210 ensureWatching($)
211}
212
213async function refresh($: $, key: string) {
214 if (inflight.has(key)) return
215 if (isIssueKey(key)) {
216 inflight.add(key)
217 try {
218 await refreshIssue($, key)
219 } finally {
220 inflight.delete(key)
221 }
222 return
223 }
224 const item = (await read($, items)).find(i => i.key === key)
225 const ref = item && prRef(item.url)
226 if (!ref) return
227 inflight.add(key)
228 try {
229 const argv = ['gh', 'pr', 'view', String(ref.number), '-R', ref.repo, '--json', PR_FIELDS]
230 const ran = await $.process.run(argv, { timeoutMs: 20_000 }).catch(() => null)
231 const fetched = ran?.exitCode === 0 ? parsePrJson(ran.stdout) : undefined
232 const now = await $.clock.now()
233 if (!(await read($, items)).some(i => i.key === key)) return
234 await update($, prStatuses, map => ({ ...map, [key]: nextStatus(map[key], fetched, now) }))
235 if (fetched?.ci === 'running') ensurePolling($)
236 ensureWatching($)
237 } finally {
238 inflight.delete(key)
239 }
240}
241
242// One at a time, in band order, off the hook's path so `gh` never holds a prompt.
243function refreshSoon($: $, keys: readonly string[], isForced = false) {
244 if (keys.length === 0) return
245 try {
246 $.clock.after(1, () =>
247 void (async () => {
248 await update($, refreshing, n => n + 1)
249 try {
250 for (const key of keys) {
251 const now = await $.clock.now()
252 const s = isIssueKey(key) ? (await read($, issueStatuses))[key] : (await read($, prStatuses))[key]
253 if (isForced || isStale(s, now, RECHECK_MS)) await refresh($, key)
254 }
255 } finally {
256 await update($, refreshing, n => Math.max(0, n - 1))
257 }
258 })().catch(() => undefined),
259 )
260 } catch {
261 // a refused timer leaves the status to the next mention or poll
262 }
263}
264
265let isBackfilling = false
266
267// Once per session, the links already in the transcript. An empty transcript (a new
268// session, or one whose history has not loaded yet) leaves it to the next trigger.
269async function backfill($: $) {
270 if (isBackfilling || (await read($, hasBackfilled))) return
271 isBackfilling = true
272 try {
273 const messages = await $.session.messages()
274 if (messages.length === 0) return
275 const gone = await read($, dismissed)
276 const past = linksFromHistory(messages.map(m => m.text.slice(0, MAX_TEXT)), MAX_STORED, gone)
277 await update($, items, list => mergeLinks(past, list, MAX_STORED))
278 await persist($)
279 await update($, hasBackfilled, () => true)
280 refreshSoon($, (await read($, items)).map(i => i.key).filter(isChecked))
281 } finally {
282 isBackfilling = false
283 }
284}
285
286function backfillSoon($: $) {
287 try {
288 $.clock.after(1, () => void backfill($).catch(() => undefined))
289 } catch {
290 // a refused timer leaves the backfill to the next trigger
291 }
292}
293
294async function refreshAll($: $): Promise<number> {
295 const keys = (await read($, items)).map(i => i.key).filter(isChecked)
296 refreshSoon($, keys, true)
297 return keys.length
298}
299
300async function dismiss($: $, key: string) {
301 await update($, dismissed, keys => addKey(keys, key, MAX_DISMISSED))
302 await update($, items, list => list.filter(i => i.key !== key))
303 await persist($)
304 await update($, prStatuses, ({ [key]: _, ...rest }) => rest)
305 await update($, issueStatuses, ({ [key]: _, ...rest }) => rest)
306}
307
308// Cells the frame takes across: a border and its padding, or a fill's padding.
309const frameColumns = (style: BandColor | null) => (!style ? 0 : style.isFill ? 2 : 4)
310
311function frame(style: BandColor | null): Partial<BoxProps> {
312 if (!style) return {}
313 if (style.isFill) return { backgroundColor: style.color, paddingX: 1 }
314
315 return { borderStyle: 'round', borderColor: style.color, paddingX: 1 }
316}
317
318async function setHidden($: $, value: boolean) {
319 await update($, isHidden, () => value)
320 await $.store.set('isHidden', value)
321}
322
323// Statuses read before the line counts were fetched.
324async function fetchMissingCounts($: $) {
325 const known = await read($, prStatuses)
326 refreshSoon($, (await read($, items)).map(i => i.key).filter(k => isPrKey(k) && known[k] !== undefined && known[k].additions === undefined), true)
327}
328
329export const register: Register = (on, options) => {
330 const browser = typeof options.browser === 'string' ? options.browser.trim() : ''
331
332 on('session.start', async ($, e, next) => {
333 const started = await next(e)
334 await $.command.register({
335 name: 'gh-linear-parse-mod',
336 description: 'Toggle the GitHub/Linear links band (args: add <url>, on, off, clear, reset, refresh, watch on|off, color <color> [fill])',
337 })
338 const stored = await $.store.get('isHidden')
339 if (typeof stored === 'boolean') await update($, isHidden, () => stored)
340 // entries from before links carried a `key` (runs, repos) are dropped on reload
341 await update($, items, list => list.filter(i => i.key))
342 // a reload ends the refresh loops that were counted
343 await update($, refreshing, () => 0)
344 await restore($).catch(() => undefined)
345 backfillSoon($)
346 await fetchMissingCounts($)
347 await $.tool.register({
348 name: 'links_band',
349 description:
350 "Returns this session's gh-linear-parse-mod links band: the GitHub PR and Linear links newest first (the band shows PRs above Linear links) with each PR's title, author, lines added and deleted, state, CI and review status, whether it has a merge conflict, and each Linear issue's status, as last checked, the links dismissed this session, whether the band is hidden or expanded, and its color. Read-only.",
351 inputSchema: { type: 'object', properties: {} },
352 isDeferred: false,
353 })
354 await $.tool.register({
355 name: 'set_status',
356 description:
357 "Sets this session's status line at the top of the gh-linear-parse-mod links band: a short note of what you are doing or waiting on (for example \"waiting on CI for #812\"), under 80 characters. Replaces any earlier status; an empty or missing text clears it. Set it when the person would want to see it at a glance, and clear it when the work is done.",
358 inputSchema: {
359 type: 'object',
360 properties: { text: { type: 'string', description: 'The status; empty or left out clears it.' } },
361 },
362 isDeferred: false,
363 })
364
365 const known = Object.values(await read($, prStatuses))
366 if (known.some(s => s.ci === 'running')) ensurePolling($)
367 if (known.length > 0 || Object.keys(await read($, issueStatuses)).length > 0) ensureWatching($)
368 // left by the removed Diff toggle
369 if ((await $.store.keys()).includes('isDiff')) await $.store.delete('isDiff')
370 // a reload draws the band with the new code without waiting for a change
371 $.ui.invalidate('ui.render')
372
373 return started
374 })
375
376 // A /clear ends the session with no session.start after it: the new conversation
377 // starts with an empty band, keeping only the color and watch settings.
378 on('session.end', async ($, e, next) => {
379 if (e.reason === 'clear') {
380 await update($, items, () => [])
381 await update($, prStatuses, () => ({}))
382 await update($, issueStatuses, () => ({}))
383 await update($, dismissed, () => [])
384 await update($, isExpanded, () => false)
385 await update($, hasBackfilled, () => false)
386 await update($, agentStatus, () => null)
387 }
388
389 return next(e)
390 })
391
392 on('tool.call', { tool: STATE_TOOL }, async $ => {
393 const [links, gone, hidden, expanded, color, known, watching] = await Promise.all([
394 read($, items),
395 read($, dismissed),
396 read($, isHidden),
397 read($, isExpanded),
398 read($, bandColor),
399 read($, prStatuses),
400 read($, isWatching),
401 ])
402 const issues = await read($, issueStatuses)
403 const state = {
404 links: links.map(i => {
405 const s = known[i.key]
406 const issue = issues[i.key]
407 if (isIssueKey(i.key) && issue) return { url: i.url, status: issue.name ?? 'unavailable', statusType: issue.type, checkedAt: new Date(issue.checkedAt).toISOString() }
408 if (!isPrKey(i.key)) return { url: i.url }
409 if (!s || s.state === 'unknown') return { url: i.url, status: s ? 'unavailable' : 'not checked yet' }
410 return { url: i.url, title: s.title, author: s.author, additions: s.additions, deletions: s.deletions, state: s.state, ci: s.ci, hasConflict: s.hasConflict ?? false, review: s.review ?? 'none', checkedAt: new Date(s.checkedAt).toISOString() }
411 }),
412 dismissed: gone,
413 isHidden: hidden,
414 isExpanded: expanded,
415 shownInBand: hidden ? 0 : expanded ? links.length : Math.min(links.length, SHOWN),
416 isWatching: watching,
417 isNudging: await isNudging($),
418 agentStatus: (st => (st ? { text: st.text, setAt: new Date(st.setAt).toISOString() } : null))(await read($, agentStatus)),
419 lastCheckedAt: (t => (t === undefined ? null : new Date(t).toISOString()))(lastChecked(links.map(i => i.key), { ...known, ...issues })),
420 color,
421 }
422
423 return { result: JSON.stringify(state, null, 2) }
424 })
425
426 on('tool.call', { tool: STATUS_TOOL }, async ($, e) => {
427 const text = (typeof e.text === 'string' ? e.text : '').replace(/\s+/g, ' ').trim().slice(0, MAX_STATUS)
428 const setAt = await $.clock.now()
429 await update($, agentStatus, () => (text ? { text, setAt } : null))
430 return { result: text ? `Status set: ${text}` : 'Status cleared.' }
431 })
432
433 on('command.run', { command: 'gh-linear-parse-mod' }, async ($, e) => {
434 const arg = e.args.trim()
435 const [verb, ...words] = arg.split(/\s+/)
436 if (verb === 'color') {
437 const parsed = parseColorArgs(words)
438 if (parsed.kind === 'invalid') {
439 return {
440 text: `Unknown color "${parsed.color}". Use a name (red, cyan, magentaBright), a hex value (#3b2a4a) or a theme key (subtle, claude), optionally followed by "fill".`,
441 }
442 }
443 if (parsed.kind === 'clear') {
444 await update($, bandColor, () => DEFAULT_COLOR)
445 return { text: 'Band back to the default gray border for this session.' }
446 }
447 if (parsed.kind === 'none') {
448 await update($, bandColor, () => null)
449 return { text: 'Band border removed for this session.' }
450 }
451 await update($, bandColor, () => parsed.value)
452 return {
453 text: `Band ${parsed.value.isFill ? 'fill' : 'border'} set to ${parsed.value.color} for this session.`,
454 }
455 }
456 if (arg === 'clear') {
457 await update($, items, () => [])
458 await update($, prStatuses, () => ({}))
459 await update($, issueStatuses, () => ({}))
460 await persist($)
461 return { text: 'Cleared the session links.' }
462 }
463 if (verb === 'watch') {
464 const [mode] = words
465 if (mode !== undefined && mode !== 'on' && mode !== 'off') return { text: 'Use "watch on" or "watch off".' }
466 const watching = mode === 'on' ? true : mode === 'off' ? false : !(await read($, isWatching))
467 await update($, isWatching, () => watching)
468 return {
469 text: watching
470 ? 'PR status refreshes every 5 minutes for open and draft PRs in view (this session).'
471 : 'PR status auto-refresh off for this session; mentions, running CI and refresh still update it.',
472 }
473 }
474 if (verb === 'nudge') {
475 const [mode] = words
476 if (mode !== undefined && mode !== 'on' && mode !== 'off') return { text: 'Use "nudge on" or "nudge off".' }
477 const nudging = mode === 'on' ? true : mode === 'off' ? false : !(await isNudging($))
478 await $.store.set('statusNudge', nudging)
479 return {
480 text: nudging
481 ? 'Agents are asked to keep their status current with set_status (all sessions, from the next request).'
482 : 'Agents are no longer asked to keep a status (all sessions); they can still set one.',
483 }
484 }
485 if (arg === 'refresh') {
486 const count = await refreshAll($)
487 return { text: `Refreshing ${count} link${count === 1 ? '' : 's'}.` }
488 }
489 if (verb === 'add') {
490 const found = extractLinks(words.join(' '))
491 if (found.length === 0) return { text: 'No GitHub PR or Linear link found. Use "add <url>".' }
492 const keys = found.map(l => l.key)
493 await update($, dismissed, gone => gone.filter(k => !keys.includes(k)))
494 refreshSoon($, await record($, words.join(' ')), true)
495 if (await read($, isHidden)) {
496 await setHidden($, false)
497 backfillSoon($)
498 }
499 return { text: `Added ${found.map(l => l.url).join(', ')}.` }
500 }
501 if (arg === 'reset') {
502 await update($, dismissed, () => [])
503 await persist($)
504 return { text: 'Dismissed links will show again when they come up.' }
505 }
506 if (arg !== '' && arg !== 'on' && arg !== 'off') {
507 // A bare color name sets the color, as `color <name>` does.
508 const parsed = parseColorArgs([verb ?? '', ...words])
509 if (parsed.kind === 'set') {
510 await update($, bandColor, () => parsed.value)
511 return { text: `Band ${parsed.value.isFill ? 'fill' : 'border'} set to ${parsed.value.color} for this session.` }
512 }
513 return { text: `Unknown option "${arg}". ${HELP}` }
514 }
515 const hidden = arg === 'off' ? true : arg === 'on' ? false : !(await read($, isHidden))
516 await setHidden($, hidden)
517 if (!hidden) backfillSoon($)
518 return { text: hidden ? 'Session links hidden.' : 'Session links shown.' }
519 })
520
521 on('prompt.submit', async ($, e, next) => {
522 refreshSoon($, await record($, e.text))
523 backfillSoon($)
524
525 return next(e)
526 })
527
528 on('turn.complete', async ($, e, next) => {
529 refreshSoon($, await record($, e.answer))
530 backfillSoon($)
531
532 return next(e)
533 })
534
535 on('prompt.compose', async ($, e, next) => {
536 const composed = await next(e)
537 if (!e.tools.includes(STATUS_TOOL) || !(await isNudging($))) return composed
538
539 return { sections: [...composed.sections.filter(c => c.id !== NUDGE.id), NUDGE] }
540 })
541
542 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
543 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
544
545 const list = await read($, items)
546 const note = await read($, agentStatus)
547 if (list.length === 0 && !note) return next(e)
548
549 const { Box, Button, Text } = $.ui.resolve(e)
550 const more = list.length - SHOWN
551 const expanded = more > 0 && (await read($, isExpanded))
552 const style = await read($, bandColor)
553 const known = await read($, prStatuses)
554 const visible = list.slice(0, expanded ? list.length : SHOWN)
555 const issues = await read($, issueStatuses)
556 const updatedAt = lastChecked(list.map(i => i.key), { ...known, ...issues })
557 const isRefreshing = (await read($, refreshing)) > 0
558 const { github, linear } = bandGroups(visible)
559 const hasPr = github.length > 0
560 const hasIssueSlot = linear.some(i => issues[i.key])
561 const canRefresh = list.some(i => isPrKey(i.key)) || Object.keys(issues).some(k => list.some(i => i.key === k))
562 const labels = new Map(visible.map(i => [i.key, linkLabel(i.url)]))
563 // Each group is its own table, with its own project width.
564 const width = (group: Item[]) => Math.max(0, ...group.map(i => labels.get(i.key)?.project.length ?? 0))
565 const widths = { github: width(github), linear: width(linear) }
566 const labelText = (i: Item) => {
567 const { project, ref } = labels.get(i.key) ?? linkLabel(i.url)
568 return ref ? `${project.padEnd(isPrKey(i.key) ? widths.github : widths.linear)} | ${ref}` : project
569 }
570 // PR labels padded to one width so the descriptions after them line up.
571 const prWidth = Math.max(0, ...github.map(i => labelText(i).length))
572 const issueWidth = Math.max(0, ...linear.filter(i => issues[i.key]?.name).map(i => labelText(i).length))
573 const addWidth = Math.max(0, ...github.map(i => String(known[i.key]?.additions ?? '').length))
574 const delWidth = Math.max(0, ...github.map(i => String(known[i.key]?.deletions ?? '').length))
575 // Fixed two-cell slots line the emoji up under their labels whatever is in them.
576 const slot = (key: string, text: string, isDim = false) => (
577 <Box key={key} width={2}>
578 <Text dimColor={isDim}>{text || ' '}</Text>
579 </Box>
580 )
581
582 // Cells a row's Button has: the band less its frame and the `x `.
583 const columns = Number.isFinite(e.props.bodyColumns) ? e.props.bodyColumns : Infinity
584 const room = columns - frameColumns(style) - 2
585
586 // The whole row past its `x` is one Button; the description is cut to fit, since a
587 // Button's children wrap rather than truncate.
588 const issueRow = (i: Item) => {
589 const s = issues[i.key]
590 const isDone = isIssueDone(s)
591 const glyph = cell(issueGlyph(s))
592 const slots = hasIssueSlot ? `${glyph} ` : ''
593 const lead = textWidth(slots)
594 const label = fitText(s?.name ? labelText(i).padEnd(issueWidth) : labelText(i), room - lead)
595 const status = s?.name ? fitText(s.name, room - lead - textWidth(label) - 2) : ''
596 return (
597 <Box key={i.key} flexDirection="row" gap={1}>
598 <Button key={`dismiss:${i.key}`} label="x" plain dimColor onPress={() => dismiss($, i.key)} />
599 <Button key={`open:${i.key}`} plain onPress={() => void openLink($, i.url, browser)}>
600 {slots && <Text dimColor={!s?.type}>{slots}</Text>}
601 <Text color={TEXT_COLOR} bold={!isDone} dimColor={isDone}>
602 {label}
603 </Text>
604 {status && <Text dimColor>{` ${status}`}</Text>}
605 </Button>
606 </Box>
607 )
608 }
609
610 const row = (i: Item) => {
611 if (!isPrKey(i.key)) return issueRow(i)
612 const s = known[i.key]
613 const g = isPrKey(i.key) ? statusGlyphs(s) : undefined
614 const isDone = s?.state === 'merged' || s?.state === 'closed'
615 const short = s?.title ? shortTitle(s.title) : undefined
616 const diff = diffParts(s, addWidth)
617 const slots = hasPr ? `${cell(g?.ci ?? '')} ${cell(g?.rv ?? '')} ` : ''
618 const lead = hasPr ? textWidth(cell(g?.pr ?? '')) + 1 + textWidth(slots) : 0
619 const label = fitText(short || diff ? labelText(i).padEnd(prWidth) : labelText(i), room - lead)
620 const by = s?.author ? `${authorInitials(s.author)} ` : ''
621 const tag = short?.tag ? `${short.tag} · ` : ''
622 const descRoom = room - lead - textWidth(label) - 2
623 // Counts are never cut: they show whole or not at all, and the title takes what is left.
624 const counts = diff ? `${by}${diff.plus} ${diff.minus}` : ''
625 const fitsDiff = diff !== undefined && textWidth(counts) <= descRoom
626 // Deletions padded so the titles after them line up.
627 const gap = fitsDiff ? 2 + delWidth + 1 - diff.minus.length : 0
628 const desc = !short
629 ? ''
630 : fitsDiff
631 ? fitText(tag + short.text, descRoom - textWidth(counts) - gap)
632 : fitText(by + tag + short.text, descRoom)
633 return (
634 <Box key={i.key} flexDirection="row" gap={1}>
635 <Button key={`dismiss:${i.key}`} label="x" plain dimColor onPress={() => dismiss($, i.key)} />
636 <Button key={`open:${i.key}`} plain onPress={() => void openLink($, i.url, browser)}>
637 {hasPr && <Text dimColor={g?.isPending}>{cell(g?.pr ?? '')} </Text>}
638 {slots}
639 <Text color={TEXT_COLOR} bold={!isDone} dimColor={isDone}>
640 {label}
641 </Text>
642 {!fitsDiff && desc && <Text dimColor>{` ${desc}`}</Text>}
643 {fitsDiff && <Text dimColor>{` ${by}`}</Text>}
644 {fitsDiff && (
645 <Text color="green" dimColor={isDone}>
646 {diff.plus}
647 </Text>
648 )}
649 {fitsDiff && ' '}
650 {fitsDiff && (
651 <Text color="red" dimColor={isDone}>
652 {diff.minus}
653 </Text>
654 )}
655 {fitsDiff && desc && <Text dimColor>{`${' '.repeat(gap)}${desc}`}</Text>}
656 </Button>
657 </Box>
658 )
659 }
660
661 // A section's header row: its column labels over the status cells, then its name.
662 const section = (key: string, name: string, columns: string[]) => (
663 <Box key={key} flexDirection="row" gap={1}>
664 <Box key="pad" width={1} />
665 {columns.map(c => slot(`label-${c.toLowerCase()}`, c, true))}
666 <Text dimColor>{name}</Text>
667 </Box>
668 )
669
670 const noteTime = note ? ` ${clockTime(note.setAt)}` : ''
671 const statusSection = note && [
672 section('status-header', 'Status', []),
673 <Box key="status" flexDirection="row" gap={1}>
674 <Button key="dismiss:status" label="x" plain dimColor onPress={() => update($, agentStatus, () => null)} />
675 <Text>
676 <Text italic>{fitText(note.text, room - textWidth(noteTime))}</Text>
677 <Text dimColor>{noteTime}</Text>
678 </Text>
679 </Box>,
680 ]
681 const title = `Session links (${list.length})`
682 const expandLabel = expanded ? 'Show less' : `Show ${more} more`
683 // The time goes first when the header would not fit on one line.
684 const actions = [canRefresh ? (isRefreshing ? 'Refreshing…' : 'Refresh') : '', more > 0 ? expandLabel : '', 'Hide']
685 const headerLead = 2
686 const headerNeeds = (withTime: boolean) =>
687 headerLead + textWidth(title) + (withTime && updatedAt !== undefined ? 3 + textWidth(`updated ${clockTime(updatedAt)}`) : 0) +
688 actions.filter(Boolean).reduce((n, a) => n + 1 + textWidth(a), 1)
689 const showsUpdated = updatedAt !== undefined && headerNeeds(true) <= room + 2
690 if (list.length === 0) {
691 return (
692 <Box flexDirection="column" {...frame(style)}>
693 {statusSection}
694 </Box>
695 )
696 }
697
698 return (
699 <Box flexDirection="column" {...frame(style)}>
700 <Box flexDirection="row" gap={1}>
701 <Box key="pad" width={1} />
702 <Text dimColor>{title}</Text>
703 {showsUpdated && (
704 <Box key="updated">
705 <Text dimColor>· updated {clockTime(updatedAt)}</Text>
706 </Box>
707 )}
708 <Box key="spacer" flexGrow={1} />
709 {canRefresh && (
710 <Button
711 key="refresh"
712 label={isRefreshing ? 'Refreshing…' : 'Refresh'}
713 plain
714 dimColor
715 onPress={() => void refreshAll($)}
716 />
717 )}
718 {more > 0 && (
719 <Button
720 key="expand"
721 label={expandLabel}
722 plain
723 dimColor
724 onPress={() => update($, isExpanded, v => !v)}
725 />
726 )}
727 <Button key="hide" label="Hide" plain dimColor onPress={() => setHidden($, true)} />
728 </Box>
729 {statusSection}
730 {hasPr && section('github-header', 'GitHub', ['PR', 'CI', 'Rv'])}
731 {github.map(row)}
732 {linear.length > 0 && section('linear-header', 'Linear', hasIssueSlot ? ['St'] : [])}
733 {linear.map(row)}
734 </Box>
735 )
736 })
737}
738hooks/browser.ts 66 lines1import type { EngineInterface } from 'claude-code'
2
3type $ = EngineInterface
4
5// Chromium browsers share the AppleScript dictionary that lists and selects tabs.
6const CHROMIUM = ['Brave Browser', 'Google Chrome', 'Google Chrome Canary', 'Microsoft Edge', 'Chromium', 'Vivaldi']
7export const canReuseTabs = (browser: string) => CHROMIUM.includes(browser)
8
9// A tab already on `url` (or a page under it, such as a PR's /files) is brought to the front.
10export const FIND_TAB = `function run(argv) {
11 const [name, url] = argv
12 const app = Application(name)
13 if (!app.running()) return 'missing'
14 const want = url.toLowerCase()
15 for (const w of app.windows()) {
16 const urls = w.tabs.url()
17 for (let i = 0; i < urls.length; i++) {
18 const t = (urls[i] || '').toLowerCase()
19 if (t === want || ['/', '?', '#'].some(c => t.startsWith(want + c))) {
20 w.activeTabIndex = i + 1
21 w.index = 1
22 app.activate()
23 return 'found'
24 }
25 }
26 }
27 return 'missing'
28}`
29
30export type Platform = 'mac' | 'linux' | 'windows'
31
32let platform: Platform | undefined
33
34async function detectPlatform($: $): Promise<Platform> {
35 if (platform) return platform
36 const ran = await $.process.run(['uname', '-s'], { timeoutMs: 5_000 }).catch(() => null)
37 const name = ran?.exitCode === 0 ? ran.stdout.trim() : ''
38 platform = name === 'Darwin' ? 'mac' : name ? 'linux' : 'windows'
39 return platform
40}
41
42export function defaultOpen(p: Platform, url: string): string[] {
43 if (p === 'mac') return ['open', url]
44 if (p === 'linux') return ['xdg-open', url]
45 return ['cmd', '/c', 'start', '""', url]
46}
47
48async function ok($: $, argv: string[]) {
49 return (await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null))?.exitCode === 0
50}
51
52// `browser` empty opens the system default. Named, on macOS, it opens there, first
53// switching to a tab already on the link when the browser is a Chromium one.
54export async function openLink($: $, url: string, browser: string) {
55 const p = await detectPlatform($)
56 if (browser && p === 'mac') {
57 if (canReuseTabs(browser)) {
58 const found = await $.process.run(['osascript', '-l', 'JavaScript', '-e', FIND_TAB, browser, url], { timeoutMs: 10_000 }).catch(() => null)
59 if (found?.exitCode === 0 && found.stdout.trim() === 'found') return
60 }
61 if (await ok($, ['open', '-a', browser, url])) return
62 $.ui.toast(`Could not open ${browser}; used the default browser.`)
63 }
64 await ok($, defaultOpen(p, url))
65}
66hooks/parse.ts 425 lines1import type { BandColor, CI, IssueStatus, IssueType, Item, PrState, PrStatus, Review } from '../types'
2
3export type Link = Item
4
5const LINK = /https?:\/\/(?:www\.)?(?:github\.com|linear\.app)\/[^\s<>"'`()[\]{}\\]+/gi
6const TRAILING = /[.,;:!?*_]+$/
7
8function githubLink(parts: string[]): Link | undefined {
9 const [owner, repo, kind, number] = parts
10 if (!owner || !repo || kind !== 'pull' || !number || !/^\d+$/.test(number)) return undefined
11
12 return {
13 key: `github:${owner}/${repo}#${number}`.toLowerCase(),
14 url: `https://github.com/${owner}/${repo}/pull/${number}`,
15 }
16}
17
18function linearLink(parts: string[]): Link | undefined {
19 const [workspace, kind, id] = parts
20 if (!workspace || !kind) return undefined
21
22 const url = `https://linear.app/${parts.join('/')}`
23 if (kind === 'issue' && id && /^[A-Za-z]+-\d+$/.test(id)) {
24 return { key: `linear:${workspace}/issue/${id}`.toLowerCase(), url }
25 }
26
27 return { key: url.toLowerCase(), url }
28}
29
30export function parseLink(raw: string): Link | undefined {
31 const m = raw.replace(TRAILING, '').match(/^https?:\/\/(?:www\.)?(github\.com|linear\.app)\/([^?#]*)/i)
32 if (!m) return undefined
33 const parts = (m[2] ?? '').split('/').filter(Boolean)
34
35 return m[1]?.toLowerCase() === 'github.com' ? githubLink(parts) : linearLink(parts)
36}
37
38// Only GitHub pull requests and Linear pages count. A PR link collapses to
39// its bare URL (no /files, query or fragment); a Linear issue keeps the URL as
40// written, slug included, and the same issue with or without a slug shows once.
41export function extractLinks(text: string): Link[] {
42 const seen = new Set<string>()
43 const links: Link[] = []
44
45 for (const m of text.matchAll(LINK)) {
46 const link = parseLink(m[0])
47 if (!link || seen.has(link.key)) continue
48 seen.add(link.key)
49 links.push(link)
50 }
51
52 return links
53}
54
55// Past `max` the oldest dismissals fall off, so the stored list stays small.
56export function addKey(keys: readonly string[], key: string, max: number): string[] {
57 return [...keys.filter(k => k !== key), key].slice(-max)
58}
59
60const THEME_KEYS = [
61 'text', 'inverseText', 'inactive', 'subtle', 'suggestion', 'remember', 'success', 'error',
62 'warning', 'merged', 'claude', 'permission', 'planMode', 'autoAccept', 'promptBorder',
63 'bashBorder', 'ide', 'diffAdded', 'diffRemoved', 'diffAddedDimmed', 'diffRemovedDimmed',
64 'diffAddedWord', 'diffRemovedWord',
65]
66const NAMED = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray', 'grey']
67
68export type ColorArg =
69 | { kind: 'clear' }
70 | { kind: 'none' }
71 | { kind: 'set'; value: BandColor }
72 | { kind: 'invalid'; color: string }
73
74// `color` alone or `color reset` goes back to the default, `color none` drops the
75// border; `color <c> [fill]` sets. A color is a
76// terminal name (`red`, `cyanBright`), a hex value or a theme key.
77export function parseColorArgs(words: readonly string[]): ColorArg {
78 const [color, mode, ...rest] = words
79 if (!color || color === 'reset') return { kind: 'clear' }
80 if (color === 'none') return { kind: 'none' }
81 if ((mode !== undefined && mode !== 'fill') || rest.length > 0) return { kind: 'invalid', color: words.join(' ') }
82
83 const named = NAMED.find(n => n === color.toLowerCase() || `${n}bright` === color.toLowerCase())
84 const theme = THEME_KEYS.find(k => k.toLowerCase() === color.toLowerCase())
85 const resolved =
86 named ? (color.toLowerCase().endsWith('bright') ? `${named}Bright` : named)
87 : theme ?? (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color.toLowerCase() : undefined)
88 if (!resolved) return { kind: 'invalid', color }
89
90 return { kind: 'set', value: { color: resolved, isFill: mode === 'fill' } }
91}
92
93// Newest first: links in `found` go to the front in text order, and a link seen
94// again moves up rather than repeating. Dismissed links never come back.
95export function mergeLinks(
96 list: readonly Link[],
97 found: readonly Link[],
98 max: number,
99 dismissed: readonly string[] = [],
100): Link[] {
101 const fresh = found.filter(l => !dismissed.includes(l.key))
102 const keys = new Set(fresh.map(l => l.key))
103
104 return [...fresh, ...list.filter(l => !keys.has(l.key))].slice(0, max)
105}
106
107const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
108
109// Messages oldest first in, links newest first out, as if each had been seen live.
110// Reminders the engine injects into user messages are not part of the discussion.
111export function linksFromHistory(texts: readonly string[], max: number, dismissed: readonly string[] = []): Link[] {
112 return texts.reduce<Link[]>(
113 (list, text) => mergeLinks(list, extractLinks(text.replace(REMINDER, '')), max, dismissed),
114 [],
115 )
116}
117
118export const isPrKey = (key: string) => key.startsWith('github:')
119
120export const isIssueKey = (key: string) => /^linear:[^/]+\/issue\/[a-z]+-\d+$/.test(key)
121
122// `linear:acme/issue/eng-12` is `ENG-12`.
123export const issueId = (key: string) => key.slice(key.lastIndexOf('/') + 1).toUpperCase()
124
125export function prRef(url: string): { repo: string; number: number } | undefined {
126 const m = url.match(/^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)$/)
127
128 return m?.[1] && m[2] ? { repo: m[1], number: Number(m[2]) } : undefined
129}
130
131type Check = {
132 __typename?: string
133 status?: string | null
134 conclusion?: string | null
135 state?: string | null
136 name?: string | null
137 context?: string | null
138 workflowName?: string | null
139 startedAt?: string | null
140}
141
142// The newest run of each check: a rerun or a newer push replaces the run it cancelled.
143function latestRuns(rollup: readonly Check[]): Check[] {
144 const latest = new Map<string, Check>()
145 rollup.forEach((c, i) => {
146 const name = c.name ?? c.context
147 const key = name ? `${c.workflowName ?? ''}/${name}` : `#${i}`
148 const seen = latest.get(key)
149 if (!seen || (c.startedAt ?? '') >= (seen.startedAt ?? '')) latest.set(key, c)
150 })
151
152 return [...latest.values()]
153}
154
155export function ciSummary(all: readonly Check[] | null | undefined): CI {
156 if (!all || all.length === 0) return 'none'
157 const rollup = latestRuns(all)
158 const bad = ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
159 if (rollup.some(c => bad.includes(c.conclusion ?? '') || bad.includes(c.state ?? ''))) return 'fail'
160 const isRunning = (c: Check) =>
161 c.__typename === 'StatusContext' || (c.__typename === undefined && c.state != null)
162 ? c.state === 'PENDING' || c.state === 'EXPECTED'
163 : c.status !== 'COMPLETED'
164 if (rollup.some(isRunning)) return 'running'
165
166 return 'pass'
167}
168
169export const PR_FIELDS = 'title,author,state,isDraft,reviewDecision,statusCheckRollup,reviewRequests,latestReviews,additions,deletions,mergeable'
170
171export type Fetched = {
172 state: PrState
173 ci: CI
174 review?: Review
175 title?: string
176 author?: string
177 additions?: number
178 deletions?: number
179 hasConflict?: boolean
180}
181
182// Review apps whose comments are not a person asking for a change.
183const BOTS = new Set(['claude', 'github-advanced-security', 'copilot-pull-request-reviewer', 'coderabbitai'])
184const isBot = (login: string) => BOTS.has(login.toLowerCase()) || login.endsWith('[bot]')
185
186export function parsePrJson(stdout: string): Fetched | undefined {
187 let pr: {
188 title?: string
189 state?: string
190 isDraft?: boolean
191 reviewDecision?: string | null
192 statusCheckRollup?: Check[] | null
193 author?: { login?: string } | null
194 reviewRequests?: unknown[] | null
195 latestReviews?: { author?: { login?: string } | null; state?: string }[] | null
196 additions?: number
197 deletions?: number
198 mergeable?: string
199 }
200 try {
201 pr = JSON.parse(stdout)
202 } catch {
203 return undefined
204 }
205 if (!pr || typeof pr.state !== 'string') return undefined
206
207 const state: PrState =
208 pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.isDraft ? 'draft' : 'open'
209 const isLive = state === 'open' || state === 'draft'
210 const others = (pr.latestReviews ?? []).filter(r => {
211 const login = r.author?.login ?? ''
212 return login !== pr.author?.login && !isBot(login)
213 })
214 // An open or draft PR nobody has been asked to review, and nobody has reviewed.
215 const isUnassigned = isLive && (pr.reviewRequests?.length ?? 0) === 0 && others.length === 0
216 // A person other than the author left a comment review, and nobody has approved.
217 const isCommented = isLive && others.some(r => r.state === 'COMMENTED') && !others.some(r => r.state === 'APPROVED')
218 const review: Review | undefined =
219 pr.reviewDecision === 'APPROVED' ? 'approved'
220 : pr.reviewDecision === 'CHANGES_REQUESTED' ? 'changes'
221 : isCommented ? 'commented'
222 : isUnassigned ? 'unassigned'
223 : pr.reviewDecision === 'REVIEW_REQUIRED' ? 'pending'
224 : undefined
225
226 const title = typeof pr.title === 'string' ? pr.title : undefined
227 const author = pr.author?.login || undefined
228 const hasDiff = typeof pr.additions === 'number' && typeof pr.deletions === 'number'
229 // GitHub works mergeability out lazily: UNKNOWN until then, so only CONFLICTING counts.
230 const hasConflict = isLive && pr.mergeable === 'CONFLICTING'
231
232 return {
233 state,
234 ci: ciSummary(pr.statusCheckRollup),
235 ...(review ? { review } : {}),
236 ...(title ? { title } : {}),
237 ...(author ? { author } : {}),
238 ...(hasDiff ? { additions: pr.additions, deletions: pr.deletions } : {}),
239 ...(hasConflict ? { hasConflict } : {}),
240 }
241}
242
243// A failed check keeps what was known; `ciSince` marks when CI started running so
244// polling can give up on a check that never finishes.
245export function nextStatus(prev: PrStatus | undefined, fetched: Fetched | undefined, now: number): PrStatus {
246 if (!fetched) return prev ? { ...prev, checkedAt: now } : { state: 'unknown', ci: 'none', checkedAt: now }
247 const ciSince = fetched.ci === 'running' ? (prev?.ci === 'running' ? prev.ciSince ?? now : now) : undefined
248
249 return { ...fetched, checkedAt: now, ...(ciSince !== undefined ? { ciSince } : {}) }
250}
251
252export const isStale = (s: PrStatus | IssueStatus | undefined, now: number, ms: number) => !s || now - s.checkedAt > ms
253
254export const shouldPoll = (s: PrStatus | undefined, now: number, windowMs: number) =>
255 s?.ci === 'running' && now - (s.ciSince ?? s.checkedAt) < windowMs
256
257const PR_GLYPH: Record<PrState, string> = { open: '🟢', draft: '📝', merged: '🟣', closed: '⚫' }
258const CI_GLYPH: Record<CI, string> = { pass: '✅', fail: '❌', running: '⏳', none: '' }
259const CONFLICT_GLYPH = '💥'
260const RV_GLYPH: Record<Review, string> = { approved: '👍', pending: '👀', changes: '✋', unassigned: '🦯', commented: '💬' }
261
262export type Glyphs = { pr: string; ci: string; rv: string; isPending: boolean }
263
264export function statusGlyphs(s: PrStatus | undefined): Glyphs {
265 if (!s) return { pr: '…', ci: '', rv: '', isPending: true }
266 if (s.state === 'unknown') return { pr: '?', ci: '', rv: '', isPending: true }
267
268 // A draft is not asking for review yet, so no reviewer is not news.
269 const rv = !s.review || (s.state === 'draft' && s.review === 'unassigned') ? '' : RV_GLYPH[s.review]
270
271 // A conflict blocks the merge whatever CI says, so it takes the CI cell.
272 const ci = s.hasConflict ? CONFLICT_GLYPH : CI_GLYPH[s.ci]
273
274 return { pr: PR_GLYPH[s.state], ci, rv, isPending: false }
275}
276
277// What the 5-minute watch re-checks: open and draft PRs, and ones not read yet. Merged
278// and closed PRs do not change, and a PR on the 90-second CI poll is covered there.
279export function watchKeys(
280 keys: readonly string[],
281 known: Readonly<Record<string, PrStatus>>,
282 now: number,
283 ciWindowMs: number,
284): string[] {
285 return keys.filter(k => {
286 if (!isPrKey(k)) return false
287 const s = known[k]
288 if (!s || s.state === 'unknown') return true
289 return (s.state === 'open' || s.state === 'draft') && !shouldPoll(s, now, ciWindowMs)
290 })
291}
292
293export type Label = { project: string; ref: string }
294
295// Dashes become spaces, except inside numbers such as dates.
296const words = (slug: string) => slug.replace(/-[0-9a-f]{8,}$/i, '').replace(/(?<!\d)-|-(?!\d)/g, ' ')
297
298// GitHub PRs read `repo | #N`; Linear issues `ENG-1 | slug words`, other Linear pages
299// `kind | name`, then the tab when the link names one (`· overview`), so two tabs of one
300// project read apart.
301export function linkLabel(url: string): Label {
302 const pr = prRef(url)
303 if (pr) return { project: pr.repo.split('/')[1] ?? pr.repo, ref: `#${pr.number}` }
304
305 const [, kind = '', id = '', ...rest] = url.replace(/^https?:\/\/[^/]+\//, '').split('/')
306 if (kind === 'issue' && id) return { project: id.toUpperCase(), ref: words(rest[0] ?? '') }
307 const tab = rest.filter(Boolean).join('/')
308 return { project: kind, ref: tab ? `${words(id)} · ${tab}` : words(id) }
309}
310
311// PRs above Linear links, each group keeping its order.
312export function bandGroups(list: readonly Item[]): { github: Item[]; linear: Item[] } {
313 return { github: list.filter(i => isPrKey(i.key)), linear: list.filter(i => !isPrKey(i.key)) }
314}
315
316// When the band's PRs were last read from GitHub, failed reads included.
317export function lastChecked(keys: readonly string[], known: Readonly<Record<string, { checkedAt: number }>>): number | undefined {
318 const times = keys.map(k => known[k]?.checkedAt).filter((t): t is number => t !== undefined)
319 return times.length > 0 ? Math.max(...times) : undefined
320}
321
322export const clockTime = (ms: number) =>
323 new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })
324
325export type ShortTitle = { tag?: string; text: string }
326
327const COMMIT_TYPES = new Set(['feat', 'fix', 'chore', 'build', 'perf', 'refactor', 'docs', 'test', 'ci', 'style', 'revert'])
328const TICKET = /\b[A-Z]{2,}-\d+\b|#\d+|\bT\d+[a-z]?\b/
329const CLAUSE = / (?:when|until|once|while|so|unless) |,? - |, (?:and|or|but|so|then) /
330
331// A PR title cut to what it does: `feat(search): x when y (T2)` reads `search` and `x`.
332export function shortTitle(title: string): ShortTitle {
333 let text = title.trim().replace(/^(?:\[[^\]]*\]\s*)+/, '').replace(/^[A-Z]{2,}-\d+:\s*/, '')
334 let tag: string | undefined
335
336 const scoped = text.match(/^(\w+)\(([^)]+)\)!?:?\s+/)
337 const bare = text.match(/^([a-z][\w-]*):\s+/)
338 if (scoped) {
339 tag = scoped[2]
340 text = text.slice(scoped[0].length)
341 } else if (bare?.[1]) {
342 if (!COMMIT_TYPES.has(bare[1])) tag = bare[1]
343 text = text.slice(bare[0].length)
344 }
345
346 text = text.replace(/\s*\(([^)]*)\)$/, (m, inner: string) => (TICKET.test(inner) ? '' : m))
347 const cut = text.match(CLAUSE)
348 if (cut?.index !== undefined && text.slice(0, cut.index).split(' ').length >= 2) text = text.slice(0, cut.index)
349
350 return { ...(tag ? { tag } : {}), text: text.trim() }
351}
352
353const WIDE = /\p{Extended_Pictographic}/u
354
355// Terminal cells: an emoji takes two, anything else one (variation selectors none).
356export const textWidth = (text: string) =>
357 [...text].reduce((n, ch) => n + (ch === '️' ? 0 : WIDE.test(ch) ? 2 : 1), 0)
358
359// A status glyph in a two-cell slot.
360export const cell = (glyph: string) => glyph + ' '.repeat(Math.max(0, 2 - textWidth(glyph)))
361
362// `text` cut to `room` cells, an ellipsis marking the cut.
363export function fitText(text: string, room: number): string {
364 if (textWidth(text) <= room) return text
365 if (room <= 0) return ''
366 let out = ''
367 for (const ch of text) {
368 if (textWidth(out + ch) > room - 1) break
369 out += ch
370 }
371 return `${out.trimEnd()}…`
372}
373
374// The PR author as two letters of their login, enough to tell the team apart.
375export const authorInitials = (login: string) => login.slice(0, 2).toLowerCase().padEnd(2)
376
377// A PR's changed lines as `+12 -3`, the additions padded to `width` digits so the
378// deletions line up down the band.
379export function diffParts(s: PrStatus | undefined, width: number): { plus: string; minus: string } | undefined {
380 if (s?.additions === undefined || s.deletions === undefined) return undefined
381 return { plus: `+${s.additions}`.padStart(width + 1), minus: `-${s.deletions}` }
382}
383
384const ISSUE_TYPES = new Set<string>(['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled', 'duplicate'])
385
386// A Linear `get_issue` result: its status name and type, whether the server sends the
387// status as a name or as `{ name, type }`.
388export function parseIssueResult(r: { content: readonly { type: string; text?: string }[]; isError: boolean }): Omit<IssueStatus, 'checkedAt'> | undefined {
389 if (r.isError) return undefined
390 let issue: { status?: unknown; statusType?: unknown; state?: unknown }
391 try {
392 issue = JSON.parse(r.content.find(c => c.type === 'text')?.text ?? '')
393 } catch {
394 return undefined
395 }
396 if (!issue || typeof issue !== 'object') return undefined
397 const raw = issue.status ?? issue.state
398 const obj = raw && typeof raw === 'object' ? (raw as { name?: unknown; type?: unknown }) : undefined
399 const name = typeof raw === 'string' ? raw : typeof obj?.name === 'string' ? obj.name : undefined
400 const kind = issue.statusType ?? obj?.type
401 const type = typeof kind === 'string' && ISSUE_TYPES.has(kind) ? (kind as IssueType) : undefined
402 if (!name && !type) return undefined
403
404 return { ...(name ? { name } : {}), ...(type ? { type } : {}) }
405}
406
407const ISSUE_GLYPH: Record<IssueType, string> = {
408 triage: '📥',
409 backlog: '💤',
410 unstarted: '⚪',
411 started: '🟡',
412 completed: '🟣',
413 canceled: '⚫',
414 duplicate: '⚫',
415}
416
417export const issueGlyph = (s: IssueStatus | undefined) => (!s ? '' : s.type ? ISSUE_GLYPH[s.type] : s.name ? '•' : '?')
418
419export const isIssueDone = (s: IssueStatus | undefined) =>
420 s?.type === 'completed' || s?.type === 'canceled' || s?.type === 'duplicate'
421
422// Linear issues the 5-minute watch re-checks: all but the finished ones.
423export const issueWatchKeys = (keys: readonly string[], known: Readonly<Record<string, IssueStatus>>) =>
424 keys.filter(k => isIssueKey(k) && !isIssueDone(known[k]))
425types/index.d.ts 67 lines1export type Item = {
2 key: string
3 url: string
4}
5
6export type PrState = 'open' | 'draft' | 'merged' | 'closed'
7export type CI = 'pass' | 'fail' | 'running' | 'none'
8export type Review = 'approved' | 'changes' | 'commented' | 'pending' | 'unassigned'
9
10export type PrStatus = {
11 state: PrState | 'unknown'
12 ci: CI
13 review?: Review
14 title?: string
15 author?: string
16 additions?: number
17 deletions?: number
18 hasConflict?: boolean
19 checkedAt: number
20 ciSince?: number
21}
22
23export type IssueType = 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled' | 'duplicate'
24
25// A Linear issue's workflow status; neither field when the read failed.
26export type IssueStatus = {
27 name?: string
28 type?: IssueType
29 checkedAt: number
30}
31
32// What the session's agent says it is doing, set through the set_status tool.
33export type StatusNote = {
34 text: string
35 setAt: number
36}
37
38export type BandColor = {
39 color: string
40 isFill: boolean
41}
42
43declare module 'claude-code' {
44 interface McpToolInputs {
45 /** This session's links band state; the mod registers and answers it itself. */
46 'mcp__gh-linear-parse-mod__links_band': {}
47 /** Sets or clears the status line at the top of the band. */
48 'mcp__gh-linear-parse-mod__set_status': { text?: string }
49 }
50
51 interface PluginState {
52 'gh-linear-parse-mod': {
53 items: Item[]
54 dismissed: string[]
55 isHidden: boolean
56 isExpanded: boolean
57 bandColor: BandColor | null
58 hasBackfilled: boolean
59 prStatuses: Record<string, PrStatus>
60 issueStatuses: Record<string, IssueStatus>
61 isWatching: boolean
62 refreshing: number
63 agentStatus: StatusNote | null
64 }
65 }
66}
67