SLOPSHOPPER

pr-viewer

The current branch's GitHub pull request (CI, review) in the band above the prompt, published as state

newbandguardprocess
v0.1.0no licenseupdated 2026-10-09djavrell/dotfile/claude/mods/pr-viewer
A shopper browsing a rack in a slop shop
README

pr-viewer

Shows the GitHub pull request of the current branch as one line in the band above the prompt, with its state, CI status and review status. Its icon is colored by the pull request state (open, draft, merged, closed). When the branch has no pull request, nothing is shown. The mod only reads the pull request, it never changes it.

Requires gh installed and logged in (gh auth login).

Example

The band looks like this:

* #123 Add the band   ✓ CI   ○ review required
Source 3 files
hooks/register.tsx 121 lines
1// The current branch's pull request: fetched with gh, published as the `current` state, drawn as one line in
2// the band above the prompt. No pull request (or no gh), no band.
3// $ and the atom stay in this file (the validator does not follow them across an import); parsing lives in lib/pr.ts.
4import { atom, read, update } from "claude-code";
5import type { EngineInterface, Register } from "claude-code";
6
7import type { PullRequest } from "../types";
8import { PR_FIELDS, parsePullRequest } from "./lib/pr";
9
10const current = atom({ plugin: "pr-viewer", key: "current" } as const, null);
11
12// ponytail: polls GitHub once a minute; a webhook-fed source if the delay ever matters
13const PR_POLL_MS = 60_000;
14const PR_ICON = ""; // nf-oct-git_pull_request
15const MERGED_ICON = ""; // nf-oct-git_merge
16
17// the commit checked out, to refetch as soon as a Bash call switched branches
18let lastHead = "";
19// bumped by every fetch: a run that is no longer the latest drops its result
20let fetchGeneration = 0;
21
22async function fetchPullRequest($: EngineInterface) {
23  const thisGeneration = ++fetchGeneration;
24  const view = await $.process.run(["gh", "pr", "view", "--json", PR_FIELDS]);
25  if (thisGeneration !== fetchGeneration) return;
26  const pullRequest = view.exitCode === 0 ? parsePullRequest(view.stdout) : null;
27  await update($, current, () => pullRequest);
28}
29
30async function refetchOnSwitch($: EngineInterface) {
31  const head = await $.process.run(["git", "--no-optional-locks", "symbolic-ref", "-q", "HEAD"]);
32  const headName = head.stdout.trim();
33  if (headName === lastHead) return;
34  lastHead = headName;
35  await fetchPullRequest($);
36}
37
38async function poller($: EngineInterface) {
39  for (;;) {
40    await $.clock.sleep(PR_POLL_MS);
41    await fetchPullRequest($).catch(() => {});
42  }
43}
44
45// $.palette is absent for a moment when this mod draws before palette has loaded: default colors then
46async function getTheme($: EngineInterface) {
47  try {
48    return await $.palette.get();
49  } catch {
50    return undefined;
51  }
52}
53
54export const register: Register = (on) => {
55  on("session.start", async ($, event, next) => {
56    void refetchOnSwitch($).catch(() => {});
57    void poller($).catch(() => {});
58
59    return next(event);
60  });
61
62  // a checkout or a switch goes through Bash
63  on("tool.call", async ($, event, next) => {
64    const result = await next(event);
65    if (event.tool === "Bash") void refetchOnSwitch($).catch(() => {});
66
67    return result;
68  });
69
70  on("ui.render", { component: "AbovePrompt" }, async ($, event, next) => {
71    // a survey holds the band first
72    if (event.props.hasSurvey) return next(event);
73    const [pullRequest, theme] = await Promise.all([read($, current), getTheme($)]);
74    if (!pullRequest) return next(event);
75    const { Box, Text } = $.ui.resolve(event);
76    const ansi = theme?.ansi;
77    const stateColor: Record<PullRequest["state"], string | undefined> = {
78      open: ansi?.green,
79      draft: theme?.comment,
80      merged: ansi?.magenta,
81      closed: ansi?.red,
82    };
83    const checks = {
84      pass: { mark: "✓ CI", color: ansi?.green },
85      fail: { mark: "✗ CI", color: ansi?.red },
86      pending: { mark: "● CI", color: ansi?.yellow },
87      none: null,
88    }[pullRequest.checks];
89    const review = {
90      approved: { mark: "✓ approved", color: ansi?.green },
91      changes_requested: { mark: "✗ changes requested", color: ansi?.red },
92      required: { mark: "○ review required", color: ansi?.yellow },
93      none: null,
94    }[pullRequest.review];
95
96    return (
97      <Box flexDirection="row" width={event.props.bodyColumns} columnGap={2}>
98        <Box flexShrink={1}>
99          <Text wrap="truncate-end">
100            <Text color={stateColor[pullRequest.state]}>
101              {pullRequest.state === "merged" ? MERGED_ICON : PR_ICON} #{pullRequest.number}
102              {pullRequest.state === "open" ? "" : ` ${pullRequest.state}`}
103            </Text>{" "}
104            {pullRequest.title}
105          </Text>
106        </Box>
107        {checks && (
108          <Box flexShrink={0}>
109            <Text color={checks.color}>{checks.mark}</Text>
110          </Box>
111        )}
112        {review && (
113          <Box flexShrink={0}>
114            <Text color={review.color}>{review.mark}</Text>
115          </Box>
116        )}
117      </Box>
118    );
119  });
120};
121
hooks/lib/pr.ts 71 lines
1import type { PullRequest } from "../../types";
2
3export const PR_FIELDS = "number,title,state,isDraft,reviewDecision,statusCheckRollup";
4
5// one entry of gh's statusCheckRollup: a GitHub Actions run, or a commit status from another CI
6type RollupItem = {
7  __typename?: string;
8  status?: string;
9  conclusion?: string;
10  state?: string;
11};
12
13const FAILED = new Set([
14  "FAILURE",
15  "ERROR",
16  "TIMED_OUT",
17  "CANCELLED",
18  "ACTION_REQUIRED",
19  "STARTUP_FAILURE",
20]);
21
22const checkOf = (item: RollupItem): PullRequest["checks"] => {
23  // a CheckRun is pending until COMPLETED, then judged by its conclusion; a StatusContext by its state
24  const outcome =
25    item.__typename === "StatusContext"
26      ? item.state
27      : item.status === "COMPLETED"
28        ? item.conclusion
29        : "PENDING";
30  if (outcome && FAILED.has(outcome)) return "fail";
31  if (outcome === "PENDING" || outcome === "EXPECTED") return "pending";
32  return "pass";
33};
34
35export function rollupChecks(items: readonly RollupItem[]): PullRequest["checks"] {
36  const checks = items.map(checkOf);
37  if (checks.includes("fail")) return "fail";
38  if (checks.includes("pending")) return "pending";
39  return checks.length ? "pass" : "none";
40}
41
42const REVIEW: Record<string, PullRequest["review"]> = {
43  APPROVED: "approved",
44  CHANGES_REQUESTED: "changes_requested",
45  REVIEW_REQUIRED: "required",
46};
47
48// `gh pr view --json PR_FIELDS` output; null when it is not one
49export function parsePullRequest(json: string): PullRequest | null {
50  try {
51    const raw = JSON.parse(json);
52    if (typeof raw?.number !== "number") return null;
53    return {
54      number: raw.number,
55      title: String(raw.title ?? ""),
56      state:
57        raw.state === "MERGED"
58          ? "merged"
59          : raw.state === "CLOSED"
60            ? "closed"
61            : raw.isDraft
62              ? "draft"
63              : "open",
64      checks: rollupChecks(Array.isArray(raw.statusCheckRollup) ? raw.statusCheckRollup : []),
65      review: REVIEW[raw.reviewDecision] ?? "none",
66    };
67  } catch {
68    return null;
69  }
70}
71
types/index.d.ts 23 lines
1// The current branch's pull request as this mod publishes it. Consumers list "pr-viewer" under
2// `dependencies` in their plugin.json, import this type from "pr-viewer", and read it with
3//   read($, atom({ plugin: "pr-viewer", key: "current" } as const, null))
4export type PullRequest = {
5  number: number
6  title: string
7  // open, draft, merged, closed
8  state: 'open' | 'draft' | 'merged' | 'closed'
9  // the checks rolled up: one failing fails all, one running keeps it pending; none when it has no checks
10  checks: 'pass' | 'fail' | 'pending' | 'none'
11  // GitHub's reviewDecision; none when the repo asks for no review
12  review: 'approved' | 'changes_requested' | 'required' | 'none'
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'pr-viewer': {
18      // null when the branch has no pull request, or gh cannot tell (not installed, logged out, not GitHub)
19      current: PullRequest | null
20    }
21  }
22}
23