SLOPSHOPPER

gh-linear-parse-mod

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…

newbandguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-16mreedr/gh-linear-parse-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-linear-parse-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gh-linear-parse-mod ⎿ gh-linear-parse-mod: Session links hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

gh-linear-parse-mod

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.

Install

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.

Browser

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.

Usage

CommandEffect
/gh-linear-parse-modToggle 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 clearEmpty the band for this session
/gh-linear-parse-mod refreshRe-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 resetLet 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 status

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.

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

Linear status

Status typeEmoji
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's view of the band

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's status

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.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 4 files
hooks/register.tsx 738 lines
1import { 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}
738
hooks/browser.ts 66 lines
1import 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}
66
hooks/parse.ts 425 lines
1import 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]))
425
types/index.d.ts 67 lines
1export 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