SLOPSHOPPER

linear-watch

Linear Watch + PR Watch: your Linear tickets and their PRs above the prompt, hourly. /tickets and /prs panes, compact and expanded modes, toasts on real…

newpanebandcommandtoaststatus
v0.7.0no licenseupdated 2026-10-09Mark-Hickey/claude-nine/linear-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · linear-watch
│ ┃ Linear tickets ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Linear Tickets 0 open · not checked yet │ linear-watch │ │ ┃ ⚠ Last check failed. This is older data. ⏺ Read(src/auth.ts) │ linear-watch: Linear is unreachable. The │ │ ┃ ⎿ Read 6 lines │ band shows the last good data. │ │ ┃ No open Linear tickets assigned to you. ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ e: compact r: refresh k: keys q: close ⏺ 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 │ │ › /tickets │ ⎿ linear-watch: Could not reach Linear: JSON Parse error: Unexpect │ │ ◆ Linear: no open tickets ⚠ stale e: ⌃ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ linear-watch: Linear: unreachable

Draws

Band
◆ Linear: no open tickets ⚠ stale e: ⌃ ⟨Claude Code's own drawing⟩
Pane · Linear tickets
Linear Tickets 0 open · not checked yet ⚠ Last check failed. This is older data. No open Linear tickets assigned to you. e: compact r: refresh k: keys q: close
Pane · PR Watch
Pull Requests 0 open · not checked yet ⚠ Last check failed. This is older data. No pull requests are linked to your open Linear tickets. e: compact r: refresh k: keys q: close
README

linear-watch

Linear Watch and PR Watch for Claude Code. It shows the Linear tickets assigned to you and the GitHub pull requests linked to them, above the prompt. It checks every hour.

What it shows

Above the prompt. Two cards, side by side when the terminal has 100 columns or more:

  • Linear Watch: each open ticket, its title and its Linear status (for example IN REVIEW).
  • PR Watch: each linked PR, what its state means for you (for example "Your operator can merge") and a status word (READY, REVIEW, FAILING, MERGED and others).

On a narrow terminal, the two cards become one card. Push ⌄ (or e) to fold the cards to one line. When other mods (for example mesh-toaster) need the rows above the prompt, the cards fold to one line automatically, so all mods stay visible.

/prs opens the PR Watch pane. PRs are in groups by who must act next: Needs you, Waiting on others, Done. Each PR card shows:

  • CI result. Each failed check is a link to its log.
  • Reviews: who approved, who asked for changes, who must still review.
  • Merge state from GitHub (for example "Blocked by branch rules" or "GitHub allows the merge").
  • The next action and who does it (for example "You fix CI" or "Operator merges").
  • The linked ticket and a link to the PR.

/tickets opens the Linear Tickets pane: each ticket as a card with its PRs.

In both panes: e expand or compact, r refresh, k show the key of symbols, o open the first action that needs you, q or Escape close. /tickets compact and /tickets expand set the view above the prompt. The mod keeps your choices between sessions.

Act from the cards

  • Each next action is a link to where you act. For example, a failed check links to its log, a merge conflict to the PR's conflicts page, requested changes to the PR's files, and a ticket to Linear. Each action line has a ↗ open button.
  • o opens the first action that needs you in your browser. From WSL the mod uses wslview or explorer.exe, else the desktop's opener. It opens only https links, and it gives the link to the opener as an argument, never as shell text.
  • Ticket and PR names are links to Linear and to GitHub.
  • Close-out. When all PRs of a ticket are merged, the ticket shows "→ Next: You move <id> to Done". /prs has a "Tickets to close" section.
  • Merged PRs fold to one dim line everywhere.
  • Cards that need you have a coloured border and a bold next-action line.

Stages: how far each ticket and PR has got

Each ticket and each PR has a line of stages. Each stage shows ✓ done, ● now, ◷ waiting, ✕ blocked, ○ not yet or – not used.

  • Ticket: ✓ Backlog → ✓ Todo → ● In Review → ○ Done · for 4d. The stages come from the real state history of the ticket in Linear.
  • PR: ✓ Open → ✓ CI → ✓ Review → ● Merge → ○ Merged · open 3d.

When there is not sufficient width, the line becomes shorter: ✓✓✓●○ Merge. The panes use the width of the pane, not of the terminal. Thus a pane docked at the side of the terminal does not go past its edge.

How it decides a PR state

A PR is Ready only when its checks pass, its reviews approve it, and GitHub says it can merge. Passing CI alone is not enough. The mod also looks at draft state, requested changes, merge conflicts, branch protection and a branch that is behind its base. If GitHub does not answer, the PR shows Unknown. The mod does not guess.

A ready PR in your own GitHub account is yours to merge. In all other repos your operator merges it (handbook rule 3).

Notifications

A toast comes only for a real change: a new ticket, a ticket status change, a new linked PR, an approval, requested changes, failed checks, a merge or a close. Checks that start on each push show only in the pane. A check with no changes shows nothing. When more than 3 changes come at the same time, you get one toast for all of them. When Linear does not answer two times in a row, you get one toast, and the cards show ⚠ stale with the last good data.

Requirements

  • A Linear connector in Claude Code: the claude.ai Linear connector, or the Linear server of the design plugin. The mod tries both.
  • The gh CLI, signed in (gh auth status). The mod reads PR states with gh pr view.

Install

/plugin install linear-watch --marketplace Mark-Hickey/claude-mods

Develop

claude plugin validate linear-watch
claude plugin test linear-watch

hooks/model.ts has the rules (PR states, who acts, changes). hooks/view.tsx draws the cards. hooks/register.tsx reads Linear and GitHub and connects the hooks.

Source 4 files
hooks/register.tsx 393 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Health, Issue, Mode, Pr, StateVisit, Update } from '../types'
5import { GH_FIELDS, PR_LOOK, STEP_LOOK, actionsForYou, allActions, allPrs, byUrgency, changes, groupPrs, inStateFor, isDone, isOpen, keyFor, prAction, prFromGh, prName, prRefs, prSteps, prWhen, rowCells, snapshotOf, stepsLine, ticketAction, ticketSteps, toastsFor } from './model'
6import type { Action, GhPr, Snapshot } from './model'
7import { band, cardRows, keyChart, paneHeader, rowsOf, prGroups, recent, ticketBlock, toolbar } from './view'
8
9
10const HOUR = 60 * 60 * 1000
11const TICKETS = 'linear-tickets'
12const PRS = 'linear-prs'
13// Store keys. 'seen' is the 0.4 snapshot (ticket id to status), read once if 'snapshot' is missing.
14const SNAPSHOT_KEY = 'snapshot'
15const LEGACY_SEEN_KEY = 'seen'
16const MODE_KEY = 'mode'
17const UPDATES_KEY = 'updates'
18const FAILED = 'linear-watch: that command failed. The debug log (claude --debug) has the reason.'
19
20const openIssues = atom({ plugin: 'linear-watch', key: 'open' } as const, [])
21const modeAtom = atom({ plugin: 'linear-watch', key: 'mode' } as const, { band: 'expanded', pane: 'expanded', key: false })
22const updatesAtom = atom({ plugin: 'linear-watch', key: 'updates' } as const, [])
23const unseenAtom = atom({ plugin: 'linear-watch', key: 'unseen' } as const, 0)
24const healthAtom = atom({ plugin: 'linear-watch', key: 'health' } as const, { failures: 0 })
25const checkingAtom = atom({ plugin: 'linear-watch', key: 'checking' } as const, false)
26
27type ModeState = { band: Mode; pane: Mode; key: boolean }
28
29// Open tickets most pressing first: the band's one line shows the ticket with the PR that needs the most.
30const byTicketUrgency = (a: Issue, b: Issue) => {
31  const top = (i: Issue) => [...(i.prs ?? [])].sort(byUrgency)[0]
32  const ta = top(a)
33  const tb = top(b)
34  if (ta === undefined || tb === undefined) return ta === undefined ? (tb === undefined ? 0 : 1) : -1
35  return byUrgency(ta, tb)
36}
37
38// The plain-text lists the commands return: for terminals too narrow for a pane, and for scrollback.
39// Each ticket and active PR has a stages line and its next action with the page to do it on;
40// finished PRs take one line. Terminals make the bare URLs clickable.
41const prHead = (c: ReturnType<typeof rowCells>[number] | undefined, pr: Pr) =>
42  c ? `${c.glyph} ${c.name}  ${c.state}${c.showCi ? `  ${c.ci}` : ''}${c.showNext ? `  ${c.next}` : ''}`.trimEnd() : prName(pr)
43const doneText = (pr: Pr, now: number) => `${PR_LOOK[pr.state].glyph} ${prName(pr)}  ${prWhen(pr, now) ?? pr.state}`
44const actionText = (a: Action) => `${a.who === 'you' ? '→' : '·'} Next: ${a.text} · ${a.url}`
45
46export function ticketsText(open: Issue[], me: string | null, now: number, columns = 100): string {
47  if (open.length === 0) return 'No open Linear tickets assigned to you.'
48  const blocks = open.map(i => {
49    const prs = [...(i.prs ?? [])].sort(byUrgency)
50    const active = prs.filter(p => !isDone(p))
51    const cells = rowCells(active, me, columns - 2)
52    const since = inStateFor(i, now)
53    const close = ticketAction(i)
54    const rows = active.flatMap((pr, n) => {
55      const when = prWhen(pr, now)
56      return [`  ${prHead(cells[n], pr)}`, `      ${stepsLine(prSteps(pr), columns - 6)}${when ? ` · ${when}` : ''}`, `      ${actionText(prAction(pr, me))}`]
57    })
58    return [
59      `◆ ${i.id}  ${i.status}${since ? ` · ${since}` : ''}`,
60      `  ${i.title}`,
61      `  ${stepsLine(ticketSteps(i), columns - 2)}`,
62      ...(close ? [`  ${actionText(close)}`] : []),
63      ...rows,
64      ...prs.filter(isDone).map(pr => `  ${doneText(pr, now)}`),
65      ...(prs.length === 0 ? ['  No linked PRs'] : []),
66      ...(close ? [] : [`  ${i.url}`]),
67    ].join('\n')
68  })
69  return blocks.join('\n\n') + legend(open)
70}
71
72export function prsText(open: Issue[], me: string | null, now: number, columns = 100): string {
73  const prs = allPrs(open)
74  if (prs.length === 0) return 'No pull requests are linked to your open Linear tickets.'
75  const groups = groupPrs(prs, me).map(g => {
76    if (g.title === 'Done') return [`Done (${g.prs.length})`, ...g.prs.map(pr => `  ${doneText(pr, now)}${pr.ticket ? ` · ${pr.ticket}` : ''}`)].join('\n')
77    const cells = rowCells(g.prs, me, columns - 2)
78    const rows = g.prs.map((pr, n) => {
79      const when = prWhen(pr, now)
80      return [`  ${prHead(cells[n], pr)}`, `      ${stepsLine(prSteps(pr), columns - 6)}${when ? ` · ${when}` : ''}${pr.ticket ? ` · ${pr.ticket}` : ''}`, `      ${actionText(prAction(pr, me))}`].join('\n')
81    })
82    return [`${g.title} (${g.prs.length})`, ...rows].join('\n')
83  })
84  const close = open.flatMap(i => ticketAction(i) ?? [])
85  const closing = close.length > 0 ? [`Tickets to close (${close.length})`, ...close.map(a => `  ${actionText(a)}`)].join('\n') : undefined
86  return [...(closing ? [closing] : []), ...groups].join('\n\n') + legend(open)
87}
88
89// The key at the end of the text lists: the PR states in use, then the stage marks.
90function legend(open: Issue[]): string {
91  const key = keyFor(open)
92  const marks = (['done', 'now', 'waiting', 'failed', 'todo'] as const).map(s => `${STEP_LOOK[s].glyph} ${STEP_LOOK[s].word}`).join('  ')
93  return `\n\nKey:\n${key.map(k => `  ${k}`).join('\n')}${key.length > 0 ? '\n' : ''}  Stages: ${marks}`
94}
95
96// The status line: open tickets, and how many actions wait on you (PR fixes and tickets to close).
97export function statusText(open: Issue[], me: string | null): string {
98  const mine = actionsForYou(open, me).length
99  return `Linear: ${open.length} open${mine > 0 ? ` · ${mine} need${mine === 1 ? 's' : ''} you` : ''}`
100}
101
102// --- Reading Linear and GitHub. Linear comes through whichever Linear MCP server the session has;
103// GitHub through the gh CLI and its own sign-in. No tokens are read or kept here.
104// The Linear servers a session may have, newest first: the claude.ai connector, then the
105// design plugin's server. The first that answers is kept for the rest of the session.
106const LINEAR_SERVERS = ['mcp__claude_ai_Linear__', 'mcp__plugin_design_linear__'] as const
107
108let server: string | undefined
109
110type Ran = { deny?: string; isError?: boolean; text?: string }
111
112// Calls a Linear tool on the server that last answered, trying each in turn until one does.
113async function linearCall($: EngineInterface, name: string, input: Record<string, unknown>): Promise<string> {
114  const order = server ? [server, ...LINEAR_SERVERS.filter(s => s !== server)] : [...LINEAR_SERVERS]
115  let last = 'no Linear server in this session'
116  for (const s of order) {
117    try {
118      // The tool's name is built at run time, so no typed overload names it.
119      const ran = (await $.tool.call({ tool: `${s}${name}`, ...input } as never)) as Ran
120      if (ran.deny === undefined && !ran.isError) {
121        server = s
122        return ran.text ?? '{}'
123      }
124      last = ran.deny ?? ran.text ?? `${s}${name} failed`
125    } catch (err) {
126      last = err instanceof Error ? err.message : String(err)
127    }
128  }
129  throw new Error(last)
130}
131
132const nameOf = (v: unknown): string | undefined =>
133  typeof v === 'string' ? v : v && typeof v === 'object' && 'name' in v && typeof v.name === 'string' ? v.name : undefined
134
135async function fetchIssues($: EngineInterface): Promise<Issue[]> {
136  const text = await linearCall($, 'list_issues', {
137    assignee: 'me',
138    fields: ['title', 'status', 'statusType', 'url', 'priority', 'project', 'updatedAt'],
139    limit: 100,
140  })
141  const raw = (JSON.parse(text).issues ?? []) as Record<string, unknown>[]
142  return raw.map(r => {
143    const i: Issue = {
144      id: String(r.id),
145      title: String(r.title ?? ''),
146      status: String(nameOf(r.status) ?? r.status ?? ''),
147      statusType: String(r.statusType ?? ''),
148      url: String(r.url ?? ''),
149    }
150    const priority = nameOf(r.priority)
151    const project = nameOf(r.project)
152    if (priority && priority !== 'No priority') i.priority = priority
153    if (project) i.project = project
154    if (typeof r.updatedAt === 'string') i.updatedAt = r.updatedAt
155    return i
156  })
157}
158
159// One ticket's details: its linked PRs, each with its state on GitHub, and the states it went
160// through. A ticket Linear cannot read keeps no PRs; a PR gh cannot read is kept, honestly, as 'unknown'.
161async function fetchDetail($: EngineInterface, ticket: string): Promise<{ prs: Pr[]; history?: StateVisit[] }> {
162  let raw: { attachments?: { url?: string }[]; stateHistory?: { state?: { name?: string; type?: string }; startedAt?: string }[] }
163  try {
164    raw = JSON.parse(await linearCall($, 'get_issue', { id: ticket }))
165  } catch {
166    return { prs: [] }
167  }
168  const history = (raw.stateHistory ?? []).flatMap(h =>
169    h.state?.name && h.startedAt ? [{ name: h.state.name, type: h.state.type ?? '', at: h.startedAt }] : [],
170  )
171  const prs = await Promise.all(
172    prRefs(raw.attachments ?? []).map(async ref => {
173      try {
174        const gh = await $.process.run(['gh', 'pr', 'view', String(ref.number), '-R', ref.repo, '--json', GH_FIELDS], { timeoutMs: 20_000 })
175        if (gh.exitCode === 0) return prFromGh(ref, JSON.parse(gh.stdout) as GhPr, ticket)
176      } catch {
177        // Fall through to unknown.
178      }
179      return { ...ref, state: 'unknown' as const, ticket }
180    }),
181  )
182  return history.length > 0 ? { prs, history } : { prs }
183}
184
185// The GitHub login gh is signed in as, read once; null when gh cannot say.
186let login: string | null | undefined
187async function myLogin($: EngineInterface): Promise<string | null> {
188  if (login !== undefined) return login
189  const gh = await $.process.run(['gh', 'api', 'user', '--jq', '.login'], { timeoutMs: 15_000 }).catch(() => null)
190  login = gh?.exitCode === 0 && gh.stdout.trim() ? gh.stdout.trim() : null
191  return login
192}
193
194let inflight: Promise<Issue[] | null> | null = null
195
196// One check: Linear, then each open ticket's PRs, then what changed. Concurrent callers share it.
197function check($: EngineInterface): Promise<Issue[] | null> {
198  inflight ??= run($).finally(() => {
199    inflight = null
200  })
201  return inflight
202}
203
204async function run($: EngineInterface): Promise<Issue[] | null> {
205  await update($, checkingAtom, () => true)
206  try {
207    let issues: Issue[]
208    try {
209      issues = await fetchIssues($)
210    } catch (err) {
211      const health: Health = await update($, healthAtom, h => ({ ...h, failures: h.failures + 1, lastError: err instanceof Error ? err.message : String(err) }))
212      $.ui.status(health.lastOk === undefined ? 'Linear: unreachable' : 'Linear: unreachable · showing older data')
213      // One failure is often a blip; say so once, on the second in a row.
214      if (health.failures === 2) $.ui.toast('linear-watch: Linear is unreachable. The band shows the last good data.', { timeoutMs: 8000 })
215      return null
216    }
217    const me = await myLogin($)
218    const open = (await Promise.all(issues.filter(isOpen).map(async i => ({ ...i, ...(await fetchDetail($, i.id)) })))).sort(byTicketUrgency)
219    const now = await $.clock.now()
220
221    // Compare with what the last check saw. A missing snapshot (first run) toasts nothing.
222    const stored = (await $.store.get(SNAPSHOT_KEY)) as Snapshot | undefined
223    const legacy = stored === undefined ? ((await $.store.get(LEGACY_SEEN_KEY)) as Record<string, string> | undefined) : undefined
224    const before: Snapshot | null = stored ?? (legacy !== undefined ? { tickets: legacy } : null)
225    const all = issues.map(i => open.find(o => o.id === i.id) ?? i)
226    const found = changes(before, all, me, now)
227    await $.store.set(SNAPSHOT_KEY, snapshotOf(all, me, before))
228
229    await update($, openIssues, () => open)
230    await update($, healthAtom, () => ({ failures: 0, lastOk: now }))
231    $.ui.status(statusText(open, me))
232    if (found.length > 0) {
233      const kept: Update[] = await update($, updatesAtom, list => [...found, ...list].slice(0, 30))
234      await $.store.set(UPDATES_KEY, kept)
235      await update($, unseenAtom, n => n + found.filter(u => u.level !== 'quiet').length)
236      for (const t of toastsFor(found)) $.ui.toast(t.text, { timeoutMs: t.timeoutMs })
237    }
238    return open
239  } finally {
240    await update($, checkingAtom, () => false)
241  }
242}
243
244async function setMode($: EngineInterface, fn: (m: ModeState) => ModeState) {
245  const next = await update($, modeAtom, fn)
246  await $.store.set(MODE_KEY, next)
247}
248
249// /tickets and /prs: a mode argument sets the band; otherwise open the pane and list the same data as text.
250async function runCommand($: EngineInterface, args: string, command: 'tickets' | 'prs'): Promise<{ text: string }> {
251  const mode = modeArg(args)
252  if (mode !== undefined) {
253    await setMode($, m => ({ ...m, band: mode }))
254    return { text: `The band above the prompt is now ${mode === 'expanded' ? 'expanded (cards)' : 'compact (one line)'}.` }
255  }
256  const opened = command === 'tickets'
257    ? await $.ui.open({ id: TICKETS, title: 'Linear tickets', focus: true, closeOnEscape: true })
258    : await $.ui.open({ id: PRS, title: 'PR Watch', focus: true, closeOnEscape: true })
259  await update($, unseenAtom, () => 0)
260  // Say why the pane is not showing, since an unplaced pane is otherwise silent.
261  const note = opened.isPlaced ? '' : `\n(Pane not shown: ${opened.reason})`
262  const open = await check($)
263  if (open === null) return { text: `Could not reach Linear: ${(await read($, healthAtom)).lastError ?? 'no answer'}.${note}` }
264  const me = await myLogin($)
265  const now = await $.clock.now()
266  return { text: (command === 'tickets' ? ticketsText(open, me, now) : prsText(open, me, now)) + note }
267}
268
269// Opens a page in the person's browser: through Windows from WSL, else the desktop's opener.
270// Only https pages from Linear or GitHub data; the URL goes in as an argument, never as shell text.
271async function openUrl($: EngineInterface, url: string, label: string) {
272  if (!/^https:\/\/[^\s]+$/.test(url)) {
273    $.ui.toast(`linear-watch: not opening ${label}: not an https link`)
274    return
275  }
276  const opener = 'if command -v wslview >/dev/null; then wslview "$1"; elif command -v explorer.exe >/dev/null; then explorer.exe "$1"; elif command -v xdg-open >/dev/null; then xdg-open "$1"; else open "$1"; fi'
277  const ran = await $.process.run(['sh', '-c', opener, 'open-url', url], { timeoutMs: 10_000 }).catch(() => null)
278  // explorer.exe reports exit 1 even when it opened the page; only a missing opener is a failure.
279  if (ran === null || ran.exitCode === 127) $.ui.toast(`linear-watch: could not open a browser. The link: ${url}`, { timeoutMs: 10_000 })
280  else $.ui.toast(`Opened: ${label}`)
281}
282
283// The pane toolbar's actions: each one real, each one the same in both panes.
284function actions($: EngineInterface, pane: string) {
285  return {
286    toggle: () => setMode($, m => ({ ...m, pane: m.pane === 'expanded' ? 'compact' : 'expanded' })),
287    refresh: () => check($),
288    key: () => setMode($, m => ({ ...m, key: !m.key })),
289    close: () => $.ui.close({ id: pane }),
290    open: (url: string, label: string) => openUrl($, url, label),
291  }
292}
293
294// "compact" or "expand(ed)" sets the band's mode; anything else leaves it.
295export function modeArg(args: string): Mode | undefined {
296  const a = args.trim().toLowerCase()
297  if (a === 'compact' || a === 'less') return 'compact'
298  if (a === 'expand' || a === 'expanded' || a === 'more') return 'expanded'
299  return undefined
300}
301
302export const register: Register = on => {
303  on('session.start', async ($, e, next) => {
304    const hint = '[compact | expand]: one line or cards above the prompt'
305    await $.command.register({ name: 'tickets', description: 'Your open Linear tickets and their PRs', argumentHint: hint })
306    await $.command.register({ name: 'prs', description: 'PR Watch: the PRs on your Linear tickets, by who acts next', argumentHint: hint })
307    // Restore the person's modes and recent changes; check now and hourly.
308    const mode = (await $.store.get(MODE_KEY)) as ModeState | undefined
309    if (mode !== undefined) await update($, modeAtom, m => ({ ...m, ...mode }))
310    const updates = (await $.store.get(UPDATES_KEY)) as Update[] | undefined
311    if (updates !== undefined) await update($, updatesAtom, () => updates)
312    // A check that fails is already reported on the status line; nothing else waits on it.
313    check($).catch(() => undefined)
314    $.clock.every(HOUR, () => void check($).catch(() => undefined))
315    return next(e)
316  })
317
318  // A command that throws says so in its own output rather than going silent.
319  on('command.run', { command: 'tickets' }, ($, e) => runCommand($, e.args ?? '', 'tickets'))
320    .catch(($, e, next) => (next.called ? next(e) : { text: FAILED }))
321  on('command.run', { command: 'prs' }, ($, e) => runCommand($, e.args ?? '', 'prs'))
322    .catch(($, e, next) => (next.called ? next(e) : { text: FAILED }))
323
324  on('ui.render', { component: 'Pane', requestId: TICKETS }, async ($, e) => {
325    const ui = $.ui.resolve(e)
326    const { Box, Text } = ui
327    const [open, mode, health, checking, updates, me, now] = await Promise.all([
328      read($, openIssues), read($, modeAtom), read($, healthAtom), read($, checkingAtom), read($, updatesAtom), myLogin($), $.clock.now(),
329    ])
330    // The pane's own body width: docked beside the transcript it is far narrower than the terminal.
331    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 100
332    const act = actions($, TICKETS)
333    return (
334      <Box flexDirection="column">
335        {paneHeader(ui, 'Linear Tickets', `${open.length} open`, health, checking, now, columns)}
336        {open.length === 0 && <Text dimColor>No open Linear tickets assigned to you.</Text>}
337        {open.map(i => ticketBlock(ui, i, me, columns, mode.pane, now, act.open))}
338        {mode.pane === 'expanded' && recent(ui, updates, now, columns, () => true)}
339        {mode.key && keyChart(ui, columns)}
340        {toolbar(ui, mode.pane, mode.key, allActions(open, me)[0], act)}
341      </Box>
342    )
343  })
344
345  on('ui.render', { component: 'Pane', requestId: PRS }, async ($, e) => {
346    const ui = $.ui.resolve(e)
347    const { Box, Text } = ui
348    const [open, mode, health, checking, updates, me, now] = await Promise.all([
349      read($, openIssues), read($, modeAtom), read($, healthAtom), read($, checkingAtom), read($, updatesAtom), myLogin($), $.clock.now(),
350    ])
351    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 100
352    const prs = allPrs(open)
353    const mine = actionsForYou(open, me).length
354    const act = actions($, PRS)
355    const closing = open.flatMap(i => ticketAction(i) ?? [])
356    return (
357      <Box flexDirection="column">
358        {paneHeader(ui, 'Pull Requests', `${prs.filter(p => !isDone(p)).length} open${mine > 0 ? ` · ${mine} need${mine === 1 ? 's' : ''} you` : ''}`, health, checking, now, columns)}
359        {prs.length === 0 && <Text dimColor>No pull requests are linked to your open Linear tickets.</Text>}
360        {closing.length > 0 && <Text color="yellow" bold>{`TICKETS TO CLOSE · ${closing.length}`}</Text>}
361        {closing.map(a => <Text key={`close:${a.ticket}`} color="cyan">{`  → ${a.text}`}</Text>)}
362        {closing.length > 0 && <Text>{' '}</Text>}
363        {prGroups(ui, prs, me, columns, mode.pane, now, act.open)}
364        {mode.pane === 'expanded' && recent(ui, updates, now, columns, u => u.pr !== undefined)}
365        {mode.key && keyChart(ui, columns)}
366        {toolbar(ui, mode.pane, mode.key, allActions(open, me)[0], act)}
367      </Box>
368    )
369  })
370
371  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
372    // Draw this line, then let the mods beneath draw theirs under it.
373    const below = await next(e)
374    if (e.props.hasSurvey) return below
375    const ui = $.ui.resolve(e)
376    const [open, mode, unseen, health, me, now] = await Promise.all([read($, openIssues), read($, modeAtom), read($, unseenAtom), read($, healthAtom), myLogin($), $.clock.now()])
377    // The band's own width, not the terminal's: it leaves room for the engine's marks.
378    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 100
379    const prs = allPrs(open)
380    const toggle = () => setMode($, m => ({ ...m, band: m.band === 'expanded' ? 'compact' : 'expanded' }))
381    // The band is shared and capped at maxRows. When the cards and the other mods' drawing
382    // would not fit together, fold to one line so nobody's drawing scrolls out of view.
383    const fits = cardRows(open, prs, columns) + rowsOf(below) <= (e.props.maxRows ?? 24)
384    const shown: Mode = mode.band === 'expanded' && !fits ? 'compact' : mode.band
385    return (
386      <ui.Box flexDirection="column">
387        {band(ui, open, prs, me, columns, shown, unseen, health, now, toggle, (url, label) => openUrl($, url, label))}
388        {below}
389      </ui.Box>
390    )
391  })
392}
393
hooks/model.ts 582 lines
1// Status interpretation for tickets and PRs: no engine calls, so every rule here is tested directly.
2import type { Actor, Check, Issue, Pr, PrState, Reviews, Step, StepState, Update } from '../types'
3
4// How each PR state reads. Every state has a glyph AND a word, so colour is never the only signal.
5// Green done, cyan ready, yellow waiting, red someone must fix, gray inactive or unknown.
6export const PR_LOOK: Record<PrState, { glyph: string; color: string; label: string; means: string }> = {
7  merged: { glyph: '✓', color: 'green', label: 'Merged', means: 'The PR is in the base branch.' },
8  ready: { glyph: '●', color: 'cyan', label: 'Ready', means: 'Checks passed, reviews approved, GitHub says it can merge.' },
9  blocked: { glyph: '◇', color: 'yellow', label: 'Blocked', means: 'Checks and reviews look done, but branch rules still block the merge.' },
10  running: { glyph: '◌', color: 'yellow', label: 'CI running', means: 'CI checks are still running.' },
11  review: { glyph: '◷', color: 'yellow', label: 'Awaiting review', means: 'Checks passed. No reviewer has approved it yet.' },
12  changes: { glyph: '✎', color: 'red', label: 'Changes requested', means: 'A reviewer asked for changes.' },
13  failing: { glyph: '✕', color: 'red', label: 'Checks failing', means: 'One or more CI checks failed.' },
14  conflict: { glyph: '≠', color: 'red', label: 'Merge conflict', means: 'The branch conflicts with its base.' },
15  behind: { glyph: '↓', color: 'yellow', label: 'Behind base', means: 'Branch rules need the branch updated from its base.' },
16  draft: { glyph: '○', color: 'gray', label: 'Draft', means: 'The PR is a draft, not ready for review.' },
17  closed: { glyph: '⊘', color: 'gray', label: 'Closed', means: 'The PR was closed without a merge.' },
18  unknown: { glyph: '?', color: 'gray', label: 'Unknown', means: 'GitHub did not answer for this PR.' },
19}
20
21// The order the key lists states in: done first, then waiting, then needs a fix, then inactive.
22export const KEY_ORDER: PrState[] = ['merged', 'ready', 'blocked', 'running', 'review', 'behind', 'changes', 'failing', 'conflict', 'draft', 'closed', 'unknown']
23
24export const keyLine = (s: PrState) => `${PR_LOOK[s].glyph} ${PR_LOOK[s].label}: ${PR_LOOK[s].means}`
25
26// The key for the states the given tickets use, in key order.
27export function keyFor(issues: Issue[]): string[] {
28  const used = new Set(issues.flatMap(i => (i.prs ?? []).map(pr => pr.state)))
29  return KEY_ORDER.filter(s => used.has(s)).map(keyLine)
30}
31
32export const isOpen = (i: Issue) => !['completed', 'canceled', 'duplicate'].includes(i.statusType)
33
34// The GitHub pull requests a ticket links to, from its attachments' URLs, once each.
35export function prRefs(attachments: { url?: string }[]): { repo: string; number: number; url: string }[] {
36  const refs = attachments.flatMap(a => {
37    const m = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(a.url ?? '')
38    return m?.[1] && m[2] ? [{ repo: m[1], number: Number(m[2]), url: `https://github.com/${m[1]}/pull/${m[2]}` }] : []
39  })
40  return refs.filter((r, i) => refs.findIndex(o => o.repo === r.repo && o.number === r.number) === i)
41}
42
43// What `gh pr view --json` returns for the fields fetch asks for.
44export type GhPr = {
45  state?: string
46  isDraft?: boolean
47  title?: string
48  author?: { login?: string } | null
49  reviewDecision?: string | null
50  mergeable?: string | null
51  mergeStateStatus?: string | null
52  reviewRequests?: { login?: string; name?: string; slug?: string }[]
53  latestReviews?: { author?: { login?: string } | null; state?: string }[]
54  createdAt?: string | null
55  mergedAt?: string | null
56  closedAt?: string | null
57  statusCheckRollup?: { name?: string | null; context?: string | null; status?: string | null; conclusion?: string | null; state?: string | null; detailsUrl?: string | null; targetUrl?: string | null }[]
58}
59
60export const GH_FIELDS = 'state,isDraft,title,author,reviewDecision,mergeable,mergeStateStatus,reviewRequests,latestReviews,statusCheckRollup,createdAt,mergedAt,closedAt'
61
62const FAILED = ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
63
64// Each check run or status context as passed, failed or running.
65export function checksOf(pr: GhPr): Check[] {
66  return (pr.statusCheckRollup ?? []).map(c => {
67    const name = c.name ?? c.context ?? 'check'
68    const url = c.detailsUrl ?? c.targetUrl ?? undefined
69    const done = c.conclusion ?? c.state ?? ''
70    const isRunning = (c.status !== undefined && c.status !== null && c.status !== 'COMPLETED') || done === 'PENDING' || done === 'EXPECTED'
71    const outcome: Check['outcome'] = isRunning ? 'running' : FAILED.includes(done) ? 'failed' : 'passed'
72    return url ? { name, outcome, url } : { name, outcome }
73  })
74}
75
76export function reviewsOf(pr: GhPr): Reviews {
77  const by = (state: string) => (pr.latestReviews ?? []).filter(r => r.state === state).map(r => r.author?.login ?? 'someone')
78  const requested = (pr.reviewRequests ?? []).map(r => r.login ?? r.slug ?? r.name ?? 'someone')
79  return { approvedBy: by('APPROVED'), changesBy: by('CHANGES_REQUESTED'), requested }
80}
81
82export function mergeableOf(pr: GhPr): 'yes' | 'no' | 'unknown' {
83  const s = pr.mergeStateStatus ?? ''
84  if (['CLEAN', 'HAS_HOOKS', 'UNSTABLE'].includes(s)) return 'yes'
85  if (['BLOCKED', 'DIRTY', 'BEHIND', 'DRAFT'].includes(s) || pr.mergeable === 'CONFLICTING') return 'no'
86  return 'unknown'
87}
88
89// One state from what GitHub reports. Passing CI alone never makes a PR ready: merged and
90// closed win, then draft, failing checks, requested changes, conflicts, running checks,
91// missing reviews, and last the merge state GitHub computes from branch protection.
92export function classifyPr(pr: GhPr): PrState {
93  if (pr.state === 'MERGED') return 'merged'
94  if (pr.state === 'CLOSED') return 'closed'
95  if (pr.isDraft) return 'draft'
96  const checks = checksOf(pr)
97  if (checks.some(c => c.outcome === 'failed')) return 'failing'
98  if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'changes'
99  if (pr.mergeable === 'CONFLICTING' || pr.mergeStateStatus === 'DIRTY') return 'conflict'
100  if (checks.some(c => c.outcome === 'running')) return 'running'
101  if (pr.reviewDecision === 'REVIEW_REQUIRED') return 'review'
102  const approved = pr.reviewDecision === 'APPROVED'
103  if (!approved && (pr.reviewRequests ?? []).length > 0) return 'review'
104  if (pr.mergeStateStatus === 'BEHIND') return 'behind'
105  if (pr.mergeStateStatus === 'BLOCKED') return 'blocked'
106  // No review decision and no clean merge state: nobody has said it can go in yet.
107  if (!approved && mergeableOf(pr) !== 'yes') return 'review'
108  return 'ready'
109}
110
111// The full PR record the views draw, from a link and GitHub's answer.
112export function prFromGh(ref: { repo: string; number: number; url: string }, gh: GhPr, ticket?: string): Pr {
113  const pr: Pr = {
114    ...ref,
115    state: classifyPr(gh),
116    checks: checksOf(gh),
117    reviews: reviewsOf(gh),
118    mergeable: mergeableOf(gh),
119  }
120  if (gh.title) pr.title = gh.title
121  if (gh.author?.login) pr.author = gh.author.login
122  if (gh.mergeStateStatus) pr.mergeState = gh.mergeStateStatus
123  if (ticket) pr.ticket = ticket
124  if (gh.createdAt) pr.createdAt = gh.createdAt
125  if (gh.mergedAt) pr.mergedAt = gh.mergedAt
126  if (gh.closedAt) pr.closedAt = gh.closedAt
127  return pr
128}
129
130const sameLogin = (a: string | undefined | null, b: string | undefined | null) => !!a && !!b && a.toLowerCase() === b.toLowerCase()
131
132// Who acts next. A ready PR is merged by you in your own repos and by your operator
133// everywhere else (handbook rule 3); fixes fall to whoever opened the PR.
134export function nextActor(pr: Pr, me: string | null): Actor {
135  switch (pr.state) {
136    case 'merged':
137    case 'closed':
138      return 'none'
139    case 'ready':
140      return sameLogin(pr.repo.split('/')[0], me) ? 'you' : 'operator'
141    case 'running':
142      return 'ci'
143    case 'review':
144      return 'reviewer'
145    case 'changes':
146    case 'failing':
147    case 'conflict':
148    case 'behind':
149    case 'draft':
150      return pr.author === undefined || sameLogin(pr.author, me) ? 'you' : 'author'
151    default:
152      return 'unknown'
153  }
154}
155
156// The next action in a few words: "You merge", "Reviewer acts", "You fix CI".
157export function nextText(pr: Pr, me: string | null): string {
158  const actor = nextActor(pr, me)
159  const who = actor === 'author' ? (pr.author ?? 'Author') : 'You'
160  switch (pr.state) {
161    case 'merged':
162      return 'Done'
163    case 'closed':
164      return 'No action'
165    case 'ready':
166      return actor === 'you' ? 'You merge' : 'Operator merges'
167    case 'running':
168      return 'Wait for CI'
169    case 'review':
170      return 'Reviewer acts'
171    case 'changes':
172      return `${who} address review`
173    case 'failing':
174      return `${who} fix CI`
175    case 'conflict':
176      return `${who} resolve conflicts`
177    case 'behind':
178      return `${who} update branch`
179    case 'draft':
180      return `${who} mark ready`
181    case 'blocked':
182      return 'Check branch rules'
183    default:
184      return 'Open the PR'
185  }
186}
187
188// "site#152" rather than "unsigned-gg/site#152": the org is the same everywhere here.
189export const prName = (pr: Pick<Pr, 'repo' | 'number'>) => `${pr.repo.split('/')[1] ?? pr.repo}#${pr.number}`
190export const prKey = (pr: Pick<Pr, 'repo' | 'number'>) => `${pr.repo}#${pr.number}`
191
192// CI in a few words. Honest when GitHub gave no checks at all.
193export function ciText(pr: Pr): string {
194  const checks = pr.checks
195  if (checks === undefined) return 'CI unknown'
196  if (checks.length === 0) return 'No checks'
197  const failed = checks.filter(c => c.outcome === 'failed').length
198  const running = checks.filter(c => c.outcome === 'running').length
199  if (failed > 0) return `CI failed ${failed}/${checks.length}`
200  if (running > 0) return `CI running ${checks.length - running}/${checks.length}`
201  return 'CI passed'
202}
203
204export function reviewText(pr: Pr): string {
205  const r = pr.reviews
206  if (r === undefined) return 'Reviews unknown'
207  if (r.changesBy.length > 0) return `Changes requested by ${r.changesBy.join(', ')}`
208  if (r.approvedBy.length > 0) return `Approved by ${r.approvedBy.join(', ')}`
209  if (r.requested.length > 0) return `Review requested from ${r.requested.join(', ')}`
210  return 'No reviews yet'
211}
212
213export function mergeText(pr: Pr): string {
214  if (pr.state === 'merged') return 'Merged'
215  if (pr.state === 'closed') return 'Closed'
216  switch (pr.mergeState) {
217    case 'CLEAN':
218    case 'HAS_HOOKS':
219      return 'GitHub allows the merge'
220    case 'UNSTABLE':
221      return 'GitHub allows the merge (non-required checks failing)'
222    case 'BLOCKED':
223      return 'Blocked by branch rules'
224    case 'DIRTY':
225      return 'Blocked by a merge conflict'
226    case 'BEHIND':
227      return 'Blocked until the branch is updated'
228    case 'DRAFT':
229      return 'Blocked: draft'
230    default:
231      return 'GitHub has not computed mergeability yet'
232  }
233}
234
235// The one-line PR summary, shortened to fit: full, then without CI, then the state alone.
236export function prSummary(pr: Pr, me: string | null, columns: number): string {
237  const look = PR_LOOK[pr.state]
238  const full = `${look.glyph} ${prName(pr)} ${look.label} · ${ciText(pr)} · ${nextText(pr, me)}`
239  if (full.length <= columns) return full
240  const mid = `${look.glyph} ${prName(pr)} ${look.label} · ${nextText(pr, me)}`
241  if (mid.length <= columns) return mid
242  return `${look.glyph} ${prName(pr)} ${look.label}`
243}
244
245// Cut text to n characters with an ellipsis; whole when it fits.
246export const fit = (text: string, n: number) => (n <= 0 ? '' : text.length <= n ? text : n === 1 ? '…' : `${text.slice(0, n - 1)}…`)
247
248// Fixed-width cells for a PR row so names, states and next actions line up down a list.
249export type RowCells = { glyph: string; name: string; state: string; ci: string; next: string; showCi: boolean; showNext: boolean }
250
251export function rowCells(prs: Pr[], me: string | null, columns: number): RowCells[] {
252  const nameW = Math.max(0, ...prs.map(p => prName(p).length))
253  const stateW = Math.max(0, ...prs.map(p => PR_LOOK[p.state].label.length))
254  const ciW = Math.max(0, ...prs.map(p => ciText(p).length))
255  const nextW = Math.max(0, ...prs.map(p => nextText(p, me).length))
256  // 2 for the glyph, then two spaces between each column.
257  const base = 2 + nameW + 2 + stateW
258  const showNext = base + 2 + nextW <= columns
259  const showCi = showNext && base + 2 + ciW + 2 + nextW <= columns
260  return prs.map(p => ({
261    glyph: PR_LOOK[p.state].glyph,
262    name: prName(p).padEnd(nameW),
263    state: PR_LOOK[p.state].label.padEnd(stateW),
264    ci: ciText(p).padEnd(ciW),
265    next: nextText(p, me),
266    showCi,
267    showNext,
268  }))
269}
270
271// The compact line for one ticket: id, status, its most pressing PR, and the title when room is left.
272export function ticketLine(i: Issue, me: string | null, columns: number): { head: string; pr?: Pr; prText?: string; title?: string } {
273  const head = `◆ ${i.id} ${i.status}`
274  const pr = mostPressing(i.prs ?? [])
275  if (pr === undefined) {
276    const room = columns - head.length - 3
277    return room >= 12 ? { head, title: fit(i.title, room) } : { head }
278  }
279  const prText = prSummary(pr, me, Math.max(0, columns - head.length - 3))
280  const room = columns - head.length - 3 - prText.length - 3
281  return room >= 16 ? { head, pr, prText, title: fit(i.title, room) } : { head, pr, prText }
282}
283
284// Most pressing first: something to fix, then something ready, then waiting, then done.
285const URGENCY: PrState[] = ['failing', 'changes', 'conflict', 'behind', 'ready', 'blocked', 'review', 'running', 'draft', 'unknown', 'merged', 'closed']
286export const byUrgency = (a: Pr, b: Pr) => URGENCY.indexOf(a.state) - URGENCY.indexOf(b.state)
287export const mostPressing = (prs: Pr[]) => [...prs].sort(byUrgency)[0]
288
289// The /prs groups, by who has to act. Empty groups are left out.
290export type Group = { title: string; prs: Pr[] }
291export function groupPrs(prs: Pr[], me: string | null): Group[] {
292  const groups: Group[] = [
293    { title: 'Needs you', prs: prs.filter(p => nextActor(p, me) === 'you') },
294    { title: 'Waiting on others', prs: prs.filter(p => ['operator', 'reviewer', 'ci', 'author'].includes(nextActor(p, me))) },
295    { title: 'Unclear', prs: prs.filter(p => nextActor(p, me) === 'unknown') },
296    { title: 'Done', prs: prs.filter(p => nextActor(p, me) === 'none') },
297  ]
298  return groups.map(g => ({ ...g, prs: [...g.prs].sort(byUrgency) })).filter(g => g.prs.length > 0)
299}
300
301// Every PR linked from the tickets, once, each tagged with its ticket.
302export function allPrs(issues: Issue[]): Pr[] {
303  const seen = new Set<string>()
304  return issues.flatMap(i =>
305    (i.prs ?? []).flatMap(pr => {
306      const k = prKey(pr)
307      if (seen.has(k)) return []
308      seen.add(k)
309      return [{ ...pr, ticket: pr.ticket ?? i.id }]
310    }),
311  )
312}
313
314// "3m ago", "2h ago", "4d ago".
315export function ago(then: number, now: number): string {
316  const s = Math.max(0, Math.round((now - then) / 1000))
317  if (s < 60) return 'just now'
318  if (s < 3600) return `${Math.round(s / 60)}m ago`
319  if (s < 86_400) return `${Math.round(s / 3600)}h ago`
320  return `${Math.round(s / 86_400)}d ago`
321}
322
323// --- Change detection --------------------------------------------------------------------
324
325// What the last check saw: each ticket's status, each PR's state and next actor.
326export type Snapshot = { tickets: Record<string, string>; prs?: Record<string, { state: PrState; actor: Actor }> }
327
328export function snapshotOf(issues: Issue[], me: string | null, before: Snapshot | null): Snapshot {
329  const prs: NonNullable<Snapshot['prs']> = {}
330  for (const pr of allPrs(issues)) {
331    // An unknown answer is a failed lookup, not a change: keep what was seen before.
332    const old = before?.prs?.[prKey(pr)]
333    prs[prKey(pr)] = pr.state === 'unknown' && old ? old : { state: pr.state, actor: nextActor(pr, me) }
334  }
335  return { tickets: Object.fromEntries(issues.map(i => [i.id, i.status])), prs }
336}
337
338// The changes between two checks worth telling the person about, most important first.
339// A first check (before null) reports nothing; PRs are compared only once a snapshot has them.
340export function changes(before: Snapshot | null, issues: Issue[], me: string | null, at: number): Update[] {
341  if (before === null) return []
342  const out: Update[] = []
343  for (const i of issues) {
344    const was = before.tickets[i.id]
345    if (was === undefined) out.push({ at, level: 'important', ticket: i.id, text: `New ticket ${i.id}: ${i.title}` })
346    else if (was !== i.status) out.push({ at, level: 'important', ticket: i.id, text: `${i.id} moved ${was} → ${i.status}` })
347  }
348  if (before.prs !== undefined) {
349    // A new ticket's own toast covers its PRs; only PRs on tickets seen before are compared.
350    for (const pr of allPrs(issues)) {
351      if (pr.ticket !== undefined && before.tickets[pr.ticket] === undefined) continue
352      out.push(...prChange(before.prs[prKey(pr)], pr, me, at))
353    }
354  }
355  const rank = { important: 0, routine: 1, quiet: 2 }
356  return out.sort((a, b) => rank[a.level] - rank[b.level])
357}
358
359function prChange(was: { state: PrState; actor: Actor } | undefined, pr: Pr, me: string | null, at: number): Update[] {
360  const name = prName(pr)
361  const base = { at, pr: prKey(pr), ...(pr.ticket ? { ticket: pr.ticket } : {}) }
362  const next = nextText(pr, me)
363  if (pr.state === 'unknown') return []
364  if (was === undefined) return [{ ...base, level: 'routine', text: `${pr.ticket ?? 'Ticket'} linked ${name} · ${PR_LOOK[pr.state].label} · ${next}` }]
365  if (was.state === pr.state) {
366    return was.actor === nextActor(pr, me) ? [] : [{ ...base, level: 'routine', text: `${name} · ${next}` }]
367  }
368  const text = (what: string) => `${name} ${what} · ${next}`
369  switch (pr.state) {
370    case 'merged':
371      return [{ ...base, level: 'important', text: `${name} merged` }]
372    case 'ready':
373      return [{ ...base, level: 'important', text: text(was.state === 'review' || was.state === 'blocked' ? 'approved, ready to merge' : 'is ready to merge') }]
374    case 'changes':
375      return [{ ...base, level: 'important', text: text('has changes requested') }]
376    case 'failing':
377      return [{ ...base, level: 'important', text: text('checks failing') }]
378    case 'conflict':
379      return [{ ...base, level: 'important', text: text('has a merge conflict') }]
380    case 'review':
381      return [{ ...base, level: was.state === 'draft' || was.state === 'failing' || was.state === 'running' ? 'routine' : 'quiet', text: text(was.state === 'draft' ? 'is ready for review' : 'checks passed, awaiting review') }]
382    case 'running':
383      // A re-run after a failure is worth a line; checks starting on every push are not.
384      return [{ ...base, level: was.state === 'failing' ? 'routine' : 'quiet', text: text('checks running') }]
385    case 'closed':
386      return [{ ...base, level: 'routine', text: `${name} closed without merge` }]
387    default:
388      return [{ ...base, level: 'routine', text: text(PR_LOOK[pr.state].label.toLowerCase()) }]
389  }
390}
391
392// What to toast for a batch of changes: each important or routine one, or one summary past three.
393export function toastsFor(updates: Update[]): { text: string; timeoutMs: number }[] {
394  const loud = updates.filter(u => u.level !== 'quiet')
395  if (loud.length > 3) {
396    const important = loud.filter(u => u.level === 'important').length
397    return [{ text: `${loud.length} ticket and PR updates${important > 0 ? `, ${important} important` : ''} · /prs or /tickets to see them`, timeoutMs: 8000 }]
398  }
399  return loud.map(u => ({ text: u.text, timeoutMs: u.level === 'important' ? 8000 : 4000 }))
400}
401
402// --- Stages: how far each ticket and PR has got --------------------------------------------
403
404// How each stage state reads. Like the PR states, every one has a glyph and a colour.
405export const STEP_LOOK: Record<StepState, { glyph: string; color: string; word: string }> = {
406  done: { glyph: '✓', color: 'green', word: 'done' },
407  now: { glyph: '●', color: 'cyan', word: 'now' },
408  waiting: { glyph: '◷', color: 'yellow', word: 'waiting' },
409  failed: { glyph: '✕', color: 'red', word: 'blocked' },
410  todo: { glyph: '○', color: 'gray', word: 'not yet' },
411  na: { glyph: '–', color: 'gray', word: 'not used' },
412}
413
414const TYPE_RANK: Record<string, number> = { triage: 0, backlog: 1, unstarted: 2, started: 3, completed: 4, canceled: 4, duplicate: 4 }
415
416// A ticket's stages from Linear: the states it went through, in workflow order, then Done.
417// Backlog and Todo show only when the ticket was in them; a started state shows once each.
418export function ticketSteps(i: Issue): Step[] {
419  const history = i.history ?? []
420  const current = TYPE_RANK[i.statusType] ?? 3
421  const first = (name: string) => history.find(h => h.name === name)?.at
422  const names: { name: string; type: string }[] = []
423  for (const h of [...history].sort((a, b) => (TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3))) {
424    if ((TYPE_RANK[h.type] ?? 3) >= 4) continue
425    if (!names.some(n => n.name === h.name)) names.push({ name: h.name, type: h.type })
426  }
427  if (current < 4 && !names.some(n => n.name === i.status)) names.push({ name: i.status, type: i.statusType })
428  names.sort((a, b) => (TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3))
429  // Within started states, the order they were first entered.
430  const steps: Step[] = names.map(n => {
431    const rank = TYPE_RANK[n.type] ?? 3
432    const isNow = n.name === i.status && current < 4
433    const before = rank < current || (rank === current && !isNow && (first(n.name) ?? '') < (first(i.status) ?? '~'))
434    const state: StepState = isNow ? 'now' : before || current >= 4 ? 'done' : 'todo'
435    const at = first(n.name)
436    return at ? { label: n.name, state, at } : { label: n.name, state }
437  })
438  const end = i.statusType === 'canceled' || i.statusType === 'duplicate' ? i.status : 'Done'
439  const endState: StepState = i.statusType === 'completed' ? 'done' : current >= 4 ? 'failed' : 'todo'
440  const endAt = current >= 4 ? first(i.status) : undefined
441  steps.push(endAt ? { label: end, state: endState, at: endAt } : { label: end, state: endState })
442  return steps
443}
444
445// A PR's stages: out of draft, CI, review, mergeable, merged. Each from what GitHub said.
446export function prSteps(pr: Pr): Step[] {
447  const merged = pr.state === 'merged'
448  const closed = pr.state === 'closed'
449  const checks = pr.checks
450  const ci: StepState = merged ? 'done'
451    : checks === undefined ? 'todo'
452    : checks.length === 0 ? 'na'
453    : checks.some(c => c.outcome === 'failed') ? 'failed'
454    : checks.some(c => c.outcome === 'running') ? 'now'
455    : 'done'
456  const r = pr.reviews
457  const review: StepState = merged ? 'done'
458    : r === undefined ? 'todo'
459    : r.changesBy.length > 0 ? 'failed'
460    : r.approvedBy.length > 0 || pr.state === 'ready' ? 'done'
461    : pr.state === 'draft' ? 'todo'
462    : 'waiting'
463  const merge: StepState = merged ? 'done'
464    : closed ? 'na'
465    : pr.state === 'conflict' ? 'failed'
466    : pr.state === 'behind' || pr.state === 'blocked' ? 'waiting'
467    : pr.mergeable === 'yes' && pr.state === 'ready' ? 'now'
468    : 'todo'
469  const open: StepState = pr.state === 'draft' ? 'now' : 'done'
470  const last: Step = merged ? (pr.mergedAt ? { label: 'Merged', state: 'done', at: pr.mergedAt } : { label: 'Merged', state: 'done' })
471    : closed ? (pr.closedAt ? { label: 'Closed', state: 'failed', at: pr.closedAt } : { label: 'Closed', state: 'failed' })
472    : { label: 'Merged', state: 'todo' }
473  return [
474    pr.createdAt ? { label: 'Open', state: open, at: pr.createdAt } : { label: 'Open', state: open },
475    { label: 'CI', state: ci },
476    { label: 'Review', state: review },
477    { label: 'Merge', state: merge },
478    last,
479  ]
480}
481
482// The stage that matters now: the first not done, or the last when all are.
483export function currentStep(steps: Step[]): Step | undefined {
484  return steps.find(s => s.state !== 'done' && s.state !== 'na') ?? steps[steps.length - 1]
485}
486
487// Stages as text, fitted: "✓ Backlog → ✓ Todo → ● In Review → ○ Done", or "✓✓●○ In Review".
488export function stepsLine(steps: Step[], columns: number): string {
489  const full = steps.map(s => `${STEP_LOOK[s.state].glyph} ${s.label}`).join(' → ')
490  if (full.length <= columns) return full
491  const mid = steps.map(s => `${STEP_LOOK[s.state].glyph} ${s.label}`).join(' ')
492  if (mid.length <= columns) return mid
493  const cur = currentStep(steps)
494  return fit(`${steps.map(s => STEP_LOOK[s.state].glyph).join('')}${cur ? ` ${cur.label}` : ''}`, columns)
495}
496
497// How long a ticket has been in its current state: "4d", from its history.
498export function inStateFor(i: Issue, now: number): string | undefined {
499  const at = [...(i.history ?? [])].reverse().find(h => h.name === i.status)?.at
500  if (at === undefined) return undefined
501  return ago(Date.parse(at), now).replace(' ago', '').replace('just now', '<1m')
502}
503
504// When a PR last moved: "merged 3d ago", "closed 1h ago", "open 4d".
505export function prWhen(pr: Pr, now: number): string | undefined {
506  if (pr.mergedAt) return `merged ${ago(Date.parse(pr.mergedAt), now)}`
507  if (pr.closedAt) return `closed ${ago(Date.parse(pr.closedAt), now)}`
508  if (pr.createdAt) return `open ${ago(Date.parse(pr.createdAt), now).replace(' ago', '')}`
509  return undefined
510}
511
512// Words to lines of at most `columns`, at most `max` lines, the last cut with an ellipsis.
513export function wrapLines(text: string, columns: number, max: number): string[] {
514  const out: string[] = []
515  let line = ''
516  for (const word of text.split(/\s+/).filter(Boolean)) {
517    if (line === '') line = word
518    else if (line.length + 1 + word.length <= columns) line += ` ${word}`
519    else {
520      out.push(line)
521      line = word
522    }
523  }
524  if (line !== '') out.push(line)
525  const lines = out.map(l => fit(l, columns))
526  if (lines.length <= max) return lines
527  const kept = lines.slice(0, max)
528  kept[max - 1] = fit(`${kept[max - 1]} ${lines.slice(max).join(' ')}`, columns)
529  return kept
530}
531
532// --- Actions: where to go to move each ticket and PR forward --------------------------------
533
534export const isDone = (pr: Pr) => pr.state === 'merged' || pr.state === 'closed'
535
536// The page where the next action happens, not just the PR: the failed check's log, GitHub's
537// conflict editor, the changed files a reviewer commented on, or the PR page for everything else.
538export function actionUrl(pr: Pr): string {
539  const base = pr.url.replace(/\/+$/, '')
540  switch (pr.state) {
541    case 'failing':
542      return pr.checks?.find(c => c.outcome === 'failed' && c.url)?.url ?? `${base}/checks`
543    case 'running':
544      return `${base}/checks`
545    case 'conflict':
546      return `${base}/conflicts`
547    case 'changes':
548      return `${base}/files`
549    default:
550      return base
551  }
552}
553
554// One next action: who does it, what, and the page to do it on.
555export type Action = { who: Actor; text: string; url: string; color: string; ticket?: string; pr?: string }
556
557export function prAction(pr: Pr, me: string | null): Action {
558  const a: Action = { who: nextActor(pr, me), text: nextText(pr, me), url: actionUrl(pr), color: PR_LOOK[pr.state].color, pr: prKey(pr) }
559  if (pr.ticket) a.ticket = pr.ticket
560  return a
561}
562
563// A ticket whose linked PRs are all finished, at least one merged, is yours to close in Linear.
564export function ticketAction(i: Issue): Action | undefined {
565  const prs = i.prs ?? []
566  if (prs.length === 0 || i.statusType === 'completed' || i.statusType === 'canceled') return undefined
567  if (!prs.every(isDone) || !prs.some(p => p.state === 'merged')) return undefined
568  return { who: 'you', text: `You move ${i.id} to Done`, url: i.url, color: 'cyan', ticket: i.id }
569}
570
571// Every open action, yours first (PR fixes by urgency, then tickets to close), then the ones
572// others hold. The first is what `o` opens.
573export function allActions(open: Issue[], me: string | null): Action[] {
574  const active = allPrs(open).filter(p => !isDone(p)).sort(byUrgency)
575  const mine = active.filter(p => nextActor(p, me) === 'you').map(p => prAction(p, me))
576  const close = open.flatMap(i => ticketAction(i) ?? [])
577  const others = active.filter(p => nextActor(p, me) !== 'you').map(p => prAction(p, me))
578  return [...mine, ...close, ...others]
579}
580
581export const actionsForYou = (open: Issue[], me: string | null) => allActions(open, me).filter(a => a.who === 'you')
582
hooks/view.tsx 562 lines
1// Drawing: one card design for the band, the Tickets pane and the PRs pane.
2// A card is a rounded border, a bold header with a dim count on the right, rows of
3// bold name + dim sub-line with the status word right-aligned, and a dim footer.
4// Every element comes from the surface's own table, so nothing here assumes a terminal.
5import type { EngineInterface } from 'claude-code'
6
7import type { Health, Issue, Mode, Pr, Step, Update } from '../types'
8import { PR_LOOK, KEY_ORDER, STEP_LOOK, actionsForYou, ago, byUrgency, ciText, currentStep, fit, groupPrs, inStateFor, isDone, mergeText, nextActor, prAction, prKey, prName, prSteps, prWhen, reviewText, ticketAction, ticketSteps, wrapLines } from './model'
9import type { Action } from './model'
10
11export type Ui = ReturnType<EngineInterface['ui']['resolve']>
12
13// Opens a page in the person's browser; the hooks module owns how.
14export type Open = (url: string, label: string) => unknown
15
16// The short, upper-case status word a row ends with.
17export const PR_WORD: Record<Pr['state'], string> = {
18  merged: 'MERGED', ready: 'READY', blocked: 'BLOCKED', running: 'RUNNING', review: 'REVIEW', changes: 'CHANGES',
19  failing: 'FAILING', conflict: 'CONFLICT', behind: 'BEHIND', draft: 'DRAFT', closed: 'CLOSED', unknown: 'UNKNOWN',
20}
21
22// The sub-line under a PR's name: what the state means for you, in plain words.
23export function prSubline(pr: Pr, me: string | null): string {
24  switch (pr.state) {
25    case 'ready':
26      return nextActor(pr, me) === 'you' ? 'You can merge' : 'Your operator can merge'
27    case 'review':
28      return 'Waiting for reviewer'
29    case 'running':
30      return 'CI checks running'
31    case 'failing':
32      return 'CI checks failed'
33    case 'changes':
34      return 'Reviewer asked for changes'
35    case 'conflict':
36      return 'Merge conflict with base'
37    case 'behind':
38      return 'Branch behind base'
39    case 'blocked':
40      return 'Branch rules block the merge'
41    case 'draft':
42      return 'Draft, not ready for review'
43    case 'merged':
44      return 'Merged'
45    case 'closed':
46      return 'Closed without merge'
47    default:
48      return 'GitHub did not answer'
49  }
50}
51
52// A PR's sub-line with its CI: the plain-words state, plus the CI result when that adds something.
53// "CI checks failed · CI failed 1/1" says it twice, so a CI state carries its own count instead.
54export function prSubWithCi(pr: Pr, me: string | null): string {
55  const checks = pr.checks ?? []
56  const failed = checks.filter(c => c.outcome === 'failed').length
57  const running = checks.filter(c => c.outcome === 'running').length
58  if (pr.state === 'failing') return `${failed} of ${checks.length} CI check${checks.length === 1 ? '' : 's'} failed`
59  if (pr.state === 'running') return `CI running, ${checks.length - running} of ${checks.length} done`
60  return `${prSubline(pr, me)} · ${ciText(pr)}`
61}
62
63// A Linear status's colour by its type: started cyan, triage yellow, done green, the rest gray.
64export const ticketColor = (i: Issue) =>
65  i.statusType === 'started' ? 'cyan' : i.statusType === 'triage' ? 'yellow' : i.statusType === 'completed' ? 'green' : 'gray'
66
67// The icon on a "Next action" line, by who acts.
68const ACTOR_ICON = { you: '→', author: '→', operator: '⚑', reviewer: '◷', ci: '◌', none: '✓', unknown: '?' } as const
69
70const rule = ({ Text }: Ui, width: number) => <Text dimColor>{'─'.repeat(Math.max(0, width))}</Text>
71
72// A card's header: bold title left, dim count right.
73function header({ Box, Text }: Ui, title: string, count: string) {
74  return (
75    <Box flexDirection="row" justifyContent="space-between">
76      <Text bold>{title}</Text>
77      <Text dimColor>{count}</Text>
78    </Box>
79  )
80}
81
82// A two-line row: glyph and name (a link to its page) with a dim sub-line, the status word
83// right-aligned in colour. With stages, the sub-line starts with one glyph per stage.
84// `strong` marks a row that needs you: its sub-line is drawn in the row's colour, not dim.
85function cardRow({ Box, Text, Link }: Ui, key: string, glyph: string, color: string, name: string, url: string, sub: string, word: string, inner: number, steps?: Step[], strong = false) {
86  // The left column shares the row with the status word, so both its lines leave room for it.
87  const room = Math.max(4, inner - word.length - 4)
88  const marks = steps ?? []
89  const subRoom = Math.max(4, room - (marks.length > 0 ? marks.length + 1 : 0))
90  return (
91    <Box key={key} flexDirection="row" justifyContent="space-between">
92      <Box flexDirection="column">
93        <Box flexDirection="row">
94          <Text color={color}>{`${glyph} `}</Text>
95          <Link href={url}>{fit(name, room)}</Link>
96        </Box>
97        <Text>
98          <Text>{'  '}</Text>
99          {marks.map((st, n) => <Text key={`m${n}`} color={STEP_LOOK[st.state].color}>{STEP_LOOK[st.state].glyph}</Text>)}
100          <Text color={strong ? color : undefined} dimColor={!strong}>{`${marks.length > 0 ? ' ' : ''}${fit(sub, subRoom)}`}</Text>
101        </Text>
102      </Box>
103      <Text color={color} bold>{word}</Text>
104    </Box>
105  )
106}
107
108// A finished PR in one dim line: "✓ unsigned-onboard#7 · merged 3d ago". It stays in view while
109// its ticket is open, without taking a card's room.
110function doneLine({ Box, Text, Link }: Ui, pr: Pr, inner: number, now: number, indent = 0) {
111  const look = PR_LOOK[pr.state]
112  const when = prWhen(pr, now) ?? (pr.state === 'merged' ? 'merged' : 'closed')
113  return (
114    <Box key={`done:${prKey(pr)}`} flexDirection="row" marginLeft={indent}>
115      <Text color={look.color} dimColor>{`${look.glyph} `}</Text>
116      <Link href={pr.url}>{fit(prName(pr), Math.max(4, inner - indent - when.length - 5))}</Link>
117      <Text dimColor>{` · ${when}`}</Text>
118    </Box>
119  )
120}
121
122const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
123
124// A card's border: the colour of what needs you, or dim when nothing does. This is what makes a
125// card that needs you stand out from the ones that only wait.
126const edge = (color: string | undefined) => (color !== undefined ? { borderColor: color } : { borderDimColor: true })
127
128// The PR Watch card: active PRs most pressing first, finished ones folded into one line.
129function prCard(ui: Ui, prs: Pr[], me: string | null, width: number, max: number, now: number, footer: ReturnType<typeof footerRow>) {
130  const { Box, Text } = ui
131  const inner = width - 4
132  const active = prs.filter(p => !isDone(p)).sort(byUrgency)
133  const done = prs.filter(isDone)
134  const shown = active.slice(0, max)
135  const mine = active.find(p => nextActor(p, me) === 'you')
136  return (
137    <Box key="lw-prcard" flexDirection="column" borderStyle="round" {...edge(mine ? PR_LOOK[mine.state].color : undefined)} paddingX={1} width={width}>
138      {header(ui, '⑂ PR Watch', `${active.length} open${done.length > 0 ? ` · ${done.length} done` : ''}`)}
139      {rule(ui, inner)}
140      {prs.length === 0 && <Text dimColor>No PRs linked to your tickets</Text>}
141      {shown.map(pr => cardRow(ui, `lw-pr:${prKey(pr)}`, PR_LOOK[pr.state].glyph, PR_LOOK[pr.state].color, prName(pr), prAction(pr, me).url, prSubline(pr, me), PR_WORD[pr.state], inner, prSteps(pr), nextActor(pr, me) === 'you'))}
142      {active.length > shown.length && <Text dimColor>{`  +${active.length - shown.length} more`}</Text>}
143      {done.length === 1 && doneLine(ui, done[0] as Pr, inner, now)}
144      {done.length > 1 && <Text dimColor>{`✓ ${done.length} merged or closed`}</Text>}
145      {rule(ui, inner)}
146      {footer}
147    </Box>
148  )
149}
150
151// The sub-line for a ticket in the band: its next step when it has one, else its title.
152const ticketSub = (i: Issue) => (ticketAction(i) !== undefined ? `All PRs merged → move to Done` : i.title)
153
154// The Linear Watch card: up to `max` tickets, most pressing first.
155function ticketCard(ui: Ui, open: Issue[], width: number, max: number, footer: ReturnType<typeof footerRow>) {
156  const { Box, Text } = ui
157  const inner = width - 4
158  const shown = open.slice(0, max)
159  const closing = open.some(i => ticketAction(i) !== undefined)
160  return (
161    <Box key="lw-tcard" flexDirection="column" borderStyle="round" {...edge(closing ? 'cyan' : undefined)} paddingX={1} width={width}>
162      {header(ui, '◆ Linear Watch', plural(open.length, 'open ticket'))}
163      {rule(ui, inner)}
164      {open.length === 0 && <Text dimColor>No open tickets assigned to you</Text>}
165      {shown.map(i => cardRow(ui, `lw-t:${i.id}`, '◆', ticketColor(i), i.id, i.url, ticketSub(i), i.status.toUpperCase(), inner, ticketSteps(i), ticketAction(i) !== undefined))}
166      {open.length > shown.length && <Text dimColor>{`  +${open.length - shown.length} more`}</Text>}
167      {rule(ui, inner)}
168      {footer}
169    </Box>
170  )
171}
172
173// A card footer: a hint on the left; on the right the open-next button, warnings and the fold.
174// `next` is the action `o` opens: the first that needs you, when there is one.
175function footerRow({ Box, Text, Button }: Ui, hint: string, unseen: number, health: Health, next?: Action, open?: Open, toggle?: () => unknown) {
176  return (
177    <Box flexDirection="row" justifyContent="space-between">
178      {next !== undefined && open !== undefined ? (
179        <Button key="lw-band-open" plain hotkey="o" label={fit(next.text, 28)} onPress={() => open(next.url, next.text)} />
180      ) : (
181        <Text dimColor>{hint}</Text>
182      )}
183      <Box flexDirection="row">
184        {unseen > 0 && <Text color="yellow">{`• ${unseen} new  `}</Text>}
185        {health.failures > 0 && <Text color="yellow">{'⚠ stale  '}</Text>}
186        {toggle !== undefined && <Button key="lw-band" plain dimColor hotkey="e" label="⌄" onPress={toggle} />}
187      </Box>
188    </Box>
189  )
190}
191
192// The band above the prompt.
193// expanded (the default): the two cards, side by side from 100 columns, one combined card below that.
194// compact: one line for the most pressing ticket and PR, for when the cards take too much room.
195export function band(ui: Ui, open: Issue[], prs: Pr[], me: string | null, columns: number, mode: Mode, unseen: number, health: Health, now: number, toggle: () => unknown, openUrl: Open) {
196  const { Box, Text, Button, Link } = ui
197  const next = actionsForYou(open, me)[0]
198  if (mode === 'compact') {
199    const top = prs.filter(p => !isDone(p)).sort(byUrgency)[0]
200    const [first] = open
201    return (
202      <Box flexDirection="row">
203        {first === undefined ? <Text dimColor>◆ Linear: no open tickets</Text> : (
204          <Box flexDirection="row">
205            <Text color={ticketColor(first)}>◆ </Text>
206            <Link href={first.url}>{first.id}</Link>
207            <Text dimColor>{` ${first.status}`}</Text>
208          </Box>
209        )}
210        {top !== undefined && (
211          <Box flexDirection="row">
212            <Text dimColor> · </Text>
213            <Text color={PR_LOOK[top.state].color}>{`${PR_LOOK[top.state].glyph} `}</Text>
214            <Link href={prAction(top, me).url}>{prName(top)}</Link>
215            <Text color={PR_LOOK[top.state].color}>{` ${PR_WORD[top.state]}`}</Text>
216          </Box>
217        )}
218        {next !== undefined && columns >= 90 && <Text color={next.color} bold>{`  → ${fit(next.text, 30)}`}</Text>}
219        {open.length > 1 && <Text dimColor>{`  +${open.length - 1}`}</Text>}
220        {unseen > 0 && <Text color="yellow">{`  • ${unseen} new`}</Text>}
221        {health.failures > 0 && <Text color="yellow">  ⚠ stale</Text>}
222        <Text>  </Text>
223        <Button key="lw-band" plain dimColor hotkey="e" label="⌃" onPress={toggle} />
224      </Box>
225    )
226  }
227  // Side by side needs room for two cards of 48; capped so very wide terminals do not stretch them thin.
228  if (columns >= 100) {
229    const width = Math.min(72, Math.floor((columns - 1) / 2))
230    return (
231      <Box flexDirection="row" gap={1}>
232        {ticketCard(ui, open, width, 3, footerRow(ui, '/tickets for details', 0, health))}
233        {prCard(ui, prs, me, width, 3, now, footerRow(ui, '/prs for details', unseen, health, next, openUrl, toggle))}
234      </Box>
235    )
236  }
237  // Narrow: one card, tickets then PRs, so the band stays one block.
238  const width = Math.max(30, Math.min(columns, 72))
239  const inner = width - 4
240  const active = prs.filter(p => !isDone(p)).sort(byUrgency)
241  const done = prs.filter(isDone)
242  return (
243    <Box flexDirection="column" borderStyle="round" {...edge(next?.color)} paddingX={1} width={width}>
244      {header(ui, '◆ Linear · ⑂ PRs', `${open.length} · ${active.length}`)}
245      {rule(ui, inner)}
246      {open.slice(0, 2).map(i => cardRow(ui, `lw-t:${i.id}`, '◆', ticketColor(i), i.id, i.url, ticketSub(i), i.status.toUpperCase(), inner, ticketSteps(i), ticketAction(i) !== undefined))}
247      {active.slice(0, 3).map(pr => cardRow(ui, `lw-pr:${prKey(pr)}`, PR_LOOK[pr.state].glyph, PR_LOOK[pr.state].color, prName(pr), prAction(pr, me).url, prSubline(pr, me), PR_WORD[pr.state], inner, prSteps(pr), nextActor(pr, me) === 'you'))}
248      {done.length === 1 && doneLine(ui, done[0] as Pr, inner, now)}
249      {done.length > 1 && <Text dimColor>{`✓ ${done.length} merged or closed`}</Text>}
250      {rule(ui, inner)}
251      {footerRow(ui, '/prs · /tickets', unseen, health, next, openUrl, toggle)}
252    </Box>
253  )
254}
255
256// How many rows a drawn tree takes, roughly: a column stacks, a row takes its tallest child,
257// a border adds two, a margin its size, hidden boxes none. Used to share the band with other mods.
258export function rowsOf(node: unknown): number {
259  if (node === null || node === undefined || node === false || node === true) return 0
260  if (typeof node === 'string' || typeof node === 'number') return 1
261  if (Array.isArray(node)) return node.reduce((n: number, c) => n + rowsOf(c), 0)
262  if (typeof node !== 'object') return 0
263  const el = node as { type?: string; props?: Record<string, unknown>; children?: unknown[] }
264  const p = el.props ?? {}
265  if (p.display === 'none') return 0
266  if (el.type !== 'Box') return 1
267  const kids = (el.children ?? []).flat()
268  const inner = p.flexDirection === 'row' ? Math.max(0, ...kids.map(rowsOf)) : kids.reduce((n: number, c) => n + rowsOf(c), 0)
269  const num = (v: unknown) => (typeof v === 'number' ? v : 0)
270  return inner + (p.borderStyle ? 2 : 0) + num(p.marginTop) + num(p.marginBottom) + 2 * num(p.marginY) + 2 * num(p.paddingY)
271}
272
273// Rows the expanded band takes: two cards side by side (the taller counts) or one combined card.
274// Active PRs take two rows each; finished ones fold into one row.
275export function cardRows(open: Issue[], prs: Pr[], columns: number): number {
276  const active = prs.filter(p => !isDone(p)).length
277  const folded = prs.some(isDone) ? 1 : 0
278  const card = (n: number, total: number, extra: number) => 6 + 2 * n + (total > n ? 1 : 0) + extra
279  if (columns >= 100) return Math.max(card(Math.min(3, open.length) || 1, open.length, 0), card(Math.min(3, active), active, folded) || 7)
280  return 6 + 2 * (Math.min(2, open.length) + Math.min(3, active)) + folded
281}
282
283// --- Panes -----------------------------------------------------------------------------------
284
285// How wide a pane's cards are: the pane's own body, never the terminal's, up to a readable 96.
286// Below 26 columns a border costs too much, so cards go borderless.
287export const paneLayout = (bodyColumns: number) => {
288  const width = Math.max(12, Math.min(bodyColumns, 96))
289  const bordered = width >= 26
290  return { width, bordered, inner: bordered ? width - 4 : width }
291}
292
293// A pane's title row: bold title left, count and age right; stacked when narrow.
294export function paneHeader(ui: Ui, title: string, count: string, health: Health, checking: boolean, now: number, columns: number) {
295  const { Box, Text } = ui
296  const when = checking ? 'checking…' : health.lastOk !== undefined ? ago(health.lastOk, now) : 'not checked yet'
297  const right = `${count} · ${when}`
298  const oneRow = title.length + right.length + 2 <= columns
299  return (
300    <Box flexDirection="column" marginBottom={1}>
301      {oneRow ? (
302        <Box flexDirection="row" justifyContent="space-between">
303          <Text color="cyan" bold>{title}</Text>
304          <Text dimColor>{right}</Text>
305        </Box>
306      ) : (
307        <Box flexDirection="column">
308          <Text color="cyan" bold>{fit(title, columns)}</Text>
309          <Text dimColor>{fit(right, columns)}</Text>
310        </Box>
311      )}
312      {health.failures > 0 && <Text color="yellow">{fit('⚠ Last check failed. This is older data.', columns)}</Text>}
313    </Box>
314  )
315}
316
317// A line of stages, each glyph in its colour: full labels with arrows when they fit, then without
318// arrows, then glyphs only with the current stage named. `suffix` (dim) follows when there is room.
319function stagesView({ Text }: Ui, key: string, steps: Step[], columns: number, suffix?: string) {
320  const full = steps.map(s => `${STEP_LOOK[s.state].glyph} ${s.label}`).join(' → ')
321  const mid = steps.map(s => `${STEP_LOOK[s.state].glyph} ${s.label}`).join(' ')
322  const form = full.length <= columns ? 'full' : mid.length <= columns ? 'mid' : 'glyphs'
323  const used = form === 'full' ? full.length : form === 'mid' ? mid.length : steps.length + 1 + (currentStep(steps)?.label.length ?? 0)
324  const tail = suffix !== undefined && used + 3 + suffix.length <= columns ? ` · ${suffix}` : ''
325  if (form === 'glyphs') {
326    const cur = currentStep(steps)
327    return (
328      <Text key={key}>
329        {steps.map((st, n) => <Text key={`g${n}`} color={STEP_LOOK[st.state].color}>{STEP_LOOK[st.state].glyph}</Text>)}
330        {cur !== undefined && <Text color={STEP_LOOK[cur.state].color}>{` ${fit(cur.label, Math.max(1, columns - steps.length - 1))}`}</Text>}
331        <Text dimColor>{tail}</Text>
332      </Text>
333    )
334  }
335  return (
336    <Text key={key}>
337      {steps.map((st, n) => (
338        <Text key={`s${n}`}>
339          {n > 0 && <Text dimColor>{form === 'full' ? ' → ' : ' '}</Text>}
340          <Text color={STEP_LOOK[st.state].color}>{STEP_LOOK[st.state].glyph}</Text>
341          <Text bold={st.state === 'now'} dimColor={st.state === 'todo' || st.state === 'na'}>{` ${st.label}`}</Text>
342        </Text>
343      ))}
344      <Text dimColor>{tail}</Text>
345    </Text>
346  )
347}
348
349// A card's top row: glyph and name (a link to its page) left, the status word right. When the
350// name and the word cannot share a row, the word goes under the name, so neither is cut.
351function titleRow({ Box, Text, Link }: Ui, glyph: string, color: string, name: string, url: string, word: string, inner: number) {
352  if (2 + name.length + 2 + word.length <= inner) {
353    return (
354      <Box flexDirection="row" justifyContent="space-between">
355        <Box flexDirection="row">
356          <Text color={color}>{`${glyph} `}</Text>
357          <Link href={url}>{name}</Link>
358        </Box>
359        <Text color={color} bold>{word}</Text>
360      </Box>
361    )
362  }
363  return (
364    <Box flexDirection="column">
365      <Box flexDirection="row">
366        <Text color={color}>{`${glyph} `}</Text>
367        <Link href={url}>{fit(name, inner - 2)}</Link>
368      </Box>
369      <Text color={color} bold>{`  ${fit(word, inner - 2)}`}</Text>
370    </Box>
371  )
372}
373
374// The next-action line: "→ Next: You fix CI", the action a link to the page where it happens, and
375// an open button on the right that opens it in the browser even where links are not clickable.
376// Bold in its colour when it is yours; dim when someone else holds it.
377function actionRow({ Box, Text, Link, Button }: Ui, key: string, a: Action, inner: number, indent: number, open: Open) {
378  const mine = a.who === 'you'
379  const icon = ACTOR_ICON[a.who]
380  const room = Math.max(6, inner - indent - 8 - 9)
381  return (
382    <Box key={key} flexDirection="row" justifyContent="space-between" marginLeft={indent}>
383      <Box flexDirection="row">
384        <Text color={a.color} bold={mine} dimColor={!mine}>{`${icon} Next: `}</Text>
385        <Link href={a.url}>{fit(a.text, room)}</Link>
386      </Box>
387      <Button key={`lw-open:${key}`} plain dimColor={!mine} label="↗ open" onPress={() => open(a.url, a.text)} />
388    </Box>
389  )
390}
391
392const cardBox = (ui: Ui, key: string, layout: ReturnType<typeof paneLayout>, color: string | undefined, children: unknown) => {
393  const { Box } = ui
394  return layout.bordered ? (
395    <Box key={key} flexDirection="column" borderStyle="round" {...edge(color)} paddingX={1} width={layout.width} marginBottom={1}>
396      {children as never}
397    </Box>
398  ) : (
399    <Box key={key} flexDirection="column" width={layout.width} marginBottom={1}>
400      {children as never}
401    </Box>
402  )
403}
404
405// The details an expanded PR shows: failed checks as links, merge state, ticket, when, link.
406function prDetails(ui: Ui, pr: Pr, inner: number, now: number, showTicket: boolean) {
407  const { Box, Text, Link } = ui
408  const failed = (pr.checks ?? []).filter(c => c.outcome === 'failed')
409  const label = inner >= 30 ? 9 : 0
410  const line = (key: string, name: string, value: string) => (
411    <Box key={key} flexDirection={label > 0 ? 'row' : 'column'}>
412      <Text dimColor>{label > 0 ? name.padEnd(label) : name}</Text>
413      <Text dimColor>{fit(value, inner - label)}</Text>
414    </Box>
415  )
416  const when = prWhen(pr, now)
417  return (
418    <Box flexDirection="column" marginTop={1}>
419      {failed.slice(0, 4).map(c => (
420        <Box key={`fail:${prKey(pr)}:${c.name}`} flexDirection="row">
421          <Text color="red">{label > 0 ? 'Failed'.padEnd(label) : '✕ '}</Text>
422          {c.url ? <Link href={c.url}>{fit(c.name, inner - (label || 2))}</Link> : <Text>{fit(c.name, inner - (label || 2))}</Text>}
423        </Box>
424      ))}
425      {failed.length > 4 && line('more', '', `+${failed.length - 4} more failing`)}
426      {line('merge', 'Merge', mergeText(pr))}
427      {showTicket && pr.ticket !== undefined && line('ticket', 'Ticket', pr.ticket)}
428      {when !== undefined && line('when', 'When', when)}
429      {pr.title !== undefined && line('title', 'Title', pr.title)}
430    </Box>
431  )
432}
433
434// One active PR as a card in the PRs pane. Its border takes the PR's colour when it needs you.
435function prBlock(ui: Ui, pr: Pr, me: string | null, layout: ReturnType<typeof paneLayout>, mode: Mode, now: number, open: Open) {
436  const { Text } = ui
437  const look = PR_LOOK[pr.state]
438  const { inner } = layout
439  const a = prAction(pr, me)
440  return cardBox(ui, `pr:${prKey(pr)}`, layout, a.who === 'you' ? look.color : undefined, [
441    titleRow(ui, look.glyph, look.color, prName(pr), pr.url, PR_WORD[pr.state], inner),
442    ...wrapLines(`${ciText(pr)} · ${reviewText(pr)}`, inner - 2, 2).map((l, n) => <Text key={`cr${n}`} dimColor>{`  ${l}`}</Text>),
443    <Text key="sp">{'  '}{stagesView(ui, 'st', prSteps(pr), inner - 2, prWhen(pr, now))}</Text>,
444    actionRow(ui, `act:${prKey(pr)}`, a, inner, 2, open),
445    mode === 'expanded' ? prDetails(ui, pr, inner - 2, now, true) : null,
446  ])
447}
448
449// The PRs pane body: groups by who acts. "Needs you" is labelled in yellow; finished PRs are one
450// dim line each under "Done", not cards.
451export function prGroups(ui: Ui, prs: Pr[], me: string | null, columns: number, mode: Mode, now: number, open: Open) {
452  const { Box, Text } = ui
453  const layout = paneLayout(columns)
454  return groupPrs(prs, me).map(g => {
455    const isMine = g.title === 'Needs you'
456    const isDoneGroup = g.title === 'Done'
457    return (
458      <Box key={`g:${g.title}`} flexDirection="column" marginBottom={isDoneGroup ? 1 : 0}>
459        <Text color={isMine ? 'yellow' : undefined} bold={isMine} dimColor={!isMine}>{fit(`${g.title.toUpperCase()} · ${g.prs.length}`, layout.width)}</Text>
460        {isDoneGroup ? g.prs.map(pr => doneLine(ui, pr, layout.width, now, 2)) : g.prs.map(pr => prBlock(ui, pr, me, layout, mode, now, open))}
461      </Box>
462    )
463  })
464}
465
466// One ticket as a card: id (a link to Linear) and status, title, its stages, then each active PR
467// with its stages and next action, finished PRs as one dim line each, and the ticket's own next
468// step when all its PRs are merged. The border takes a colour when anything in it needs you.
469export function ticketBlock(ui: Ui, i: Issue, me: string | null, columns: number, mode: Mode, now: number, open: Open) {
470  const { Box, Text } = ui
471  const layout = paneLayout(columns)
472  const { inner } = layout
473  const meta = [i.priority, i.project].filter(Boolean).join(' · ')
474  const prs = [...(i.prs ?? [])].sort(byUrgency)
475  const active = prs.filter(p => !isDone(p))
476  const done = prs.filter(isDone)
477  const since = inStateFor(i, now)
478  const close = ticketAction(i)
479  const mine = active.find(p => nextActor(p, me) === 'you')
480  const color = close !== undefined ? close.color : mine !== undefined ? PR_LOOK[mine.state].color : undefined
481  return cardBox(ui, `t:${i.id}`, layout, color, [
482    titleRow(ui, '◆', ticketColor(i), i.id, i.url, i.status.toUpperCase(), inner),
483    ...wrapLines(i.title, inner, mode === 'expanded' ? 3 : 2).map((l, n) => <Text key={`tl${n}`}>{l}</Text>),
484    mode === 'expanded' && meta !== '' ? <Text key="meta" dimColor>{fit(meta, inner)}</Text> : null,
485    stagesView(ui, 'ts', ticketSteps(i), inner, since !== undefined ? `for ${since}` : undefined),
486    close !== undefined ? actionRow(ui, `close:${i.id}`, close, inner, 0, open) : null,
487    <Text key="rule" dimColor>{'─'.repeat(Math.max(0, inner))}</Text>,
488    prs.length === 0 ? <Text key="nopr" dimColor>No linked PRs</Text> : null,
489    ...active.map((pr, n) => (
490      <Box key={`tp:${prKey(pr)}`} flexDirection="column" marginTop={n > 0 ? 1 : 0}>
491        {titleRow(ui, PR_LOOK[pr.state].glyph, PR_LOOK[pr.state].color, prName(pr), pr.url, PR_WORD[pr.state], inner)}
492        {wrapLines(prSubWithCi(pr, me), inner - 2, 2).map((l, k) => <Text key={`ps${k}`} dimColor>{`  ${l}`}</Text>)}
493        <Text>{'  '}{stagesView(ui, 'pst', prSteps(pr), inner - 2, prWhen(pr, now))}</Text>
494        {actionRow(ui, `act:${prKey(pr)}`, prAction(pr, me), inner, 2, open)}
495        {mode === 'expanded' && prDetails(ui, pr, inner - 2, now, false)}
496      </Box>
497    )),
498    done.length > 0 ? (
499      <Box key="done" flexDirection="column" marginTop={active.length > 0 ? 1 : 0}>
500        {done.map(pr => doneLine(ui, pr, inner, now))}
501      </Box>
502    ) : null,
503  ])
504}
505
506// The pane's footer: real actions only. `o` opens the first action that needs you (or the most
507// pressing one) in the browser. Whole buttons wrap to the next row in a narrow pane.
508export function toolbar({ Box, Button }: Ui, mode: Mode, showKey: boolean, next: Action | undefined, on: { toggle: () => unknown; refresh: () => unknown; key: () => unknown; close: () => unknown; open: Open }) {
509  return (
510    <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
511      {next !== undefined && <Button key="lw-open-next" plain hotkey="o" label="open next" onPress={() => on.open(next.url, next.text)} />}
512      <Button key="lw-toggle" plain hotkey="e" label={mode === 'expanded' ? 'compact' : 'expand'} onPress={on.toggle} />
513      <Button key="lw-refresh" plain hotkey="r" label="refresh" onPress={on.refresh} />
514      <Button key="lw-key" plain hotkey="k" label={showKey ? 'hide keys' : 'keys'} onPress={on.key} />
515      <Button key="lw-close" plain hotkey="q" label="close" onPress={on.close} />
516    </Box>
517  )
518}
519
520// The chart of every symbol: PR states, then stages.
521export function keyChart({ Box, Text }: Ui, columns: number) {
522  const layout = paneLayout(columns)
523  const room = layout.inner - 13
524  return (
525    <Box flexDirection="column" borderStyle={layout.bordered ? 'round' : undefined} borderDimColor paddingX={layout.bordered ? 1 : 0} marginTop={1} width={layout.width}>
526      <Text bold>PR states</Text>
527      {KEY_ORDER.map(s => (
528        <Box key={`key:${s}`} flexDirection="row">
529          <Text color={PR_LOOK[s].color}>{`${PR_LOOK[s].glyph} ${PR_WORD[s].padEnd(10)}`}</Text>
530          {room >= 16 && <Text dimColor>{fit(PR_LOOK[s].means, room)}</Text>}
531        </Box>
532      ))}
533      <Text bold>{' '}</Text>
534      <Text bold>Stages</Text>
535      {(['done', 'now', 'waiting', 'failed', 'todo', 'na'] as const).map(s => (
536        <Text key={`step:${s}`}>
537          <Text color={STEP_LOOK[s].color}>{STEP_LOOK[s].glyph}</Text>
538          <Text dimColor>{` ${STEP_LOOK[s].word}`}</Text>
539        </Text>
540      ))}
541    </Box>
542  )
543}
544
545// Recent changes, newest first, for the expanded panes.
546export function recent({ Box, Text }: Ui, updates: Update[], now: number, columns: number, filter: (u: Update) => boolean) {
547  const list = updates.filter(filter).slice(0, 5)
548  if (list.length === 0) return null
549  const w = paneLayout(columns).width
550  return (
551    <Box flexDirection="column" marginTop={1}>
552      <Text dimColor>RECENT CHANGES</Text>
553      {list.map((u, n) => (
554        <Text key={`upd:${n}:${u.at}`}>
555          <Text color={u.level === 'important' ? 'yellow' : 'gray'}>{u.level === 'important' ? '• ' : '  '}</Text>
556          <Text dimColor>{fit(`${u.text} · ${ago(u.at, now)}`, w - 2)}</Text>
557        </Text>
558      ))}
559    </Box>
560  )
561}
562
types/index.d.ts 89 lines
1// Where a linked pull request stands. Merge state and checks decide it before reviews do.
2export type PrState =
3  | 'merged'
4  | 'ready'
5  | 'blocked'
6  | 'running'
7  | 'review'
8  | 'changes'
9  | 'failing'
10  | 'conflict'
11  | 'behind'
12  | 'draft'
13  | 'closed'
14  | 'unknown'
15
16// Who acts next on a PR. 'author' is someone other than you who opened it.
17export type Actor = 'you' | 'author' | 'operator' | 'reviewer' | 'ci' | 'none' | 'unknown'
18
19// One CI check as GitHub reports it, reduced to what the views draw.
20export type Check = { name: string; outcome: 'passed' | 'failed' | 'running'; url?: string }
21
22// A PR's reviews: who approved, who asked for changes, who is still asked.
23export type Reviews = { approvedBy: string[]; changesBy: string[]; requested: string[] }
24
25export type Pr = {
26  repo: string
27  number: number
28  state: PrState
29  url: string
30  title?: string
31  author?: string
32  checks?: Check[]
33  reviews?: Reviews
34  // GitHub's own merge verdict: 'yes' only when it says CLEAN (or HAS_HOOKS / UNSTABLE).
35  mergeable?: 'yes' | 'no' | 'unknown'
36  // The raw mergeStateStatus, for the expanded view: CLEAN, BLOCKED, DIRTY, BEHIND, ...
37  mergeState?: string
38  // The ticket the PR is linked from.
39  ticket?: string
40  // ISO times from GitHub, for "open 4d" and "merged 2h ago".
41  createdAt?: string
42  mergedAt?: string
43  closedAt?: string
44}
45
46// One Linear state a ticket was in, and when it entered it (from Linear's state history).
47export type StateVisit = { name: string; type: string; at: string }
48
49// One stage of a ticket or PR: done, the one it is in now, waiting on someone,
50// failed or blocked, not reached yet, or not used here.
51export type StepState = 'done' | 'now' | 'waiting' | 'failed' | 'todo' | 'na'
52export type Step = { label: string; state: StepState; at?: string }
53
54export type Issue = {
55  id: string
56  title: string
57  status: string
58  statusType: string
59  url: string
60  priority?: string
61  project?: string
62  updatedAt?: string
63  prs?: Pr[]
64  // The states it went through, oldest first, when Linear gives them.
65  history?: StateVisit[]
66}
67
68// compact: the band is one line and pane rows have no details. expanded: both show more.
69export type Mode = 'compact' | 'expanded'
70
71// One change a check found, kept for the views. 'quiet' changes are listed but never toasted.
72export type Update = { at: number; text: string; level: 'important' | 'routine' | 'quiet'; ticket?: string; pr?: string }
73
74// Whether the last checks reached Linear; the views say when what they show is stale.
75export type Health = { failures: number; lastOk?: number; lastError?: string }
76
77declare module 'claude-code' {
78  interface PluginState {
79    'linear-watch': {
80      open: Issue[]
81      mode: { band: Mode; pane: Mode; key: boolean }
82      updates: Update[]
83      unseen: number
84      health: Health
85      checking: boolean
86    }
87  }
88}
89