Pane listing your open PRs across code hosts, grouped into stacks, with next-action buttons and a state file other tools can watch

Your open pull requests in a Claude Code side pane

Reads from GitHub, GitLab, Gitea/Forgejo and Azure DevOps.
/plugin install pr-deck --marketplace khaosdoctor/claude-pr-deck
From a shell:
claude plugin install pr-deck --marketplace khaosdoctor/claude-pr-deck
From a local clone (add claude --plugin-dir <path to clone> to load it for one session only):
claude plugin marketplace add <path to clone>
claude plugin install pr-deck@claude-pr-list-mod
Desktop app: click + next to the prompt box, then Plugins -> Add marketplace with khaosdoctor/claude-pr-deck, then Add plugin.
Update, then restart Claude Code:
claude plugin marketplace update claude-pr-list-mod
claude plugin update pr-deck@claude-pr-list-mod
The CLI of each code host you enable, signed in:
| Host | CLI | Sign in |
|---|---|---|
| GitHub | gh | gh auth login |
| GitLab | glab | glab auth login |
| Gitea, Forgejo | tea and jq | tea login add |
| Azure DevOps | az | az login, then az extension add --name azure-devops |
/prs
/prs watch acme/api#7
/prs monitor
/prs opens the pane./prs watch <pr> watches a PR until CI passes, see Watching a PR./prs monitor [filter] has the session watch the state file for changes to its PRs, see Monitoring the state file.hooks/register.tsx 756 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, ProcessRunResult, Register } from 'claude-code'
3
4import type { GroupBy, Pr, PromptsFile, Snapshot, Stack, StatePrompts, View } from '../types'
5import { configScreen } from './config-screen'
6import { parseJsonc } from './jsonc'
7import { DEFAULT_FABLE_PROMPT, monitorPrompt, nextAction, plain, prVars, promptsTemplate, stateFileOf, summaryOf, toStacks, toStatePrompts, watchPrompt, workAction } from './prs'
8import { fetchAll, repoFromRemote, ticketUrl } from './providers'
9import { openInEditor, quote, run } from './run'
10import { findPr, guarded, keepFailedHosts, parseConfig, repoKeyOf, safeSessionName, selectPrs, short, splitFullName, textOption } from './snapshot'
11import type { StoredConfig } from './snapshot'
12import { TERMINALS } from './terminals'
13import type { TerminalEnv } from './terminals'
14
15const PANE = 'pr-deck'
16const YOUR_MOVE = 'group:your-move'
17const snapshot = atom({ plugin: 'pr-deck', key: 'snapshot' } as const, null)
18const showAll = atom({ plugin: 'pr-deck', key: 'showAll' } as const, false)
19const collapsed = atom({ plugin: 'pr-deck', key: 'collapsed' } as const, [] as string[])
20// owner/repo, lowercase, as written in the config file
21const ignored = atom({ plugin: 'pr-deck', key: 'ignored' } as const, [] as string[])
22// status the person picked for a PR url, saved in the config file; a PR with no entry shows its own status
23const overrides = atom({ plugin: 'pr-deck', key: 'overrides' } as const, {} as Record<string, string>)
24const showIgnored =atom({ plugin: 'pr-deck', key: 'showIgnored' } as const, false)
25const view = atom({ plugin: 'pr-deck', key: 'view' } as const, 'list' as View)
26// the per-state prompt templates from ~/.config/pr-deck.prompts.jsonc, or why it could not be read
27const prompts = atom({ plugin: 'pr-deck', key: 'prompts' } as const, { map: {} } as PromptsFile)
28// options come as string | number | boolean | string[]; the command and path ones are strings
29const str = (value: unknown) => (typeof value === 'string' ? value : '')
30// listed in the order the statuses need attention
31const STATUS_COLOR: Record<string, string> = {
32 '✗ broken': 'error',
33 '✗ conflicts': 'error',
34 '● needs you': 'warning',
35 '○ waiting': 'suggestion',
36 '✓ approved': 'success',
37}
38const STATUS_ORDER = Object.keys(STATUS_COLOR)
39const colorOfStatus = (status: string) => STATUS_COLOR[status] ?? 'suggestion'
40
41// the statuses a move button cycles through after auto, in order
42const MOVES = ['○ waiting', '● needs you', '✓ approved']
43// undefined past the last one means back to auto
44const nextMove = (current: string | undefined) => MOVES[MOVES.indexOf(current ?? '') + 1]
45
46const configFile = async ($: EngineInterface, name: string) => `${(await $.env.get('HOME')) ?? ''}/.config/${name}`
47
48// Loads and saves of the config file run one at a time: a refresh reading the file between a save's state update and its write would put the old values back.
49const configQueue = { tail: Promise.resolve() as Promise<unknown> }
50function serial<T>(task: () => Promise<T>): Promise<T> {
51 const result = configQueue.tail.then(task)
52 configQueue.tail = result.catch(() => undefined)
53 return result
54}
55
56// text is undefined when the file is missing; config is undefined when the file does not parse (mid-edit)
57async function readConfig($: EngineInterface, path: string) {
58 const text = (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
59 return { text, config: parseConfig(text ?? '{}', MOVES) }
60}
61
62// The config file is the source of truth; edits to it apply on the next refresh.
63function loadConfig($: EngineInterface) {
64 return serial(async () => {
65 // a file that cannot be read keeps what is in memory too; a save says why
66 const { config } = await readConfig($, await configFile($, 'pr-deck.json')).catch(() => ({ config: undefined }))
67 // a file that does not parse keeps what is in memory
68 if (!config) return
69 await update($, ignored, () => config.ignored)
70 await update($, overrides, () => config.overrides)
71 })
72}
73
74// mtime of the prompts file at its last load: 0 when it was missing, -1 before the first load
75const promptsLoaded = { mtimeMs: -1 }
76
77// A separate file from pr-deck.json: the mod only reads it, so it can keep the user's comments.
78async function loadPrompts($: EngineInterface) {
79 const path = await configFile($, 'pr-deck.prompts.jsonc')
80 promptsLoaded.mtimeMs = await $.fs.stat(path).then(stat => stat.mtimeMs, () => 0)
81 if (!(await $.fs.exists(path))) return update($, prompts, () => ({ map: {} }))
82 try {
83 const map = toStatePrompts(parseJsonc(await $.fs.read(path)))
84 return update($, prompts, () => ({ map }))
85 } catch (error) {
86 return update($, prompts, () => ({ map: {}, error: error instanceof Error ? error.message : String(error) }))
87 }
88}
89
90// $.fs has no watch, so a save in the editor is picked up by polling the file's mtime
91async function reloadChangedPrompts($: EngineInterface) {
92 const path = await configFile($, 'pr-deck.prompts.jsonc')
93 const mtimeMs = await $.fs.stat(path).then(stat => stat.mtimeMs, () => 0)
94 if (mtimeMs !== promptsLoaded.mtimeMs) await loadPrompts($)
95}
96
97const SWAP_CHANGED = 3
98const SWAP_LOCKED = 75
99// lock waits and changed-file retries share this budget, about two seconds of waits
100const SWAP_ATTEMPTS = 20
101
102// Applies one change to what the file holds now, not to what the last refresh read, so an edit from another session or by hand survives.
103// $.fs has no lock or rename, so swap-config.sh writes the file: it swaps it only while it still holds the text read here, under a lock every session takes; when it changed, the change is applied again to the new text.
104// ponytail: an editor that saves by renaming a new file over the config within the script's compare and mv, a few microseconds, can still lose its edit; editors take no lock, and sh cannot see a rename it did not make.
105async function saveConfig($: EngineInterface, change: (config: StoredConfig) => StoredConfig = config => config) {
106 const file = await configFile($, 'pr-deck.json')
107 // a symlinked config (dotfiles) stays a symlink: the swap replaces the file it points to
108 const path = (await $.fs.stat(file, { resolve: true }).catch(() => undefined))?.realPath ?? file
109 const last = { text: undefined as string | undefined, wasChanged: false }
110 for (const _ of Array(SWAP_ATTEMPTS)) {
111 const { text, config: current } = await readConfig($, path)
112 // writing over a file that does not parse would wipe the person's edit
113 if (!current) throw new Error('~/.config/pr-deck.json does not parse; fix it and try again')
114 // $.fs.read turns bytes that are not UTF-8 into U+FFFD, so the text never matches the file: the same text twice is not a change
115 if (last.wasChanged && text !== undefined && text === last.text) throw new Error('~/.config/pr-deck.json holds bytes that are not UTF-8; save it as UTF-8 and try again')
116 const config = change(current)
117 const next = `${JSON.stringify({ ignored: config.ignored, overrides: config.overrides }, null, 2)}\n`
118 const swap = await $.process
119 .run(['sh', `${$.plugin.root}/hooks/swap-config.sh`, path, text === undefined ? 'missing' : 'file', text ?? '', next])
120 .catch(error => ({ exitCode: 1, stderr: error instanceof Error ? error.message : String(error) }))
121 last.text = text
122 last.wasChanged = swap.exitCode === SWAP_CHANGED
123 if (swap.exitCode === SWAP_LOCKED) await $.clock.sleep(100)
124 if (swap.exitCode === SWAP_LOCKED || swap.exitCode === SWAP_CHANGED) continue
125 if (swap.exitCode !== 0) throw new Error(`Could not save ~/.config/pr-deck.json: ${short(swap.stderr.trim() || `sh exited ${swap.exitCode}`, 200)}`)
126 await update($, ignored, () => config.ignored)
127 await update($, overrides, () => config.overrides)
128 return
129 }
130 throw new Error(`Could not save ~/.config/pr-deck.json: it kept changing, or ${path}.lock is left from a crash; remove it if no session is saving`)
131}
132
133// Overrides of PRs this session saw close are dropped. One for a PR this session never fetched (another session's, another host's) stays.
134// ponytail: a PR that closes while no session is open keeps its override in the file; it only costs a line.
135async function pruneOverrides($: EngineInterface, before: Set<string>) {
136 const after = new Set((await read($, snapshot))?.prs.map(pr => pr.url))
137 const gone = new Set(Object.keys(await read($, overrides)).filter(url => before.has(url) && !after.has(url)))
138 if (gone.size === 0) return
139 await serial(() => saveConfig($, config => ({ ...config, overrides: Object.fromEntries(Object.entries(config.overrides).filter(([url]) => !gone.has(url))) })))
140}
141
142// undefined puts the PR back to auto
143function saveOverride($: EngineInterface, url: string, status: string | undefined) {
144 return serial(() =>
145 saveConfig($, config => {
146 const rest = Object.entries(config.overrides).filter(([key]) => key !== url)
147 return { ...config, overrides: Object.fromEntries(status ? [...rest, [url, status]] : rest) }
148 }),
149 )
150}
151
152function saveIgnored($: EngineInterface, list: (current: string[]) => string[]) {
153 return serial(() => saveConfig($, config => ({ ...config, ignored: list(config.ignored) })))
154}
155
156async function openFile($: EngineInterface, options: PluginOptions, name: string, create: (path: string) => Promise<unknown>) {
157 const path = await configFile($, name)
158 if (!(await $.fs.exists(path))) await create(path)
159 toastFailure($, await openInEditor($, textOption(options, 'editorCommand'), path))
160}
161
162// a leading ~ is the home folder
163async function expandHome($: EngineInterface, path: string) {
164 if (path !== '~' && !path.startsWith('~/')) return path
165 return `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}`
166}
167
168// empty when stateFile is "off"
169async function stateFilePath($: EngineInterface, options: PluginOptions) {
170 const path = textOption(options, 'stateFile')
171 return path.toLowerCase() === 'off' ? '' : expandHome($, path)
172}
173
174// Other tools watch this file; stateFile "off" turns it off. $.fs has no rename, so it is a plain write.
175async function writeStateFile($: EngineInterface, options: PluginOptions) {
176 const path = await stateFilePath($, options)
177 const snap = await read($, snapshot)
178 // every host failed and nothing was known: keep the file another session wrote while it was online
179 if (!path || !snap || (snap.error && snap.prs.length === 0)) return
180 const manual = await read($, overrides)
181 const { map } = await read($, prompts)
182 const state = stateFileOf(snap.prs, (pr, isBottom) => resolveStatus(pr, isBottom, manual, map), snap.updatedAt, snap.error, await read($, ignored))
183 await $.fs.write(path, `${JSON.stringify(state, null, 2)}\n`)
184}
185
186async function refresh($: EngineInterface, options: PluginOptions) {
187 await loadConfig($)
188 await loadPrompts($)
189 const [{ prs, errors }, remote] = await Promise.all([
190 fetchAll($, options),
191 run($, textOption(options, 'remoteCommand')).catch(() => undefined),
192 ])
193 const now = await $.clock.now()
194 const project = remote?.exitCode === 0 ? repoFromRemote(remote.stdout) : undefined
195 const error = errors.join(' · ')
196
197 const before = new Set((await read($, snapshot))?.prs.map(pr => pr.url))
198 await update($, snapshot, (old): Snapshot => ({ prs: keepFailedHosts(prs, old?.prs ?? [], errors), updatedAt: now, project, error: error || undefined }))
199 await pruneOverrides($, before).catch(failure => $.ui.toast(short(failure instanceof Error ? failure.message : String(failure), 200)))
200 await writeStateFile($, options).catch(failure => $.ui.toast(`Could not write the state file: ${short(failure instanceof Error ? failure.message : String(failure), 200)}`))
201}
202
203// `/prs watch <ref>` sends the card's watch prompt, so a session that has no pane open (one from New session, a script) can start watching a PR
204async function watchCommand($: EngineInterface, options: PluginOptions, ref: string) {
205 const known = await read($, snapshot)
206 const first = findPr(known?.prs ?? [], ref, known?.project)
207 // the session may not have fetched yet, or the PR is newer than the last refresh; the PR and its stack come from the same snapshot
208 const snap = typeof first === 'string' ? await refresh($, options).then(() => read($, snapshot)) : known
209 const pr = typeof first === 'string' ? findPr(snap?.prs ?? [], ref, snap?.project) : first
210 // a fetch that failed is the likelier reason nothing matched
211 if (typeof pr === 'string') return snap?.error ? `${pr} The last refresh failed: ${snap.error}` : pr
212 const isBottom = toStacks(snap?.prs ?? []).find(stack => stack.prs.includes(pr))?.prs[0] === pr
213 const { map } = await read($, prompts)
214 const { action } = resolveStatus(pr, isBottom, await read($, overrides), map)
215 void guarded($, () => $.prompt.submit({ text: watchPrompt(pr, action.state, str(options.watchPrompt), map) }))
216 return `Watching ${pr.fullName}#${pr.number}.`
217}
218
219// `/prs monitor [filter]` asks the session to watch the state file; without a filter it watches the PRs the session owns
220async function monitorCommand($: EngineInterface, options: PluginOptions, filter: string) {
221 const path = await stateFilePath($, options)
222 if (!path) return 'The state file is off; set the State file option to a path first.'
223 const known = await read($, snapshot)
224 const snap = known ?? (await refresh($, options).then(() => read($, snapshot)))
225 const prs = snap?.prs ?? []
226 const picked = filter ? selectPrs(prs, filter, snap?.project) : undefined
227 if (typeof picked === 'string') return snap?.error ? `${picked} The last refresh failed: ${snap.error}` : picked
228 const branch = picked ? undefined : await run($, 'git rev-parse --abbrev-ref HEAD')
229 const head = branch?.exitCode === 0 ? branch.stdout.trim() : ''
230 const onBranch = prs.filter(pr => head && pr.head === head && pr.fullName === snap?.project)
231 void guarded($, () => $.prompt.submit({ text: monitorPrompt(path, picked, onBranch, str(options.monitorPrompt)) }))
232 return picked ? `Monitoring ${picked.length} PR${picked.length === 1 ? '' : 's'} in ${path}.` : `Monitoring this session's PRs in ${path}.`
233}
234
235// the session name for a PR, e.g. pr-3109-worker; empty turns naming off
236const sessionNameOf = (options: PluginOptions, pr: Pr) =>
237 options.autoRename === false ? '' : safeSessionName(plain(textOption(options, 'sessionName'), prVars(pr)))
238
239// where the PR's repo is cloned, undefined when cloneDir is blank
240async function cloneDirOf($: EngineInterface, options: PluginOptions, pr: Pr) {
241 const template = str(options.cloneDir).trim()
242 if (!template) return undefined
243 return expandHome($, plain(template, { host: pr.host, ...splitFullName(pr.fullName) }))
244}
245
246function ciLine(pr: Pr): { text: string; color: string } {
247 const counts = `${pr.checksPassed}/${pr.checksTotal}`
248 if (pr.ci === 'SUCCESS') return { text: '✓ CI passing', color: 'success' }
249 if (pr.ci === 'FAILURE' || pr.ci === 'ERROR') return { text: `✗ CI failed ${counts}`, color: 'error' }
250 if (pr.ci === 'PENDING' || pr.ci === 'EXPECTED') return { text: `◌ CI running ${counts}`, color: 'warning' }
251 return { text: '· no CI', color: 'subtle' }
252}
253
254function reviewLine(pr: Pr): { text: string; color: string } {
255 if (pr.isDraft) return { text: 'draft', color: 'subtle' }
256 if (pr.unresolved > 0) return { text: `${pr.unresolved} unresolved`, color: 'warning' }
257 if (pr.review === 'APPROVED') return { text: 'approved', color: 'success' }
258 if (pr.review === 'CHANGES_REQUESTED') return { text: 'changes requested', color: 'error' }
259 return { text: 'review needed', color: 'suggestion' }
260}
261
262// The status in a card's top-right corner, from the PR's own state.
263function ownStatus(pr: Pr, needsMe: boolean) {
264 if (pr.ci === 'FAILURE' || pr.ci === 'ERROR') return '✗ broken'
265 if (pr.mergeable === 'CONFLICTING') return '✗ conflicts'
266 if (needsMe) return '● needs you'
267 if (pr.review === 'APPROVED') return '✓ approved'
268 return '○ waiting'
269}
270
271// the one place a PR's status, colour and "needs you" come from: the person's pick when there is one, else its own
272function resolveStatus(pr: Pr, isBottom: boolean, manual: Record<string, string>, statePrompts: StatePrompts) {
273 const action = nextAction(pr, isBottom, statePrompts)
274 const hasPrompt = Boolean(action.prompt)
275 const picked = manual[pr.url]
276 const status = picked ?? ownStatus(pr, hasPrompt)
277 return { action, color: colorOfStatus(status), status, needsMe: picked ? picked === '● needs you' : hasPrompt, isManual: Boolean(picked) }
278}
279
280// Each repo (or PR, for random card colours) gets a theme colour picked from its name, so it keeps the same one across refreshes and filters.
281const REPO_COLORS = ['claude', 'suggestion', 'merged', 'autoAccept', 'planMode', 'ide', 'permission']
282const repoColor = (repo: string) =>
283 REPO_COLORS[[...repo].reduce((sum, char) => sum + char.charCodeAt(0), 0) % REPO_COLORS.length] ?? 'claude'
284
285// Labels are written "icon text"; buttonStyle keeps one half or both.
286const labelFor = (style: unknown) => (label: string) => {
287 const cut = label.indexOf(' ')
288 if (style === 'icons') return label.slice(0, cut)
289 if (style === 'text') return label.slice(cut + 1)
290 return label
291}
292
293// a command that failed says why in a toast
294function toastFailure($: EngineInterface, result: ProcessRunResult) {
295 if (result.exitCode !== 0) $.ui.toast(short(result.stderr.trim() || `command exited ${result.exitCode}`, 200))
296}
297
298export const register: Register = (on, options) => {
299 const label = labelFor(options.buttonStyle)
300 // the option is only the atom's starting value, a toggle sticks until the session state resets
301 const groupBy = atom({ plugin: 'pr-deck', key: 'groupBy' } as const, (options.defaultGroup === 'none' ? 'none' : 'status') as GroupBy)
302 const isAttention = !['status', 'repo', 'random'].includes(str(options.cardColors))
303 const cardModes = ['attention', 'status', 'repo', 'random']
304 const cardMode = isAttention ? 'attention' : str(options.cardColors)
305 const cardColor = (pr: Pr, repo: string, status: string, needsMe: boolean) => {
306 if (options.cardColors === 'repo') return repoColor(repo)
307 if (options.cardColors === 'random') return repoColor(pr.url)
308 if (isAttention) return needsMe ? status : 'subtle'
309 return status
310 }
311
312 on('session.start', async ($, e, next) => {
313 await $.command.register({
314 name: 'prs',
315 description: 'Show your open PRs and stacks in a pane, watch one until it is green, or monitor the state file for changes',
316 argumentHint: '[watch <pr> | monitor [all|owner/repo|pr]]',
317 })
318 void guarded($, () => refresh($, options))
319 $.clock.every((Number(options.refreshMinutes) || 2) * 60 * 1000, () => void guarded($, () => refresh($, options)))
320 $.clock.every(2000, () => void guarded($, () => reloadChangedPrompts($)))
321 // session.start also fires on a reload, so this reopens a pane the person closed then; unfocused so the prompt keeps the cursor
322 if (options.openOnStart === true) void guarded($, () => $.ui.open({ id: PANE, title: 'Pull requests' }))
323
324 return next(e)
325 })
326
327 on('command.run', { command: 'prs' }, async ($, e) => {
328 const [sub, ...rest] = e.args.trim().split(/\s+/)
329 if (sub === 'watch') return { text: await watchCommand($, options, rest.join(' ')) }
330 if (sub === 'monitor') return { text: await monitorCommand($, options, rest.join(' ')) }
331 void guarded($, () => refresh($, options))
332 await $.ui.open({ id: PANE, title: 'Pull requests', focus: true })
333
334 return { text: 'PR pane opened.' }
335 })
336
337 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
338 const ui = $.ui.resolve(e)
339 const { Box, Text, Button } = ui
340 // mobile has no Input element, so it keeps the list and has no Config button
341 const hasConfig = 'Input' in ui
342 if (hasConfig && (await read($, view)) === 'config') {
343 return configScreen($, ui, {
344 options,
345 label,
346 back: () => void update($, view, () => 'list'),
347 openIgnored: () => void guarded($, () => openFile($, options, 'pr-deck.json', () => serial(() => saveConfig($)))),
348 // the file is only created here, with every state as a commented-out block
349 openPrompts: () => void guarded($, () => openFile($, options, 'pr-deck.prompts.jsonc', path => $.fs.write(path, promptsTemplate()))),
350 })
351 }
352 const snap = await read($, snapshot)
353
354 if (snap === null) return <Text dimColor>Loading PRs…</Text>
355
356 // the toggle flips away from the configured default
357 const isAll = (await read($, showAll)) !== (options.alwaysShowAll === true) || !snap.project
358 const ignoredList = await read($, ignored)
359 const isIgnoredOpen = await read($, showIgnored)
360 const visible = snap.prs.filter(pr => !ignoredList.includes(pr.fullName))
361 const prs = isAll ? visible : visible.filter(pr => pr.fullName === snap.project)
362 const toggle = () => update($, showAll, value => !value)
363 const fold = (id: string) => update($, collapsed, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
364 const stacks = toStacks(prs)
365 const rank = (key: string) => STATUS_ORDER.indexOf(key)
366 const manual = await read($, overrides)
367 const { map: statePrompts, error: promptsError } = await read($, prompts)
368 const statusFor = (pr: Pr, i: number) => resolveStatus(pr, i === 0, manual, statePrompts)
369 const statusOf = (pr: Pr, i: number) => statusFor(pr, i).status
370 const moves = stacks
371 .flatMap(stack => stack.prs.map((pr, i) => ({ pr, i })).filter(({ pr, i }) => statusFor(pr, i).needsMe))
372 .sort((a, b) => rank(statusOf(a.pr, a.i)) - rank(statusOf(b.pr, b.i)))
373 const pinMoves = options.pinYourMove !== false && moves.length > 0
374 // a stack where every PR needs you moves to Your move whole; a stack where only some do stays intact in its repo and those PRs show in both places
375 const sections = pinMoves ? stacks.filter(s => !s.prs.every((pr, i) => statusFor(pr, i).needsMe)) : stacks
376 // sections are named by repoKey, so two repos with the same short name stay apart
377 const repoKey = repoKeyOf(snap.prs)
378 const sectionRepo = (stack: Stack) => (stack.prs[0] ? repoKey(stack.prs[0]) : stack.repo)
379 const repos = [...new Set(sections.map(sectionRepo))]
380 // numbered by bottom PR so a stack keeps its number when grouping changes
381 // PR numbers repeat across repos, so the url identifies the bottom PR
382 const bottoms = stacks
383 .filter(s => s.prs.length > 1)
384 .flatMap(s => s.prs.slice(0, 1))
385 .sort((a, b) => a.number - b.number || a.url.localeCompare(b.url))
386 const stackNumber = (stack: Stack) => bottoms.findIndex(pr => pr.url === stack.prs[0]?.url) + 1
387 // border, padding, "#1234 ", the stack marker and the status take ~28 columns
388 const titleRoom = Math.max(20, (e.viewport?.columns ?? 80) - 28)
389 // handlers run unawaited, so a rejection from the engine has to end in a toast
390 const guard = (task: () => unknown) => void guarded($, task)
391 const submit = (text: string) => guard(() => $.prompt.submit({ text }))
392 const open = (url: string) => guard(async () => toastFailure($, await run($, textOption(options, 'openCommand'), { url })))
393 const copy = (text: string, surface: Parameters<typeof $.ui.copy>[0]['surface']) =>
394 guard(async () => {
395 const result = await $.ui.copy({ text, surface })
396 if (!result.isCopied) $.ui.toast(`Could not copy (${result.reason})`)
397 })
398 const switchHere = async (pr: Pr) => {
399 const name = sessionNameOf(options, pr)
400 if (name) await $.command.run({ command: 'rename', args: name }).catch(() => $.ui.toast(`Could not rename the session to ${name}`))
401 const dir = await cloneDirOf($, options, pr)
402 const where = dir ? `it should be at ${dir}` : 'look in nearby folders'
403 submit(
404 `Switch this session to repo ${pr.fullName} (host ${pr.host}) on branch ${pr.head}. If the current folder is not a clone of that repo, find the local clone (${where}) or clone it, cd there, fetch and check out ${pr.head}, then summarize the PR state and wait for instructions.`,
405 )
406 }
407 const newSession = async (pr: Pr, prompt: string) => {
408 const dir = (await cloneDirOf($, options, pr)) ?? (await $.session.cwd())
409 // an empty option means auto: the first terminal that matches the session's own environment
410 const env: TerminalEnv = {
411 program: await $.env.get('TERM_PROGRAM'),
412 bundleId: await $.env.get('__CFBundleIdentifier'),
413 tmux: await $.env.get('TMUX'),
414 zellij: await $.env.get('ZELLIJ'),
415 kittyWindow: await $.env.get('KITTY_WINDOW_ID'),
416 alacrittyWindow: await $.env.get('ALACRITTY_WINDOW_ID'),
417 }
418 const custom = str(options.newSessionCommand).trim()
419 const terminal = TERMINALS.find(t => t.test(env))
420 const name = sessionNameOf(options, pr)
421 const vars = { dir, prompt, shellPrompt: quote(prompt), label: `#${pr.number} ${pr.repo}`, url: pr.url, branch: pr.head, repo: pr.fullName, name }
422 // {claude} goes in unquoted: the name is limited to safe characters, so it survives any shell or AppleScript layer
423 const template = (custom || (terminal?.command ?? '')).replaceAll('{claude}', name ? `claude -n ${name}` : 'claude')
424 const result = await run($, template, vars)
425 const where = custom ? 'a new session' : terminal?.name
426 $.ui.toast(result.exitCode === 0 ? `Opened #${pr.number} in ${where}` : short(result.stderr.trim() || `new session command exited ${result.exitCode}`, 200))
427 }
428 // session state may still hold the removed 'project' value, treat anything else as status
429 const folded = new Set(await read($, collapsed))
430 const grouping: GroupBy =(await read($, groupBy)) === 'none' ? 'none' : 'status'
431 // a stack groups as its bottom PR, so stacks never split
432 const groupOf = (stack: Stack): string => {
433 const bottom = stack.prs[0]
434 if (!bottom || grouping === 'none') return ''
435 return statusOf(bottom, 0)
436 }
437 // fold levels, each adding to the one below: 1 cards, 2 status groups and stacks, 3 repos and Your move
438 const startFolded = options.startCollapsed === true
439 const cardIds = prs.map(pr => `pr:${pr.url}`)
440 const sectionIds = [
441 ...new Set([
442 ...(pinMoves && grouping === 'status' ? moves.map(m => `status:move:${repoKey(m.pr)}:${statusOf(m.pr, m.i)}`) : []),
443 ...sections.filter(s => groupOf(s)).map(s => `status:repo:${sectionRepo(s)}:${groupOf(s)}`),
444 ...sections.filter(s => s.prs.length > 1).map(s => `stack:${s.prs[0]?.url}`),
445 ]),
446 ]
447 const topIds = [...repos, ...(pinMoves ? [YOUR_MOVE] : [])]
448 const levelSize = [0, cardIds.length, sectionIds.length, topIds.length]
449 // the level comes from what is folded now, so a hand-folded repo counts as level 3
450 const foldLevel = (() => {
451 if (topIds.some(id => folded.has(id))) return 3
452 if (sectionIds.some(id => folded.has(id))) return 2
453 return cardIds.some(id => folded.has(id) !== startFolded) ? 1 : 0
454 })()
455 // the list holds PRs toggled away from the startCollapsed default
456 const foldTo = (level: number) =>
457 update($, collapsed, () => [
458 ...cardIds.filter(() => (level >= 1) !== startFolded),
459 ...(level >= 2 ? sectionIds : []),
460 ...(level >= 3 ? topIds : []),
461 ])
462 // a level with nothing to fold is skipped, or the press would change nothing; past the last level it wraps to all open
463 const collapseTo = [1, 2, 3].find(n => n > foldLevel && levelSize[n]) ?? 0
464 const statusHeader = (statusId: string, status: string, count: number) => (
465 <Button key={statusId} plain onPress={() => fold(statusId)}>
466 <Text bold color={colorOfStatus(status)}>
467 {folded.has(statusId) ? '▸' : '▾'} {status} <Text dimColor>{count}</Text>
468 </Text>
469 </Button>
470 )
471 // place: 'move' when drawn in Your move, since the same PR can also sit in its repo section
472 const card = (pr: Pr, i: number, isStack: boolean, place = '') => {
473 const { action, color: statusColor, status, needsMe, isManual } = statusFor(pr, i)
474 const work = workAction(pr, action, options.showCopilotPass !== false, str(options.workPrompt), statePrompts)
475 const ci = ciLine(pr)
476 const review = reviewLine(pr)
477 const color = cardColor(pr, repoKey(pr), statusColor, needsMe)
478 const isDim = isAttention && !needsMe
479 const hasBar = isAttention && needsMe
480 const foldKey = `pr:${pr.url}`
481 // the same PR can be drawn twice (Your move and its repo), so button keys carry the place
482 const key = (name: string) => `${name}:${place}${pr.url}`
483 const ticketLink = pr.ticket ? ticketUrl(options, pr.ticket) : undefined
484 const isPrFolded = folded.has(foldKey) !== startFolded
485 const titleRow = (
486 <Box justifyContent="space-between">
487 <Box flexShrink={1}>
488 <Button key={key('fold')} plain label={isPrFolded ? '▸' : '▾'} onPress={() => fold(foldKey)} />
489 <Text> </Text>
490 {isStack && <Text color={color}>{i === 0 ? '◆ ' : '◇ '}</Text>}
491 <Button key={key('pr')} plain label={`#${pr.number}`} onPress={() => open(pr.url)} />
492 <Text bold dimColor={isDim} color={color} wrap="truncate-end">
493 {' '}
494 {short(pr.title, titleRoom)}
495 </Text>
496 </Box>
497 <Box flexShrink={0} marginLeft={1}>
498 {isManual && <Text dimColor>✎ </Text>}
499 <Text color={statusColor}>{status}</Text>
500 </Box>
501 </Box>
502 )
503
504 // the bar is an absolute Box pinned top and bottom, so it spans the card height whatever it holds; a long column of ┃ is clipped to it
505 const bar = hasBar && (
506 <Box position="absolute" top={0} bottom={0} left={0} width={1} overflow="hidden">
507 {/* grouped by status, the headers say the status, so the bar carries the repo; ungrouped, it carries the status */}
508 <Text color={grouping === 'status' ? repoColor(repoKey(pr)) : statusColor}>{'┃\n'.repeat(60)}</Text>
509 </Box>
510 )
511
512 if (isPrFolded) {
513 return (
514 <Box key={pr.url} flexDirection="column" borderStyle="round" borderColor={color} paddingLeft={hasBar ? 2 : 1} paddingRight={1}>
515 {bar}
516 {titleRow}
517 </Box>
518 )
519 }
520
521 return (
522 <Box key={pr.url} flexDirection="column" borderStyle="round" borderColor={color} paddingLeft={hasBar ? 2 : 1} paddingRight={1}>
523 {bar}
524 {titleRow}
525 {options.showSummary !== false && pr.summary && <Text dimColor wrap="wrap">{summaryOf(pr.summary, Number(options.summaryLength) || 150)}</Text>}
526 <Box flexWrap="wrap" columnGap={1}>
527 <Button key={key('copy-url')} label={label('⧉ copy link')} onPress={press => copy(pr.url, press.surface)} />
528 <Button key={key('browser')} label={label('↗ open')} onPress={() => open(pr.url)} />
529 <Button key={key('copy-num')} label={label(`# copy ${pr.number}`)} onPress={press => copy(String(pr.number), press.surface)} />
530 <Button key={key('move')} label={label(`⇄ move: ${nextMove(manual[pr.url])?.slice(2) ?? 'auto'}`)} onPress={() => guard(() => saveOverride($, pr.url, nextMove(manual[pr.url])))} />
531 </Box>
532 <Text dimColor={isDim} wrap="truncate-end">
533 <Text color={ci.color}>{ci.text}</Text>
534 <Text dimColor> · </Text>
535 <Text color={review.color}>{review.text}</Text>
536 <Text dimColor> · {pr.changedFiles} files </Text>
537 <Text bold color="success">
538 +{pr.additions}
539 </Text>
540 <Text> </Text>
541 <Text bold color="error">
542 −{pr.deletions}
543 </Text>
544 </Text>
545 <Box>
546 {pr.ticket && (
547 ticketLink ? (
548 <Button key={key('ticket')} plain label={pr.ticket} onPress={() => open(ticketLink)} />
549 ) : (
550 <Text>{pr.ticket}</Text>
551 )
552 )}
553 <Text dimColor>{pr.ticket ? ' · ' : ''}</Text>
554 <Button key={key('files')} plain label={short(pr.head, titleRoom)} onPress={() => open(`${pr.url}/files`)} />
555 </Box>
556 <Box marginTop={1} flexWrap="wrap" columnGap={1}>
557 {/* next action keeps its text in every buttonStyle, an arrow alone says nothing */}
558 <Button
559 key={key('next')}
560 label={options.buttonStyle === 'text' ? action.label : `→ ${action.label}`}
561 variant={action.prompt ? 'primary' : undefined}
562 onPress={() => action.prompt && submit(action.prompt)}
563 />
564 {options.showFableButton !== false && (
565 <Button
566 key={key('review')}
567 label={label('✦ Fable review')}
568 onPress={() =>
569 submit(plain(str(options.fablePrompt).trim() || DEFAULT_FABLE_PROMPT, prVars(pr)))
570 }
571 />
572 )}
573 <Button key={key('work')} label={label(work.label)} onPress={() => work.prompt && submit(work.prompt)} />
574 <Button key={key('watch')} label={label('◉ watch')} onPress={() => submit(watchPrompt(pr, action.state, str(options.watchPrompt), statePrompts))} />
575 <Button key={key('switch')} label={label('⎇ Switch here')} onPress={() => guard(() => switchHere(pr))} />
576 <Button key={key('new-session')} label={label('⧉ New session')} onPress={() => guard(() => newSession(pr, workAction(pr, action, false, str(options.workPrompt), statePrompts).prompt ?? ''))} />
577 </Box>
578 </Box>
579 )
580 }
581
582 return (
583 <Box flexDirection="column">
584 {/* the header is a filled Box, so the strip spans the pane whatever its width; the empty row after it comes from the next block's marginTop */}
585 <Box flexDirection="column" backgroundColor="userMessageBackground" marginTop={1}>
586 <Box justifyContent="center">
587 <Text wrap="truncate-end">
588 <Text bold color="claude">
589 PULL REQUESTS
590 </Text>
591 <Text bold> · {isAll ? 'ALL REPOS' : snap.project?.toUpperCase()} · {prs.length} OPEN · </Text>
592 <Text bold color="warning">{moves.length} NEED YOU</Text>
593 <Text dimColor> · updated {new Date(snap.updatedAt).toTimeString().slice(0, 5)}</Text>
594 </Text>
595 </Box>
596 <Box justifyContent="center" flexWrap="wrap" columnGap={1}>
597 {snap.project && <Button key="scope" label={label(isAll ? '◧ This repo' : '◨ All')} onPress={toggle} />}
598 <Button key="group" label={label(`⊞ ${grouping}`)} onPress={() => update($, groupBy, () => (grouping === 'none' ? 'status' : 'none'))} />
599 <Button
600 key="colors"
601 label={label(`◐ ${cardMode}`)}
602 onPress={() =>
603 guard(async () => {
604 const next = cardModes[(cardModes.indexOf(cardMode) + 1) % cardModes.length] ?? 'attention'
605 const { deny } = await $.config.set({ key: `${$.plugin.name}.cardColors`, value: next })
606 if (deny) $.ui.toast(deny)
607 })
608 }
609 />
610 <Button key="collapse" label={label(`⊖ collapse ${foldLevel}/3`)} onPress={() => foldTo(collapseTo)} />
611 <Button key="refresh" label={label('↻ Refresh')} onPress={() => guard(() => refresh($, options))} />
612 {hasConfig && <Button key="config" label={label('⚙ Config')} onPress={() => update($, view, () => 'config')} />}
613 </Box>
614 </Box>
615 {!snap.project && <Text dimColor>no repo remote in this folder, showing all repos</Text>}
616 {snap.error && <Text color="error">fetch failed: {short(snap.error, 200)}</Text>}
617 {promptsError && <Text color="error">prompts file: {short(promptsError, 200)}</Text>}
618 {prs.length === 0 && (
619 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} marginTop={1}>
620 <Text dimColor>{isAll ? 'No open PRs of yours.' : `No open PRs of yours in ${snap.project}.`}</Text>
621 {!isAll && <Button key="show-all" label="Show all PRs" variant="primary" onPress={toggle} />}
622 </Box>
623 )}
624 {pinMoves && (
625 <Box flexDirection="column" marginTop={1}>
626 <Button key={YOUR_MOVE} plain onPress={() => fold(YOUR_MOVE)}>
627 <Text color="warning">{folded.has(YOUR_MOVE) ? '▸' : '▾'} </Text>
628 <Text bold color="inverseText" backgroundColor="warning"> ● YOUR MOVE </Text>{' '}
629 <Text dimColor>{moves.length}</Text>
630 </Button>
631 {!folded.has(YOUR_MOVE) &&
632 [...new Set(moves.map(m => repoKey(m.pr)))].map(repo => {
633 const mine = moves.filter(m => repoKey(m.pr) === repo)
634 // moves are already sorted by status, so the keys come out in attention order
635 const keys = [...new Set(mine.map(m => (grouping === 'none' ? '' : statusOf(m.pr, m.i))))]
636 return (
637 <Box key={`move:${repo}`} flexDirection="column" marginTop={1}>
638 <Text>
639 <Text bold color="inverseText" backgroundColor={repoColor(repo)}>
640 {' '}
641 {repo.toUpperCase()}{' '}
642 </Text>{' '}
643 <Text dimColor>{mine.length}</Text>
644 </Text>
645 {keys.map(key => {
646 const statusId = `status:move:${repo}:${key}`
647 const isGroupFolded = key !== '' && folded.has(statusId)
648 return (
649 <Box key={`move:${repo}:${key}`} flexDirection="column">
650 {key && statusHeader(statusId, key, mine.filter(m => statusOf(m.pr, m.i) === key).length)}
651 {!isGroupFolded &&
652 mine.filter(m => !key || statusOf(m.pr, m.i) === key).map(({ pr, i }) => card(pr, i, false, 'move:'))}
653 </Box>
654 )
655 })}
656 </Box>
657 )
658 })}
659 </Box>
660 )}
661 {repos.map(repo => (
662 <Box key={repo} flexDirection="column" marginTop={1}>
663 <Box>
664 <Button key={`repo:${repo}`} plain onPress={() => fold(repo)}>
665 <Text bold size="title" color="inverseText" backgroundColor={repoColor(repo)}>
666 {' '}
667 {folded.has(repo) ? '▸' : '▾'} {repo.toUpperCase()}{' '}
668 </Text>{' '}
669 <Text dimColor>{sections.filter(s => sectionRepo(s) === repo).reduce((n, s) => n + s.prs.length, 0)}</Text>
670 </Button>
671 <Text> </Text>
672 <Button
673 key={`ignore:${repo}`}
674 plain
675 dimColor
676 label="⊘ ignore"
677 onPress={() => {
678 const name = snap.prs.find(pr => repoKey(pr) === repo)?.fullName ?? repo
679 guard(() => saveIgnored($, list => (list.includes(name) ? list : [...list, name])))
680 }}
681 />
682 </Box>
683 {/* viewport is the whole screen, not the pane: draw a long rule and clip it to one row of the pane's width */}
684 <Box width="100%" height={1} overflow="hidden">
685 <Text color={repoColor(repo)}>{'━'.repeat(500)}</Text>
686 </Box>
687 {[...new Set(sections.filter(s => sectionRepo(s) === repo && !folded.has(repo)).map(groupOf))]
688 .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
689 .map(key => {
690 const group = sections.filter(s => sectionRepo(s) === repo && groupOf(s) === key)
691 // status ids are prefixed so they never clash with repo names, stack: or pr: keys
692 const statusId = `status:repo:${repo}:${key}`
693 const isGroupFolded = key !== '' && folded.has(statusId)
694 return (
695 <Box key={`${repo}:${key}`} flexDirection="column" marginTop={1}>
696 {key && statusHeader(statusId, key, group.reduce((n, s) => n + s.prs.length, 0))}
697 {!isGroupFolded && group.map(stack => {
698 const isStack = stack.prs.length > 1
699 // collapsed keys share one list with repos; stack keys are prefixed so they never clash
700 const stackId = `stack:${stack.prs[0]?.url}`
701 const isFolded = isStack && folded.has(stackId)
702 const top = stack.prs.at(-1)
703 return (
704 <Box key={stackId} flexDirection="column">
705 {isStack && (
706 <Button key={`stack:${stackId}`} plain onPress={() => fold(stackId)}>
707 <Text color="claude">
708 {isFolded ? '▸' : '▾'} stack #{stackNumber(stack)} on {stack.base}
709 </Text>
710 {isFolded && top && (
711 <Text dimColor>
712 {' '}
713 · {stack.prs.length} PRs · last #{top.number} {short(top.title, titleRoom - 20)}
714 </Text>
715 )}
716 </Button>
717 )}
718 {!isFolded && stack.prs.map((pr, i) => card(pr, i, isStack))}
719 {isStack && !isFolded && <Text color="claude">└─ end of stack #{stackNumber(stack)}</Text>}
720 </Box>
721 )
722 })}
723 </Box>
724 )
725 })}
726 </Box>
727 ))}
728 {ignoredList.length > 0 && (
729 <Box flexDirection="column" marginTop={1}>
730 <Button key="ignored" plain dimColor onPress={() => update($, showIgnored, value => !value)}>
731 <Text dimColor>
732 {isIgnoredOpen ? '▾' : '▸'} Ignored {ignoredList.length}
733 </Text>
734 </Button>
735 {isIgnoredOpen &&
736 ignoredList.map(name => (
737 <Box key={`ignored:${name}`}>
738 <Text dimColor>
739 {' '}
740 {name} · {snap.prs.filter(pr => pr.fullName === name).length} open{' '}
741 </Text>
742 <Button
743 key={`unignore:${name}`}
744 plain
745 label="↺ unignore"
746 onPress={() => guard(() => saveIgnored($, list => list.filter(x => x !== name)))}
747 />
748 </Box>
749 ))}
750 </Box>
751 )}
752 </Box>
753 )
754 })
755}
756hooks/config-screen.tsx 105 lines1import type { ConfigRow, ConfigValue, Elements, EngineInterface, PluginOptions } from 'claude-code'
2
3import { openInEditor } from './run'
4import { guarded, textOption } from './snapshot'
5
6type Deps = {
7 options: PluginOptions
8 // wraps a button label in the buttonStyle option
9 label: (text: string) => string
10 back: () => void
11 openIgnored: () => void
12 openPrompts: () => void
13}
14
15// fields not listed here are Display
16const SECTIONS: Record<string, string[]> = {
17 Display: [],
18 'Hosts and trackers': ['providers', 'tracker', 'trackerUrl', 'githubCommand', 'gitlabCommand', 'giteaCommand', 'azdoCommand'],
19 Commands: ['openCommand', 'editorCommand', 'remoteCommand', 'cloneDir', 'newSessionCommand', 'autoRename', 'stateFile'],
20 Prompts: ['sessionName', 'fablePrompt', 'workPrompt', 'watchPrompt', 'monitorPrompt'],
21}
22const sectionOf = (field: string) => Object.keys(SECTIONS).find(section => SECTIONS[section]?.includes(field)) ?? 'Display'
23
24// The /config rows of this plugin, drawn in the pane. A save reloads the module with the new options.
25// a function declaration, so `claude plugin validate` can follow `$` through the import
26export async function configScreen($: EngineInterface, { Box, Text, Button }: Elements['terminal' | 'desktop' | 'vscode'], { options, label, back, openIgnored, openPrompts }: Deps) {
27 const prefix = `${$.plugin.name}.`
28 const rows = (await $.config.list()).filter(row => row.key.startsWith(prefix))
29
30 const save = async (row: ConfigRow, value: ConfigValue) => {
31 const { deny } = await $.config.set({ key: row.key, value })
32 $.ui.toast(deny ?? `Saved ${row.label}`)
33 }
34 // asks the engine to run the built-in /config; if it refuses, the toast says what to type
35 const openInClaude = () => $.command.run({ command: 'config' }).catch(() => $.ui.toast('Type /config in the prompt'))
36 // the user-level settings file, where pluginConfigs[<plugin>].options is written unless the plugin is set up per project
37 const openSettings = async () => {
38 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
39 const result = await openInEditor($, textOption(options, 'editorCommand'), `${dir}/settings.json`)
40 if (result.exitCode !== 0) $.ui.toast(result.stderr.trim().slice(0, 200) || `command exited ${result.exitCode}`)
41 }
42 // handlers run unawaited, so a rejection from the engine has to end in a toast
43 const guard = (task: () => unknown) => void guarded($, task)
44
45 const control = (row: ConfigRow) => {
46 if (row.isLocked) return <Text dimColor>{String(row.value)} (locked)</Text>
47 if (row.kind === 'boolean') {
48 // keys matter here: without one a Button is addressed by its label, and every row shares these labels
49 return <Button key={row.key} label={label(row.value ? '● on' : '○ off')} variant={row.value ? 'primary' : undefined} onPress={() => guard(() => save(row, !row.value))} />
50 }
51 if (row.kind === 'choice') {
52 return (
53 <Box flexWrap="wrap" columnGap={1}>
54 {(row.options ?? []).map(option => (
55 <Button key={`${row.key}:${option}`} label={option} variant={row.value === option ? 'primary' : undefined} onPress={() => guard(() => save(row, option))} />
56 ))}
57 </Box>
58 )
59 }
60 // the pane's Input does not take typing, so text values are edited in /config or the settings file
61 return (
62 <Box flexWrap="wrap" columnGap={1}>
63 <Text wrap="truncate-end">{String(row.value) || <Text dimColor>empty</Text>}</Text>
64 <Button key={`${row.key}:claude`} plain label={label('⚙ edit in /config')} onPress={() => guard(openInClaude)} />
65 <Button key={`${row.key}:file`} plain label={label('✎ edit in file')} onPress={() => guard(openSettings)} />
66 </Box>
67 )
68 }
69
70 return (
71 <Box flexDirection="column">
72 <Box flexDirection="column" backgroundColor="userMessageBackground" marginTop={1}>
73 <Box justifyContent="center">
74 <Text bold color="claude">PR DECK CONFIG</Text>
75 </Box>
76 <Box justifyContent="center" flexWrap="wrap" columnGap={1}>
77 {/* keyed: with buttonStyle icons the three ✎ labels are the same, and a keyless Button is addressed by its label */}
78 <Button key="back" label={label('← Back')} onPress={back} />
79 <Button key="open-config" label={label('⚙ Open in Claude /config')} onPress={() => guard(openInClaude)} />
80 <Button key="open-settings" label={label('✎ Open file')} onPress={() => guard(openSettings)} />
81 <Button key="open-ignored" plain dimColor label={label('✎ open ignored list')} onPress={openIgnored} />
82 <Button key="open-prompts" plain dimColor label={label('✎ open prompts file')} onPress={openPrompts} />
83 </Box>
84 </Box>
85 {Object.keys(SECTIONS).map(section => {
86 const mine = rows.filter(row => sectionOf(row.key.slice(prefix.length)) === section)
87 return (
88 mine.length > 0 && (
89 <Box key={section} flexDirection="column" marginTop={1}>
90 <Text bold dimColor>{section}</Text>
91 {mine.map(row => (
92 <Box key={row.key} flexDirection="column" marginTop={1}>
93 <Text bold>{row.label}</Text>
94 {row.description && <Text dimColor wrap="truncate-end">{row.description}</Text>}
95 {control(row)}
96 </Box>
97 ))}
98 </Box>
99 )
100 )
101 })}
102 </Box>
103 )
104}
105hooks/jsonc.ts 20 lines1// A string, a // comment or a /* */ comment: the first one found wins, so comment markers inside strings survive.
2const STRING_OR_COMMENT = /"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g
3// A comma right before a closing bracket, or a string (kept as is)
4const STRING_OR_TRAILING_COMMA = /"(?:[^"\\]|\\.)*"|,(\s*[}\]])/g
5
6// Comments become spaces and newlines stay, so a JSON.parse error still points at the right place in the file.
7// A byte order mark, which some Windows editors put first, goes too: JSON.parse rejects it.
8export function stripJsonComments(text: string) {
9 return text
10 .replace(/^/, '')
11 .replace(STRING_OR_COMMENT, match => (match.startsWith('"') ? match : match.replace(/[^\n]/g, ' ')))
12 .replace(STRING_OR_TRAILING_COMMA, (match, tail?: string) => tail ?? match)
13}
14
15// A file with nothing but whitespace and comments is an empty object, not an "Unexpected end of JSON input".
16export function parseJsonc(text: string): unknown {
17 const json = stripJsonComments(text)
18 return json.trim() ? JSON.parse(json) : {}
19}
20hooks/prs.ts 249 lines1import type { ActionState, Pr, Stack, StatePrompts } from '../types'
2import { providerOf } from './providers'
3
4export { summaryOf } from './providers/shared'
5
6// A stack is a chain of PRs where each one's base is the previous one's head.
7// Single PRs come out as stacks of one.
8export function toStacks(prs: Pr[]): Stack[] {
9 const stacks: Stack[] = []
10 // the short repo name is not unique: acme/api and other/api, or a GitLab and a GitHub api
11 const repos = new Map<string, Pr[]>()
12 for (const pr of prs) {
13 const key = `${pr.host}/${pr.fullName}`
14 repos.set(key, [...(repos.get(key) ?? []), pr])
15 }
16
17 for (const mine of repos.values()) {
18 // the default branch is never a parent: a PR from a fork's main would otherwise make every PR based on main a child
19 const isParent = (parent: Pr, pr: Pr) => parent !== pr && parent.head === pr.base && parent.head !== parent.trunk
20 const roots = mine.filter(pr => !mine.some(parent => isParent(parent, pr)))
21 const seen = new Set<Pr>()
22
23 // PRs that point at each other have no root, so they start a stack of their own after the real roots
24 for (const root of [...roots, ...mine]) {
25 if (seen.has(root)) continue
26 const chain: Pr[] = []
27 const queue = [root]
28 seen.add(root)
29 // depth-first walk, a branching stack shows as one flattened chain
30 while (queue.length > 0) {
31 const pr = queue.shift() as Pr
32 chain.push(pr)
33 const children = mine.filter(child => !seen.has(child) && isParent(pr, child))
34 for (const child of children) seen.add(child)
35 queue.unshift(...children)
36 }
37 stacks.push({ repo: root.repo, base: root.base, prs: chain })
38 }
39 }
40
41 return stacks
42}
43
44export type Action = { state: ActionState; label: string; prompt?: string }
45
46// Placeholders are plain text, not shell-quoted like the command ones; unknown ones stay as written.
47export const plain = (template: string, vars: Record<string, string>) => template.replace(/\{(\w+)\}/g, (match, name: string) => (Object.hasOwn(vars, name) ? vars[name] : undefined) ?? match)
48
49// What the prompt and session name templates can use; {number} is an alias of {pr}.
50export const prVars = (pr: Pr): Record<string, string> => ({
51 pr: String(pr.number),
52 number: String(pr.number),
53 url: pr.url,
54 title: pr.title,
55 branch: pr.head,
56 base: pr.base,
57 repo: pr.repo,
58 fullName: pr.fullName,
59 host: pr.host,
60 cli: providerOf(pr.provider).cli,
61})
62
63// Same text as the fablePrompt, workPrompt and watchPrompt defaults in plugin.json, used when an option is blank.
64export const DEFAULT_FABLE_PROMPT = "Spawn a subagent with model fable to review {url} against the repo's own standards. Report findings to me, do not post anything to {host}."
65const DEFAULT_WORK_PROMPT =
66 'Load context for {url}: check out branch {branch} in the {repo} repo, read the PR description, diff, CI status and review comments ({cli} reads them), then wait for my instructions.'
67export const DEFAULT_WATCH_PROMPT =
68 'Watch {url} until it is green: re-check its CI and review state with {cli} every few minutes, waiting in the background between checks, tell me about each change in one line, and stop as soon as CI passes and nothing else needs me.'
69
70// Per state: the button label, when it applies (the order of the keys is the order of the checks in stateOf)
71// and the default next-action prompt. States without a prompt only wait, unless the prompts file gives them one.
72const STATE_INFO: Record<ActionState, { label: string; when: string; next?: string }> = {
73 draft: { label: 'Mark ready', when: 'the PR is a draft', next: 'Mark PR {url} (branch {branch}) as ready for review with {readyHint}.' },
74 'ci-failed': {
75 label: 'Fix CI',
76 when: 'a check failed',
77 next: 'CI is failing on PR {url} (branch {branch}). Find the failing checks ({cli} can list them), fix the cause and push.',
78 },
79 conflicts: {
80 label: 'Rebase',
81 when: 'the PR has merge conflicts',
82 next: 'PR {url} (branch {branch}) has conflicts with {base}. Rebase it, resolve the conflicts and force-push with lease.',
83 },
84 comments: {
85 label: 'Address comments',
86 when: 'changes were requested or review threads are unresolved',
87 next: 'Go through the unresolved review comments on PR {url} (branch {branch}) (read them with {cli}), fix what is valid and draft replies for me to approve.',
88 },
89 mergeable: {
90 label: 'Merge',
91 when: 'approved, no check failing or running, and the PR is alone or at the bottom of its stack',
92 next: 'PR {url} (branch {branch}) is approved and green. Confirm with me, then merge it with {cli}.',
93 },
94 'stack-below': { label: 'Wait for stack below', when: 'approved with no check failing, but a PR below it in the stack is not merged yet' },
95 'ci-running': { label: 'Wait for CI', when: 'checks are still running' },
96 'no-reviewers': {
97 label: 'Request a review',
98 when: 'nobody is asked to review yet',
99 next: 'Request reviews on PR {url} (branch {branch}) with {cli} from the right people or team based on CODEOWNERS and recent history.',
100 },
101 maintainers: { label: 'Wait for maintainers', when: 'nobody is asked to review and you cannot request reviews on the repo' },
102 'in-review': { label: 'Wait for review', when: 'a review is requested and nothing else needs you' },
103}
104
105export const STATES = Object.keys(STATE_INFO) as ActionState[]
106
107// First match wins.
108function stateOf(pr: Pr, isBottom: boolean): ActionState {
109 if (pr.isDraft) return 'draft'
110 if (pr.ci === 'FAILURE' || pr.ci === 'ERROR') return 'ci-failed'
111 if (pr.mergeable === 'CONFLICTING') return 'conflicts'
112 if (pr.review === 'CHANGES_REQUESTED' || pr.unresolved > 0) return 'comments'
113 const isRunning = pr.ci === 'PENDING' || pr.ci === 'EXPECTED'
114 // NONE is a repo without checks or a host that does not report them; failures are handled above
115 if (pr.review === 'APPROVED' && !isRunning) return isBottom ? 'mergeable' : 'stack-below'
116 if (isRunning) return 'ci-running'
117 if (pr.reviewRequests === 0) return pr.canRequestReview ? 'no-reviewers' : 'maintainers'
118 return 'in-review'
119}
120
121// {readyHint} and {state} come on top of the PR placeholders
122const stateVars = (pr: Pr, state: ActionState) => ({ ...prVars(pr), readyHint: providerOf(pr.provider).readyHint, state })
123
124// A next template from the prompts file also turns a wait-only state into a pressable action.
125export function nextAction(pr: Pr, isBottom: boolean, prompts: StatePrompts = {}): Action {
126 const state = stateOf(pr, isBottom)
127 const { label, next } = STATE_INFO[state]
128 const template = prompts[state]?.next?.trim() || next
129 if (!template) return { state, label }
130 return { state, label, prompt: plain(template, stateVars(pr, state)) }
131}
132
133// Reads the parsed prompts file: unknown states and fields that are not text are dropped.
134export function toStatePrompts(parsed: unknown): StatePrompts {
135 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('expected an object keyed by state')
136 const prompts: StatePrompts = {}
137 for (const state of STATES) {
138 const entry: unknown = (parsed as Record<string, unknown>)[state]
139 if (typeof entry !== 'object' || entry === null) continue
140 const { next, work, watch } = entry as Record<string, unknown>
141 prompts[state] = {
142 next: typeof next === 'string' ? next : undefined,
143 work: typeof work === 'string' ? work : undefined,
144 watch: typeof watch === 'string' ? watch : undefined,
145 }
146 }
147 return prompts
148}
149
150// What a new ~/.config/pr-deck.prompts.jsonc starts with: valid as it is ({}), every state as a commented-out block with its defaults.
151export function promptsTemplate(): string {
152 const header = [
153 'Prompts per state for pr-deck. Read by the mod, never written after this file is created.',
154 'Remove the // in front of a block to use it. Comments and trailing commas are fine here.',
155 '',
156 '"next" replaces the prompt of the next-action button in that state.',
157 'A state with no default next prompt only waits; give it one and the wait becomes a button.',
158 '"work" replaces the prompt of the Work on it button and of New session in that state.',
159 'Without it the workPrompt option applies, then the built-in default.',
160 '"watch" replaces the prompt of the Watch button in that state; without it the watchPrompt option applies.',
161 '',
162 'Placeholders: {pr} {number} {url} {title} {branch} {base} {repo} {fullName} {host} {cli} {readyHint} {state}',
163 ]
164 const comment = (line: string) => (line ? `// ${line}` : '//')
165 const blocks = STATES.flatMap(state => {
166 const { next, when } = STATE_INFO[state]
167 const nextLines = next ? [` "next": ${JSON.stringify(next)},`] : []
168 return ['', ...[`${state}: ${when}`, `"${state}": {`, ...nextLines, ` "work": ${JSON.stringify(DEFAULT_WORK_PROMPT)},`, ` "watch": ${JSON.stringify(DEFAULT_WATCH_PROMPT)}`, '},'].map(comment)]
169 })
170 return [...header.map(comment), '{', ...blocks, '}', ''].join('\n')
171}
172
173// The Watch button prompt: the state's watch template, then the watchPrompt option, then the built-in default.
174export const watchPrompt = (pr: Pr, state: ActionState, template?: string, prompts: StatePrompts = {}) =>
175 plain(prompts[state]?.watch?.trim() || template?.trim() || DEFAULT_WATCH_PROMPT, stateVars(pr, state))
176
177// What a PR shows in the pane: its next action and the status the card prints, which a manual pick replaces.
178export type PrStatus = { action: Action; status: string; needsMe: boolean; isManual: boolean }
179
180// The JSON other tools watch, PRs per owner/repo. Statuses lose their pane glyph ("● needs you" is "needs you").
181// Ignored repos stay in, marked, so a watcher can match the pane's count or not.
182export function stateFileOf(prs: Pr[], statusFor: (pr: Pr, isBottom: boolean) => PrStatus, updatedAt: number, error?: string, ignored: string[] = []) {
183 const repos: Record<string, object[]> = {}
184 for (const stack of toStacks(prs)) {
185 for (const [i, pr] of stack.prs.entries()) {
186 const { action, status, needsMe, isManual } = statusFor(pr, i === 0)
187 const shown = status.slice(2)
188 const entry = {
189 number: pr.number,
190 title: pr.title,
191 url: pr.url,
192 branch: pr.head,
193 base: pr.base,
194 provider: pr.provider,
195 host: pr.host,
196 draft: pr.isDraft,
197 ci: pr.ci,
198 review: pr.review,
199 mergeable: pr.mergeable,
200 unresolved: pr.unresolved,
201 state: action.state,
202 status: shown,
203 needsYou: needsMe,
204 ignored: ignored.includes(pr.fullName),
205 nextAction: action.label,
206 override: isManual ? shown : undefined,
207 }
208 repos[pr.fullName] = [...(repos[pr.fullName] ?? []), entry]
209 }
210 }
211 return { updatedAt: new Date(updatedAt).toISOString(), error: error || undefined, repos }
212}
213
214// Same text as the monitorPrompt default in plugin.json, used when the option is blank.
215export const DEFAULT_MONITOR_PROMPT =
216 'Monitor the pr-deck state file at {path}. pr-deck rewrites it on every refresh with my open PRs, grouped by owner/repo, one entry per PR keyed by its url. {prs} Watch the file in the background (the Monitor tool, or a background loop on its modification time), not with sleeps in the foreground. When an entry of those PRs changes its status, ci, review, mergeable, unresolved or nextAction fields, tell me in one line what changed. A PR that is no longer in the file was merged or closed: say so and stop watching it. Stop once none are left.'
217
218// The prompt `/prs monitor` sends. Without picked PRs the session works out which ones it owns, helped by the PRs on its current branch.
219export function monitorPrompt(path: string, picked: Pr[] | undefined, onBranch: Pr[] = [], template = ''): string {
220 const name = (pr: Pr) => `${pr.fullName}#${pr.number} (${pr.url})`
221 const branchHint = onBranch.length > 0 ? `; the current branch is the head of ${onBranch.map(name).join(', ')}` : ''
222 const prs = picked
223 ? `Watch only these PRs: ${picked.map(name).join(', ')}.`
224 : `Watch only the PRs this session owns, the ones it opened or has worked on${branchHint}. If you cannot tell which those are, ask me before you start.`
225 return plain(template.trim() || DEFAULT_MONITOR_PROMPT, { path, prs })
226}
227
228// The third button: once the PR is green and only waiting on people, put Copilot on it
229// (only when the user allows it and the code host has Copilot).
230// The work prompt: the state's work template, then the workPrompt option, then the built-in default.
231export function workAction(pr: Pr, next: Action, allowCopilot: boolean, workTemplate?: string, prompts: StatePrompts = {}): Action {
232 const ref = `PR ${pr.url} (branch ${pr.head})`
233 const provider = providerOf(pr.provider)
234
235 if (allowCopilot && provider.supportsCopilot && pr.ci === 'SUCCESS' && pr.unresolved === 0 && !next.prompt && !pr.hasCopilot) {
236 return {
237 state: next.state,
238 label: '✦ Copilot pass',
239 prompt: `Request a review from Copilot on ${ref} with ${provider.cli} pr edit ${pr.url} --add-reviewer @copilot. Wait for its review to arrive (poll ${provider.cli} pr view --json reviews every minute, give up after 15 minutes), then check out ${pr.head}, go through each Copilot comment, fix the valid ones, push, and reply to and resolve each thread. List what you fixed and what you dismissed and why.`,
240 }
241 }
242
243 return {
244 state: next.state,
245 label: '❯ Work on it',
246 prompt: plain(prompts[next.state]?.work?.trim() || workTemplate?.trim() || DEFAULT_WORK_PROMPT, stateVars(pr, next.state)),
247 }
248}
249hooks/providers/index.ts 75 lines1import type { EngineInterface, PluginOptions } from 'claude-code'
2
3import type { Pr, Provider } from '../../types'
4import { run } from '../run'
5import { azdo } from './azdo'
6import { gitea } from './gitea'
7import { github } from './github'
8import { gitlab } from './gitlab'
9import { TICKET_KEY } from './shared'
10
11export { repoFromRemote } from './shared'
12
13const providers: Provider[] = [github, gitlab, gitea, azdo]
14
15export function providerOf(id: string): Provider {
16 return providers.find(provider => provider.id === id) ?? github
17}
18
19// An option as trimmed text; blank and missing both come back as ''
20const text = (options: PluginOptions, key: string) => String(options[key] ?? '').trim()
21
22async function fetchOne($: EngineInterface, provider: Provider, custom: string): Promise<{ prs: Pr[]; error?: string }> {
23 try {
24 const result = await run($, custom || provider.command, { query: provider.query ?? '' })
25 if (result.exitCode !== 0) return { prs: [], error: `${provider.id}: ${result.stderr.trim()}` }
26 return { prs: provider.parse(result.stdout) }
27 } catch (error) {
28 return { prs: [], error: `${provider.id}: ${error instanceof Error ? error.message : String(error)}` }
29 }
30}
31
32export async function fetchAll($: EngineInterface, options: PluginOptions): Promise<{ prs: Pr[]; errors: string[] }> {
33 const ids = (text(options, 'providers') || 'github').split(',').map(id => id.trim().toLowerCase()).filter(Boolean)
34 const errors: string[] = []
35 const jobs: Promise<{ prs: Pr[]; error?: string }>[] = []
36
37 for (const id of new Set(ids)) {
38 const provider = providers.find(p => p.id === id)
39 if (!provider) {
40 errors.push(`${id}: unknown code host`)
41 continue
42 }
43 jobs.push(fetchOne($, provider, text(options, `${id}Command`)))
44 }
45
46 const prs: Pr[] = []
47 for (const result of await Promise.all(jobs)) {
48 prs.push(...result.prs)
49 if (result.error) errors.push(result.error)
50 }
51
52 return { prs, errors }
53}
54
55const withSlash = (base: string) => (base.endsWith('/') ? base : `${base}/`)
56
57const trackers = [
58 {
59 id: 'jira',
60 // a bare site URL gets /browse/, a full prefix is used as given; the host is not part of the path
61 url: (base: string, key: string) => `${withSlash(base)}${base.replace(/^[\w+.-]+:\/\//, '').includes('/browse') ? '' : 'browse/'}${key}`,
62 },
63 { id: 'linear', url: (base: string, key: string) => `${withSlash(base)}${key}` },
64]
65
66export function ticketUrl(options: PluginOptions, key: string): string | undefined {
67 const base = text(options, 'trackerUrl')
68 if (!base) return undefined
69
70 const tracker = trackers.find(t => t.id === (text(options, 'tracker').toLowerCase() || 'jira'))
71 if (!tracker || !TICKET_KEY.test(key)) return undefined
72
73 return tracker.url(base, key)
74}
75hooks/run.ts 30 lines1import type { EngineInterface, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3// Every external command in the mod is a shell template with {name} placeholders,
4// so users can swap any of them from /config. Values are single-quoted before they go in.
5export const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`
6
7// own keys only: `in` also matches inherited names, so {constructor} would crash quote() on a function
8export const fill = (template: string, vars: Record<string, string>) =>
9 template.replace(/\{(\w+)\}/g, (match, name: string) => (Object.hasOwn(vars, name) ? quote(vars[name] ?? '') : match))
10
11// $.process.run rejects when the command outlives its 30 s budget; that comes back as a failed result, so callers that toast stderr show it
12// a function declaration, so `claude plugin validate` can follow `$` through the import
13export async function run($: EngineInterface, template: string, vars: Record<string, string> = {}, init?: ProcessRunInit): Promise<ProcessRunResult> {
14 try {
15 return await $.process.run(['sh', '-c', fill(template, vars)], init)
16 } catch (error) {
17 return { exitCode: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), isStdoutTruncated: false, isStderrTruncated: false }
18 }
19}
20
21// the system's own opener: xdg-open on Linux, the default text editor on macOS
22const SYSTEM_OPEN = 'if command -v xdg-open >/dev/null; then xdg-open {path}; else open -t {path}; fi'
23
24// A terminal editor (vim, nano) has no terminal here and hangs until killed, so a command still running after a few seconds counts as failed, like one that errors; both fall back to the system opener.
25export async function openInEditor($: EngineInterface, template: string, path: string): Promise<ProcessRunResult> {
26 const result = await run($, template, { path }, { stdin: '', timeoutMs: 5000 })
27 if (result.exitCode === 0) return result
28 return run($, SYSTEM_OPEN, { path })
29}
30hooks/snapshot.ts 109 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Pr } from '../types'
4
5// Runs a handler that nobody awaits (a button press, a timer); a rejection ends in a toast instead of vanishing.
6export async function guarded($: Pick<EngineInterface, 'ui'>, task: () => unknown): Promise<void> {
7 try {
8 await task()
9 } catch (error) {
10 $.ui.toast(`pr-deck: ${error instanceof Error ? error.message : String(error)}`)
11 }
12}
13
14// Cuts text to max characters with an ellipsis; a max below 1 still gives the ellipsis, not a slice from the end.
15export function short(text: string, max: number): string {
16 const room = Math.max(1, max)
17 return text.length > room ? `${text.slice(0, room - 1)}…` : text
18}
19
20// A host that failed this round keeps its last PRs on screen; dropping them would also prune their manual statuses on the next save.
21// Errors start with the provider id, see fetchAll.
22export function keepFailedHosts(fresh: Pr[], old: Pr[], errors: string[]): Pr[] {
23 const kept = old.filter(pr => errors.some(error => error.startsWith(`${pr.provider}:`)))
24 return [...fresh, ...kept]
25}
26
27// owner is everything before the last "/"; a name without one (Azure DevOps falls back to the bare repo name) has no owner
28export function splitFullName(fullName: string): { owner: string; repo: string } {
29 const cut = fullName.lastIndexOf('/')
30 return { owner: cut < 0 ? '' : fullName.slice(0, cut), repo: fullName.slice(cut + 1) }
31}
32
33// What a repo section is called: the short name, or host/owner/repo for two repos that share one (acme/api and other/api), so their PRs never mix.
34export function repoKeyOf(prs: Pr[]): (pr: Pr) => string {
35 const full = new Map<string, Set<string>>()
36 for (const pr of prs) full.set(pr.repo, (full.get(pr.repo) ?? new Set<string>()).add(`${pr.host}/${pr.fullName}`))
37 return pr => ((full.get(pr.repo)?.size ?? 0) > 1 ? `${pr.host}/${pr.fullName}` : pr.repo)
38}
39
40// A session name goes unquoted into `claude -n <name>`, so anything outside [A-Za-z0-9._-] becomes '-' and a leading '-' is dropped, or claude reads it as a flag.
41export const safeSessionName = (name: string) => name.replace(/[^\w.-]+/g, '-').replace(/^-+/, '')
42
43// The PR `/prs watch <ref>` names: its URL, owner/repo#number, or a bare number, which prefers the session's repo when several repos have one.
44// A string is the reason nothing matched.
45export function findPr(prs: Pr[], ref: string, project?: string): Pr | string {
46 const text = ref.trim().replace(/^#/, '')
47 if (!text) return 'Which PR? Pass its number, owner/repo#number or URL.'
48 // a URL copied from a tab or a notification, like .../pull/7/files, .../pull/7#discussion or .../pull/7?notification_referrer_id=1, is still that PR
49 const byUrl = prs.find(pr => text === pr.url || ['/', '#', '?'].some(separator => text.startsWith(`${pr.url}${separator}`)))
50 if (byUrl) return byUrl
51 const match = /^(?:(.+)#)?(\d+)$/.exec(text)
52 if (!match) return `${text} is not a PR number, owner/repo#number or URL.`
53 const [, repo, number] = match
54 const found = prs.filter(pr => pr.number === Number(number) && (!repo || pr.fullName === repo.toLowerCase()))
55 const here = found.filter(pr => pr.fullName === project)
56 const [only] = found.length === 1 ? found : here.length === 1 ? here : []
57 if (only) return only
58 if (found.length === 0) return `None of your open PRs is ${text}.`
59 return `#${number} is open in ${found.map(pr => pr.fullName).join(', ')}, pass owner/repo#${number}.`
60}
61
62// The PRs `/prs monitor <filter>` names: all of them, every PR of an owner/repo, or one PR as findPr reads it.
63// A string is the reason nothing matched.
64export function selectPrs(prs: Pr[], filter: string, project?: string): Pr[] | string {
65 const text = filter.trim()
66 if (text.toLowerCase() === 'all') return prs.length > 0 ? prs : 'You have no open PRs.'
67 if (/^[^\s#:]+\/[^\s#:]+$/.test(text) && !/^\d+$/.test(text)) {
68 const inRepo = prs.filter(pr => pr.fullName === text.toLowerCase())
69 if (inRepo.length > 0) return inRepo
70 }
71 const pr = findPr(prs, text, project)
72 return typeof pr === 'string' ? pr : [pr]
73}
74
75// A text field can be stored as "" (an install dialog that shows it empty, a cleared /config row), which hides its manifest default,
76// so a blank one falls back here. Keep in sync with the defaults in plugin.json.
77const TEXT_DEFAULTS: Record<string, string> = {
78 openCommand: 'open {url}',
79 editorCommand: 'open -t {path}',
80 remoteCommand: 'git remote get-url origin',
81 sessionName: 'pr-{pr}-worker',
82 stateFile: '~/.cache/pr-deck/state.json',
83}
84
85export function textOption(options: Readonly<Record<string, unknown>>, key: string): string {
86 const value = options[key]
87 return (typeof value === 'string' ? value.trim() : '') || TEXT_DEFAULTS[key] || ''
88}
89
90export type StoredConfig = { ignored: string[]; overrides: Record<string, string> }
91
92// undefined when the text is not a JSON object, so a half-edited file never reads as an empty config.
93export function parseConfig(text: string, statuses: string[]): StoredConfig | undefined {
94 try {
95 const parsed: unknown = JSON.parse(text)
96 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
97 const { ignored, overrides } = parsed as { ignored?: unknown; overrides?: unknown }
98 const list = Array.isArray(ignored) ? ignored.filter((x): x is string => typeof x === 'string') : []
99 const stored = typeof overrides === 'object' && overrides !== null ? Object.entries(overrides) : []
100 return {
101 // a repo listed twice (or in two cases) would draw two rows with the same key
102 ignored: [...new Set(list.map(x => x.toLowerCase()))],
103 overrides: Object.fromEntries(stored.filter(([, status]) => statuses.includes(status))),
104 }
105 } catch {
106 return undefined
107 }
108}
109hooks/terminals.ts 69 lines1import { quote } from './run'
2
3// Where the session itself runs, read from its environment by register.tsx.
4export type TerminalEnv = { program?: string; bundleId?: string; tmux?: string; zellij?: string; kittyWindow?: string; alacrittyWindow?: string }
5type Terminal = { name: string; test: (env: TerminalEnv) => boolean; command: string }
6
7// Shell templates for opening a new Claude session, filled by run().
8// {claude} is replaced before fill with `claude` or `claude -n <session name>`; the prompt follows `--`
9// so one that starts with a dash is not read as a claude flag.
10// {dir} {prompt} {label} are single-quoted by fill; {shellPrompt} is already quoted for the
11// terminal's own shell, so it comes out quoted twice and survives being typed into a pane.
12const osascript = (...lines: string[]) => `osascript ${['on run argv', ...lines, 'end run'].map(line => `-e ${quote(line)}`).join(' ')} {dir} {prompt}`
13
14// Multiplexers come before terminal apps, first match wins; the last entry matches anything.
15export const TERMINALS: Terminal[] = [
16 // waits for the first shell prompt, then a second, because typing during shell startup garbles the command; needs jq
17 {
18 name: 'a herdr tab',
19 test: env => env.program === 'herdr',
20 command:
21 "pane=$(herdr tab create --cwd {dir} --label {label} --focus | jq -r .result.root_pane.pane_id) && { herdr pane wait-output \"$pane\" --regex '[❯$%#>]\\s*$' --timeout 10000 >/dev/null; sleep 1; herdr pane run \"$pane\" {claude} -- {shellPrompt}; }",
22 },
23 { name: 'a tmux window', test: env => Boolean(env.tmux), command: 'tmux new-window -c {dir} -n {label} {claude} -- {prompt}' },
24 { name: 'a zellij tab', test: env => Boolean(env.zellij), command: 'zellij action new-tab --cwd {dir} --name {label} -- {claude} -- {prompt}' },
25 { name: 'a WezTerm tab', test: env => env.program === 'WezTerm', command: 'wezterm cli spawn --cwd {dir} -- {claude} -- {prompt}' },
26 // kitty needs allow_remote_control (or listen_on) in kitty.conf; without it the toast shows kitty's error
27 { name: 'a kitty tab', test: env => Boolean(env.kittyWindow), command: 'kitty @ launch --type=tab --cwd={dir} --tab-title={label} {claude} -- {prompt}' },
28 // Alacritty has no tabs, so a new window through its IPC socket
29 {
30 name: 'an Alacritty window',
31 test: env => Boolean(env.alacrittyWindow),
32 command: 'alacritty msg create-window --working-directory {dir} -e {claude} -- {prompt}',
33 },
34 // always a new Rio instance, like Ghostty; flags written from Rio's docs
35 // the prompt travels in an environment variable: the login shell is the user's own, and fish gives -c arguments to $argv with no $0
36 {
37 name: 'Rio',
38 test: env => env.program === 'rio',
39 command: 'open -na Rio --env PR_DECK_PROMPT={prompt} --args --working-dir {dir} -e "${SHELL:-/bin/sh}" -lic \'exec {claude} -- "$PR_DECK_PROMPT"\'',
40 },
41 // always a new Ghostty instance, since the macOS CLI cannot add a window to a running one; a login shell finds claude on PATH
42 {
43 name: 'Ghostty',
44 test: env => env.bundleId === 'com.mitchellh.ghostty' || env.program === 'ghostty',
45 command: 'open -na Ghostty --env PR_DECK_PROMPT={prompt} --args --working-directory={dir} -e "${SHELL:-/bin/sh}" -lic \'exec {claude} -- "$PR_DECK_PROMPT"\'',
46 },
47 {
48 name: 'iTerm',
49 test: env => env.program === 'iTerm.app',
50 command: osascript(
51 'tell application "iTerm"',
52 'activate',
53 'set w to (create window with default profile)',
54 'tell current session of w to write text "cd " & quoted form of item 1 of argv & " && {claude} -- " & quoted form of item 2 of argv',
55 'end tell',
56 ),
57 },
58 {
59 name: 'Terminal',
60 test: () => true,
61 command: osascript(
62 'tell application "Terminal"',
63 'activate',
64 'do script "cd " & quoted form of item 1 of argv & " && {claude} -- " & quoted form of item 2 of argv',
65 'end tell',
66 ),
67 },
68]
69hooks/providers/shared.ts 92 lines1// Ticket keys look like ABC-123 in both Jira and Linear. A number followed by .5 or -5 is a version or
2// an id (lodash-4.17.21, CVE-2024-1234), not a key; ENG-1-2fa-login still is one.
3// An underscore ends a key like any other separator (ENG-123_fix), which \b does not do; the key is group 1.
4export const TICKET_KEY = /(?:^|[^A-Za-z0-9])([A-Z][A-Z0-9]+-\d+)(?![A-Za-z0-9])(?!\.\d|-\d+\b)/i
5
6export function ticketKey(text: string): string | undefined {
7 return text.match(TICKET_KEY)?.[1]?.toUpperCase()
8}
9
10const TICKET_KEYS = new RegExp(TICKET_KEY.source, 'gi')
11// markdown links keep their text; emphasis, code ticks and list or quote markers go
12const plainText = (line: string) =>
13 line
14 .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
15 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
16 // underscores only as emphasis, so snake_case names stay whole
17 .replace(/[*`]+|\b_+|_+\b/g, '')
18 .replace(/^\s*(?:[-*+>]|\d+\.)\s+/, '')
19 .trim()
20
21// the first sentence of the first prose line; a model summary would read better but costs a call per PR.
22// Skips headings ("## Summary"), fences, tables, link references and ticket-only lines ("ABC-1, part of ABC-2.").
23// Parsers keep the whole sentence; the pane cuts it to the summaryLength option.
24export function summaryOf(body: string, max = Infinity): string {
25 const isProse = (raw: string) => {
26 // "#42 follow-up" is prose, "## Summary" a heading; "- [ ] task" is a template checklist
27 if (/^\s*(?:#{1,6}(?:\s|$)|```|\||\[[^\]]+\]:|[-*+]\s+\[[ xX]\](?:\s|$)|(?:<\/?[a-z][^>]*>\s*)+$)/i.test(raw)) return false
28 return plainText(raw).replace(TICKET_KEYS, '').split(/\s+/).filter(word => /\w{2,}/.test(word)).length >= 3
29 }
30 // PR templates keep their instructions in HTML comments, often over several lines
31 const lines = body.replace(/<!--[\s\S]*?(?:-->|$)/g, '').split('\n')
32 const line = plainText(lines.find(isProse) ?? '')
33 const sentence = line.match(/^.+?[.!?](?=\s|$)/)?.[0] ?? line
34 return sentence.length > max ? `${sentence.slice(0, max - 1).trimEnd()}…` : sentence
35}
36
37// What a host that does not report CI, review threads, line counts or permissions leaves at its neutral value.
38// canRequestReview stays true because the permission is unknown, so the review request is still offered.
39export const UNKNOWN = {
40 review: 'NONE',
41 ci: 'NONE',
42 checksPassed: 0,
43 checksTotal: 0,
44 canRequestReview: true,
45 hasCopilot: false,
46 unresolved: 0,
47 changedFiles: 0,
48 additions: 0,
49 deletions: 0,
50}
51
52// hostname of an http(s) URL; the engine has no URL global
53export function hostOf(url: string): string {
54 return url.match(/^https?:\/\/(?:[^@/]+@)?([^/:]+)/i)?.[1]?.toLowerCase() ?? ''
55}
56
57// Azure DevOps remotes: https://dev.azure.com/org/project/_git/repo, git@ssh.dev.azure.com:v3/org/project/repo and the
58// legacy https://org.visualstudio.com/[DefaultCollection/]project/_git/repo and org@vs-ssh.visualstudio.com:v3/org/project/repo.
59// A repo named like its project has a short https form without the project (.../org/_git/repo).
60const AZURE_REMOTE = /^(?:[\w+.-]+:\/\/)?(?:[^@/]+@)?((?:ssh\.)?dev\.azure\.com|vs-ssh\.visualstudio\.com|([^/:@.]+)\.visualstudio\.com)[:/](.+)$/i
61
62// org/project/repo, or undefined when the URL is not an Azure DevOps one
63function azureRepo(clean: string): string | undefined {
64 const match = clean.match(AZURE_REMOTE)
65 if (!match) return undefined
66 const [, host = '', legacyOrg, path = ''] = match
67 const segments = path.split('/')
68
69 if (/^(?:vs-)?ssh\./i.test(host)) return segments.length === 4 && segments[0] === 'v3' ? segments.slice(1).join('/').toLowerCase() : undefined
70
71 const git = segments.indexOf('_git')
72 const repo = segments[git + 1]
73 if (git < 0 || !repo) return undefined
74 const before = segments.slice(0, git).filter(segment => segment.toLowerCase() !== 'defaultcollection')
75 const [org, project] = legacyOrg ? [legacyOrg, before[0]] : before
76 if (!org) return undefined
77 return [org, project ?? repo, repo].join('/').toLowerCase()
78}
79
80// owner/repo from a git remote URL, ssh or https. GitLab subgroups keep the whole path
81// (group/sub/repo); Azure DevOps gives org/project/repo.
82export function repoFromRemote(url: string): string | undefined {
83 const clean = url.trim().replace(/\/$/, '').replace(/\.git$/, '')
84
85 const azure = azureRepo(clean)
86 if (azure) return azure
87
88 const path = clean.match(/^(?:[\w+.-]+:\/\/)?(?:[^@/]+@)?[^/:]+(?::\d+(?=\/))?[:/](.+)$/)?.[1]
89 if (!path?.includes('/')) return undefined
90 return path.toLowerCase()
91}
92hooks/providers/azdo.ts 55 lines1import type { Pr, Provider } from '../../types'
2import { hostOf, repoFromRemote, summaryOf, ticketKey, UNKNOWN } from './shared'
3
4const branchOf = (ref: string) => ref.replace(/^refs\/heads\//, '')
5
6// Reviewer votes: 10 approved, 5 approved with suggestions, 0 none, -5 waiting for author, -10 rejected
7// any negative vote asks for changes, otherwise any positive vote approves; branch policies are not read
8function reviewOf(votes: number[]): string {
9 if (votes.some(vote => vote < 0)) return 'CHANGES_REQUESTED'
10 if (votes.some(vote => vote > 0)) return 'APPROVED'
11 return 'NONE'
12}
13
14const MERGEABLE: Record<string, string> = { succeeded: 'MERGEABLE', conflicts: 'CONFLICTING' }
15
16// raw az objects as-is, az is the trust boundary. Build results and comment threads live
17// behind other calls, so the rest stays UNKNOWN
18function toPr(node: any): Pr {
19 const repository = node.repository
20 const head = branchOf(node.sourceRefName)
21 const votes: number[] = node.reviewers.map((r: any) => r.vote)
22
23 return {
24 provider: 'azdo',
25 host: hostOf(repository.webUrl),
26 number: node.pullRequestId,
27 title: node.title,
28 url: `${repository.webUrl}/pullrequest/${node.pullRequestId}`,
29 repo: repository.name,
30 // webUrl can carry the project id instead of its name; remoteUrl is the clone URL, the same shape as the git remote
31 fullName: repoFromRemote(repository.remoteUrl ?? repository.webUrl) ?? repository.name.toLowerCase(),
32 trunk: branchOf(repository.defaultBranch ?? 'refs/heads/main'),
33 head,
34 base: branchOf(node.targetRefName),
35 isDraft: node.isDraft === true,
36 mergeable: MERGEABLE[node.mergeStatus] ?? 'UNKNOWN',
37 reviewRequests: votes.filter(vote => vote === 0).length,
38 ...UNKNOWN,
39 review: reviewOf(votes),
40 ticket: ticketKey(`${node.title} ${head}`),
41 summary: summaryOf(node.description ?? ''),
42 }
43}
44
45// az lists one project's PRs (the one it detects from the git remote or `az devops configure`);
46// override azdoCommand to cover more projects. --creator takes the signed-in account's name
47export const azdo: Provider = {
48 id: 'azdo',
49 cli: 'az repos',
50 command: 'az repos pr list --status active --top 50 --creator "$(az account show --query user.name -o tsv)" -o json',
51 parse: stdout => JSON.parse(stdout).map(toPr),
52 readyHint: 'az repos pr update --draft false --id <the PR number>',
53 supportsCopilot: false,
54}
55hooks/providers/gitea.ts 52 lines1import type { Pr, Provider } from '../../types'
2import { hostOf, summaryOf, ticketKey, UNKNOWN } from './shared'
3
4// Forgejo and Gitea mark drafts with a title prefix, and newer versions also set `draft`
5const DRAFT_PREFIX = /^(\[(wip|draft)\]|(wip|draft):)/i
6
7// raw API objects as-is, tea is the trust boundary. Review threads and CI are separate
8// endpoints per PR, so they stay UNKNOWN
9function toPr(node: any): Pr {
10 const base = node.base.repo
11
12 return {
13 provider: 'gitea',
14 host: hostOf(node.html_url),
15 number: node.number,
16 title: node.title,
17 url: node.html_url,
18 repo: base.name,
19 fullName: base.full_name.toLowerCase(),
20 trunk: base.default_branch ?? 'main',
21 head: node.head.ref,
22 base: node.base.ref,
23 isDraft: node.draft === true || DRAFT_PREFIX.test(node.title),
24 mergeable: node.mergeable === false ? 'CONFLICTING' : 'MERGEABLE',
25 reviewRequests: (node.requested_reviewers ?? []).length,
26 ...UNKNOWN,
27 changedFiles: node.changed_files ?? 0,
28 additions: node.additions ?? 0,
29 deletions: node.deletions ?? 0,
30 ticket: ticketKey(`${node.title} ${node.head.ref}`),
31 summary: summaryOf(node.body ?? ''),
32 }
33}
34
35// the issue search lists my PRs across repos but without branches, so each one is fetched
36// from its pull endpoint and jq slurps the objects into one array; needs jq next to tea.
37// Each step is its own assignment so a failing tea or jq stops the script under any POSIX sh
38// (no pipefail), and printf keeps dash's echo from expanding the \n inside the JSON.
39export const gitea: Provider = {
40 id: 'gitea',
41 cli: 'tea',
42 command: [
43 String.raw`list=$(tea api '/repos/issues/search?type=pulls&state=open&created=true&limit=50') || exit 1`,
44 String.raw`paths=$(printf '%s\n' "$list" | jq -r '.[] | "\(.repository.full_name)/pulls/\(.number)"') || exit 1`,
45 String.raw`out=$(for path in $paths; do tea api "/repos/$path" || exit 1; done) || exit 1`,
46 String.raw`printf '%s\n' "$out" | jq -s .`,
47 ].join('\n'),
48 parse: stdout => JSON.parse(stdout).map(toPr),
49 readyHint: 'tea api -X PATCH on the pull request, sending its title without the WIP: or Draft: prefix',
50 supportsCopilot: false,
51}
52hooks/providers/github.ts 82 lines1import type { Pr, Provider } from '../../types'
2import { hostOf, summaryOf, ticketKey } from './shared'
3
4const query = `{viewer{pullRequests(first:50,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{
5 number title bodyText url isDraft headRefName baseRefName reviewDecision mergeable changedFiles additions deletions
6 repository{name nameWithOwner viewerPermission defaultBranchRef{name}}
7 reviewRequests(first:20){totalCount nodes{requestedReviewer{... on Bot{login} ... on User{login}}}}
8 reviews(first:50){nodes{author{login}}}
9 latestOpinionatedReviews(first:20){nodes{state}}
10 reviewThreads(first:100){nodes{isResolved}}
11 commits(last:1){nodes{commit{statusCheckRollup{state contexts(first:100){nodes{
12 __typename ... on CheckRun{status conclusion} ... on StatusContext{state}
13 }}}}}}
14}}}}`
15
16const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
17// READ (or null) cannot request reviews, e.g. a PR from a fork into someone else's repo
18const CAN_REQUEST_REVIEW = new Set(['ADMIN', 'MAINTAIN', 'WRITE', 'TRIAGE'])
19
20type Context = { __typename: string; status?: string; conclusion?: string | null; state?: string }
21
22// reviewDecision is null when the repo does not require reviews, so the latest review of each person decides then
23function reviewOf(node: any): string {
24 if (node.reviewDecision) return node.reviewDecision
25 const states: string[] = (node.latestOpinionatedReviews?.nodes ?? []).map((r: { state: string }) => r.state)
26 if (states.includes('CHANGES_REQUESTED')) return 'CHANGES_REQUESTED'
27 if (states.includes('APPROVED')) return 'APPROVED'
28 return 'NONE'
29}
30
31// takes the raw GraphQL nodes as-is, no schema validation; gh is the trust boundary
32function toPr(node: any): Pr {
33 const rollup = node.commits.nodes[0]?.commit.statusCheckRollup
34 const contexts: Context[] = rollup?.contexts.nodes ?? []
35 const passed = contexts.filter(c =>
36 c.__typename === 'CheckRun' ? PASSED.has(c.conclusion ?? '') : c.state === 'SUCCESS',
37 ).length
38 const reviewers: string[] = [
39 ...node.reviews.nodes.map((r: any) => r.author?.login ?? ''),
40 ...node.reviewRequests.nodes.map((r: any) => r.requestedReviewer?.login ?? ''),
41 ]
42
43 return {
44 provider: 'github',
45 host: hostOf(node.url),
46 number: node.number,
47 title: node.title,
48 url: node.url,
49 repo: node.repository.name,
50 fullName: node.repository.nameWithOwner.toLowerCase(),
51 trunk: node.repository.defaultBranchRef?.name ?? 'main',
52 head: node.headRefName,
53 base: node.baseRefName,
54 isDraft: node.isDraft,
55 review: reviewOf(node),
56 mergeable: node.mergeable,
57 ci: rollup?.state ?? 'NONE',
58 checksPassed: passed,
59 checksTotal: contexts.length,
60 reviewRequests: node.reviewRequests.totalCount,
61 canRequestReview: CAN_REQUEST_REVIEW.has(node.repository.viewerPermission),
62 // login is copilot-pull-request-reviewer once it reviewed
63 hasCopilot: reviewers.some(login => login.toLowerCase().includes('copilot')),
64 unresolved: node.reviewThreads.nodes.filter((t: { isResolved: boolean }) => !t.isResolved).length,
65 changedFiles: node.changedFiles,
66 additions: node.additions,
67 deletions: node.deletions,
68 ticket: ticketKey(`${node.title} ${node.headRefName}`),
69 summary: summaryOf(node.bodyText ?? ''),
70 }
71}
72
73export const github: Provider = {
74 id: 'github',
75 cli: 'gh',
76 command: 'gh api graphql -f query={query}',
77 query,
78 parse: stdout => JSON.parse(stdout).data.viewer.pullRequests.nodes.map(toPr),
79 readyHint: 'gh pr ready',
80 supportsCopilot: true,
81}
82