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…

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.
Above the prompt. Two cards, side by side when the terminal has 100 columns or more:
IN REVIEW).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:
/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.
↗ 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./prs has a "Tickets to close" section.Each ticket and each PR has a line of stages. Each stage shows ✓ done, ● now, ◷ waiting, ✕ blocked, ○ not yet or – not used.
✓ Backlog → ✓ Todo → ● In Review → ○ Done · for 4d. The stages come from the real state history of the ticket in Linear.✓ 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.
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).
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.
design plugin. The mod tries both.gh CLI, signed in (gh auth status). The mod reads PR states with gh pr view./plugin install linear-watch --marketplace Mark-Hickey/claude-mods
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.
hooks/register.tsx 393 lines1import { 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}
393hooks/model.ts 582 lines1// 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')
582hooks/view.tsx 562 lines1// 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}
562types/index.d.ts 89 lines1// 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