A macOS menu bar inbox for GitHub notifications that shows only what needs you. Caton joins each notification to the live state of its pull request or issue, clears what no longer needs anyone, and files the rest into four splits: Needs me · Team · Following · Feed. The number in the menu bar is the number of things waiting on you.
The product thinking is in PRD.md.
brew install --cask shergin/tap/catonCaton needs macOS 26 on Apple silicon. It is not notarized, so macOS blocks
its first launch: open System Settings › Privacy & Security and click Open
Anyway, once. Updates install with brew upgrade --cask caton.
ARCHITECTURE.md has the full map: the layers, the
session, panel and command table, and where Baton begins and ends.
- CatonCore (no UI, no network library): the REST client for GitHub's notifications feed (the feed has no GraphQL equivalent), the classifier, the action queue with its undo window, and the inbox projection. Pure and tested.
- Caton (the app): an AppKit status item and non-activating panel hosting
SwiftUI, with subject state through Baton. Each pull request
and issue row reads its state through a fragment beside the view; the
operations the model owns and the facts the classifier reads are in
Subjects.graphqlbeside the store. The first fetch of a subject goes throughrepository(owner:name:), later refreshes throughnodes(ids:)in batches into the same records, and a relaunch classifies the inbox from Baton's on-disk image before the network answers. - My PRs (
g p) is the part to read for Baton: a screen with no REST behind it. Its query and paged connection are inMyPullRequestsView.swift, and its row and the fragment that says where a pull request stands inPullRequestRow.swift, each beside the view that reads it. The app model asks for the same query by value and so shares the view's handle, rows reuse the inbox's own fragments, and the list renders from the image at launch. Its two writes, nudge and ready for review, are mutations inPullRequestWrites.graphqlwith typed optimistic responses;WriteTestsplays GitHub to show the optimistic answer, the server's, and a refusal taken back.
The commands below build Caton from a checkout.
Caton uses Baton 0.15. SwiftPM downloads the package and its matching prebuilt GraphQL compiler; no sibling checkout or Rust toolchain is needed:
git clone https://github.com/shergin/caton.git
cd catonswift test
CATON_DRY_RUN=1 ./scripts/run.shCATON_DRY_RUN=1 keeps every change local: nothing is marked read, done or
unsubscribed on GitHub. CATON_GITHUB_TOKEN=$(gh auth token) signs in without
the sign-in screen. Debug builds also take CATON_OPEN_PANEL=1,
CATON_SECTION=0…3|prs|snoozed|later|cleared, CATON_PRACTICE=1 (opens
the practice inbox), CATON_OVERLAY=peek|help|commands|snooze|why|tips|zero|search|none,
CATON_SNAPSHOT=/path.png (renders the panel to a file) and CATON_DUMP=1
(prints the classified inbox to stderr).
Not signed in yet, or new to the keys? "Try a practice inbox first" on the
sign-in screen (or "Practice inbox" in ⌘K) opens made-up threads that fill
every split; nothing there reaches GitHub or is saved.
./scripts/icons.sh renders the menu bar images, the in-app logo and the app
icon into Sources/Caton/Resources from logo.png and icon-*.png at the
root; run it after changing those.
./scripts/bundle.sh builds build/Caton.app for Apple silicon,
versioned from Sources/Caton/App/AppInfo.swift; ./scripts/release.sh
zips it for a release (homebrew/README.md has the steps). Releases are meant to ship as a Homebrew
cask in Caton's own tap, so brew upgrade --cask caton installs updates; the app checks GitHub
Releases once a day and from "Check for Updates…" and shows that command when
a newer version is out.
"Sign in with GitHub" uses the device flow of Caton's OAuth App (client id
Ov23liz9s5AlvZVZWZ2k; no secret is involved); CATON_GITHUB_CLIENT_ID
points it at another OAuth App with device flow enabled. It asks for
notifications repo, or notifications public_repo in Lite mode (private
pull requests then show no state). The GitHub CLI's login and a classic token
with those scopes also work. GitHub's notifications API does not accept
fine-grained tokens or GitHub App tokens.
Several accounts can be signed in, on github.com, a GitHub Enterprise Cloud
tenant (acme.ghe.com) or a GitHub Enterprise Server host (sign in to those
with a token or the GitHub CLI). One shows at a time, each with its own
rules, snoozes, saved searches and Cleared log; switch from the gear menu,
Settings or ⌘K.
Requires macOS 26 and Swift 6.2, as Baton does.
The Baton 0.15 integration describes typed GraphQL values, cache expiration, live model observation, session shutdown, and the debug store inspector. SwiftPM keeps Caton on compatible 0.15 patch releases.
swift test runs two suites: CatonCoreTests (classifier, projection, queue,
REST client, alerts, search) and CatonTests (the app model's verbs, against
a throwaway state file and user defaults).
j/k move, ⏎/o open, e/d done, h snooze, u unsubscribe, b
later, m mark read, p peek, x select, z undo, y copy link, /
search, ⇥ next split, 1–4 splits, 5–9 saved searches, →/← open
and close a Feed bundle, ⌘K commands, ⌘, settings, ? all keys (also in
Settings › Shortcuts). The global shortcut is ⌘' by default (⌥⌘' when
another app holds it); a second one, off by default, opens straight into
Needs me.
My PRs (g p, or the ⋯ menu) lists your open pull requests by whose move
it is, with "waiting on @alex · 3d" and the like; h there sets a reminder
that joins Needs me if nobody has reviewed or commented by then. n nudges:
it asks the reviewers again (also from a Follow up row, which it then
ends), and ⇧R marks a draft ready for review. Both wait out the undo
window, like every verb, and a dry run only says what they would do.
In the snooze picker, n switches to a follow-up: the thread comes back on
any new activity, and at the chosen time only if nothing happened ("back: no
reply yet"). ⌘K › "Why is this here?" explains a thread's split, and "Get
me to zero…" previews each bulk clear with its count.
Feed bundles what one bot sent and what a busy repository sent into single
rows; e on a bundle clears all of it. The gear menu (or ⌘K) moves the
inbox into an ordinary window that stays open.
Search takes words and GitHub-style qualifiers, each negatable with -:
repo:, org:, author:, reason: (review, mention, team, …), is:
(pr, issue, draft, unread, open, closed, merged, bot) and
ci: (failing, passing, pending). "Save as split" keeps a search as a
split of its own, in the ⋯ menu and on keys 5–9.
The bundled app shows a banner when something new needs you: Needs me only, at most three per poll (adjustable) and a summary for the rest, never while the panel is open, and silent outside working hours (9:00–19:00 on weekdays by default). The first poll after launch announces nothing; what is already there is seen. An optional morning digest sums up what waits and what rules cleared overnight.
Four rules run by default and log what they clear in the Cleared view, where each can be restored: merged or closed subjects outside Needs me are cleared, drafts that do not request you go to Feed, bot pull requests go to Feed, and muted repositories are cleared. Rules never touch Needs me. Until you turn on "Mark rule-cleared threads done on GitHub", rule clears stay on this Mac.
Licensed under the MIT License.
