@d3lm/pr-stats 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +46 -1
  2. package/dist/tui-app.mjs +564 -84
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,7 +7,7 @@ An interactive terminal UI for GitHub PR statistics, built with [OpenTUI](https:
7
7
  ## Requirements
8
8
 
9
9
  - Node 26.4+ or Bun 1.3+. The TUI renders through native FFI. Node gates that behind `--experimental-ffi`, and the launcher re-executes itself with the flag, so a plain `pr-stats` works on both runtimes.
10
- - Either an authenticated gh CLI (`gh auth login`) or a GitHub access token via `--token`, `GITHUB_TOKEN`, or `GH_TOKEN`. With a token, the tool calls the GitHub API directly and does not need the gh CLI. Private repositories need the `repo` scope.
10
+ - Either an authenticated gh CLI (`gh auth login`) or a GitHub access token via `--token`, `GITHUB_TOKEN`, or `GH_TOKEN`. With a token, the tool calls the GitHub API directly and does not need the gh CLI. See [Tokens](#tokens) for the access a token needs.
11
11
 
12
12
  ## Install
13
13
 
@@ -70,6 +70,21 @@ pr-stats --work-hours 9-17 --target 1d
70
70
  pr-stats --review-types approve,request-changes
71
71
  ```
72
72
 
73
+ ## Tokens
74
+
75
+ The `--token` flag and the `GITHUB_TOKEN` and `GH_TOKEN` environment variables switch the tool from the gh CLI to direct GitHub API calls. The PR list comes from the GitHub search API, which silently leaves out every repository the token cannot see, so a token with too little access shows an empty view instead of an error.
76
+
77
+ A classic token needs the `repo` scope, or `public_repo` when every repository is public.
78
+
79
+ A fine-grained token needs more care, because it only covers one resource owner and only the repositories you select for it.
80
+
81
+ - Pick the organization as the resource owner when the PRs live in an organization's repositories. A token owned by your user account never sees them.
82
+ - Grant access to all repositories or select the ones you care about. The public repositories option hides every private one.
83
+ - Grant read access to `Metadata`, `Pull requests`, and `Contents`. Searching pull requests in private repositories needs `Contents` on top of `Pull requests`, even though GitHub's permission tables do not mention it.
84
+ - Ask an organization owner to approve the token when the organization requires approval for fine-grained tokens. Until then, the token sees nothing in that organization.
85
+
86
+ To check a token, run `pr-stats --token <token> --repo owner/name` against a repository you expect to see. Naming the repository makes the search fail with a `422 Validation Failed` error when the token cannot see it, where a search without `--repo` would just return nothing.
87
+
73
88
  ## JSON export
74
89
 
75
90
  The `--json` flag prints the full report to stdout instead of starting the TUI. Every other flag applies the same way, and progress renders on stderr, so a piped stdout stays pure JSON.
@@ -88,6 +103,36 @@ The cache lives in `~/Library/Caches/pr-stats` on macOS and in `$XDG_CACHE_HOME/
88
103
 
89
104
  The options modal saves the current options to the cache directory with the `s` key. Later runs start from the saved options wherever no flag was given, and flags always take precedence.
90
105
 
106
+ ## Auto Reload
107
+
108
+ The `r` key reloads the data by hand, and `R` refetches everything past the cache. The TUI can also keep itself fresh while it stays open. The Auto reload row in the settings dialog turns background reloads on, and the Reload interval row below it sets how long the TUI waits after one load finishes before the next one starts, as a value in seconds, minutes, or hours like `30s`, `10m`, or `2h`. The header shows the interval next to the refresh time while it is on. Both settings persist in `settings.json` in the cache directory.
109
+
110
+ ```json
111
+ {
112
+ "autoReload": true,
113
+ "reloadInterval": "10m"
114
+ }
115
+ ```
116
+
117
+ ## Notifications
118
+
119
+ The Desktop notifications row in the settings dialog makes the TUI send a desktop notification whenever a load finds a PR newly awaiting your review or a review re-requested from you after you already reviewed the PR. Every load diffs its review requests against the load before it, so the first load of a session only records what is already waiting and never notifies about it. Manual reloads count as well, but the feature pairs naturally with auto reload, which lets the TUI watch your review queue from a spare terminal. The setting persists in `settings.json` in the cache directory.
120
+
121
+ ```json
122
+ {
123
+ "notifications": true,
124
+ "notifyChannel": "auto"
125
+ }
126
+ ```
127
+
128
+ Notifications go through the terminal itself when it supports a notification escape sequence, which covers iTerm2, Kitty, Ghostty, WezTerm, and most VTE-based terminals like GNOME Terminal. The terminal posts the notification under its own notification permission and the sequence travels through SSH, so this path needs no setup beyond what the terminal asks for. Some terminals only show the banner while their window is unfocused. Terminals without such support get the platform's own command instead, `osascript` on macOS and `notify-send` on Linux. A notification that fails to send reports in the footer, for example when `notify-send` is missing on a Linux machine.
129
+
130
+ The "Notification channel" row picks the path, and the choice persists as `notifyChannel` with the values `auto`, `terminal`, `command`, and `bell`. On `auto` the TUI tries the terminal first and falls back to the platform command, `terminal` and `command` force one of those paths, and `bell` rings the terminal bell instead of posting any text. The dialog shows the `command` value as the command's name on this platform. Forcing the command pays off inside editor terminals like the one in VS Code, which render the terminal path as a small in-editor toast instead of a system notification. The bell reaches every terminal, including the ones that swallow notifications, and each terminal turns it into its own signal, a sound, a flashing screen, a bell icon on the tab, or a badge on the dock icon. The "Send test notification" row below the channel names the channel the next send takes and sends a sample notification, so you can check that your desktop displays it before relying on it.
131
+
132
+ Two terminals accept the notification sequence without showing anything, and the test row warns about both. iTerm2 drops it until the profile has "Notification Center Alerts" turned on under Settings → Profiles → Terminal, which is off by default. Behind the "Filter Alerts…" button next to it, "Send escape sequence-generated alerts" has to stay on as well, and macOS asks for iTerm2's notification permission on the first send. iTerm2 prefixes the banner with the session name unless its advanced setting "Omit session identifier from notifications" is on. Apple Terminal never implemented the sequence at all, so `auto` skips the terminal there and goes straight to the platform command.
133
+
134
+ The macOS command posts through the built-in Script Editor, and since macOS 15 those notifications stay invisible until Script Editor holds notification permission, without any prompt appearing. To grant it once, open the Script Editor app, run the one-line script `display notification "test"`, and allow the prompt that appears. Script Editor then shows up under Notifications in the System Settings like any other app.
135
+
91
136
  ## Theming
92
137
 
93
138
  The TUI ships five built-in themes, the warm amber default plus green, blue, purple, and yellow variants. The Theme row in the settings dialog cycles through them, and the Edit colors row below it edits individual colors, which creates a custom theme that joins the cycle as a sixth entry. Everything persists in a `theme` object in `settings.json` in the cache directory, which you can also edit by hand.
package/dist/tui-app.mjs CHANGED
@@ -135,8 +135,8 @@ import { createCliRenderer } from "@opentui/core";
135
135
  import { createRoot } from "@opentui/react";
136
136
 
137
137
  // src/tui/App.tsx
138
- import { useKeyboard, useRenderer as useRenderer2, useTerminalDimensions as useTerminalDimensions3 } from "@opentui/react";
139
- import { useEffect as useEffect6, useMemo as useMemo2, useReducer, useRef as useRef5, useState as useState4 } from "react";
138
+ import { useKeyboard, useRenderer as useRenderer3, useTerminalDimensions as useTerminalDimensions3 } from "@opentui/react";
139
+ import { useEffect as useEffect7, useMemo as useMemo2, useReducer, useRef as useRef6, useState as useState4 } from "react";
140
140
 
141
141
  // src/settings.ts
142
142
  import { readFileSync as readFileSync2, rmSync as rmSync2 } from "node:fs";
@@ -279,10 +279,36 @@ function percentile(sorted, percent) {
279
279
  }
280
280
 
281
281
  // src/settings.ts
282
+ var NOTIFY_CHANNELS = ["auto", "terminal", "command", "bell"];
283
+ var DEFAULT_RELOAD_INTERVAL = "10m";
284
+ var MAX_RELOAD_INTERVAL_MS = 24 * 60 * 60 * 1e3;
285
+ function reloadIntervalMs(input) {
286
+ const match = /^(\d+)([smh])$/.exec(input);
287
+ if (match === null) {
288
+ return null;
289
+ }
290
+ const ms = Number(match[1]) * (match[2] === "h" ? 36e5 : match[2] === "m" ? 6e4 : 1e3);
291
+ return ms >= 1e3 && ms <= MAX_RELOAD_INTERVAL_MS ? ms : null;
292
+ }
293
+ function parseReloadInterval(input) {
294
+ const ms = reloadIntervalMs(input);
295
+ if (ms === null) {
296
+ throw new CliError(`invalid reload interval "${input}", use a value from 1s to 24h like 30s, 10m, or 2h`);
297
+ }
298
+ return ms;
299
+ }
282
300
  function settingsFile() {
283
301
  return join2(cacheDir(), "settings.json");
284
302
  }
285
303
  var current = {};
304
+ function writeCurrent() {
305
+ if (!cacheEnabled()) {
306
+ return false;
307
+ }
308
+ writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
309
+ `);
310
+ return true;
311
+ }
286
312
  function loadSettings() {
287
313
  current = {};
288
314
  if (!cacheEnabled()) {
@@ -310,26 +336,46 @@ function loadSettings() {
310
336
  if (settings.copyLinks !== void 0 && typeof settings.copyLinks !== "boolean") {
311
337
  throw new CliError(`"copyLinks" in ${settingsFile()} must be true or false`);
312
338
  }
339
+ if (settings.autoReload !== void 0 && typeof settings.autoReload !== "boolean") {
340
+ throw new CliError(`"autoReload" in ${settingsFile()} must be true or false`);
341
+ }
342
+ if (settings.reloadInterval !== void 0 && (typeof settings.reloadInterval !== "string" || reloadIntervalMs(settings.reloadInterval) === null)) {
343
+ throw new CliError(
344
+ `"reloadInterval" in ${settingsFile()} must be an interval from 1s to 24h like "30s", "10m", or "2h"`
345
+ );
346
+ }
347
+ if (settings.notifications !== void 0 && typeof settings.notifications !== "boolean") {
348
+ throw new CliError(`"notifications" in ${settingsFile()} must be true or false`);
349
+ }
350
+ if (settings.notifyChannel !== void 0 && !NOTIFY_CHANNELS.includes(settings.notifyChannel)) {
351
+ throw new CliError(`"notifyChannel" in ${settingsFile()} must be "auto", "terminal", "command", or "bell"`);
352
+ }
313
353
  current = settings;
314
354
  return current;
315
355
  }
316
356
  function saveNoCache(on) {
317
357
  current = { ...current, noCache: on };
318
- if (!cacheEnabled()) {
319
- return false;
320
- }
321
- writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
322
- `);
323
- return true;
358
+ return writeCurrent();
324
359
  }
325
360
  function saveCopyLinks(on) {
326
361
  current = { ...current, copyLinks: on };
327
- if (!cacheEnabled()) {
328
- return false;
329
- }
330
- writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
331
- `);
332
- return true;
362
+ return writeCurrent();
363
+ }
364
+ function saveAutoReload(on) {
365
+ current = { ...current, autoReload: on };
366
+ return writeCurrent();
367
+ }
368
+ function saveReloadInterval(value2) {
369
+ current = { ...current, reloadInterval: value2 };
370
+ return writeCurrent();
371
+ }
372
+ function saveNotifications(on) {
373
+ current = { ...current, notifications: on };
374
+ return writeCurrent();
375
+ }
376
+ function saveNotifyChannel(channel) {
377
+ current = { ...current, notifyChannel: channel };
378
+ return writeCurrent();
333
379
  }
334
380
  function saveTheme(value2) {
335
381
  current = { ...current };
@@ -338,12 +384,7 @@ function saveTheme(value2) {
338
384
  } else {
339
385
  current.theme = value2;
340
386
  }
341
- if (!cacheEnabled()) {
342
- return false;
343
- }
344
- writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
345
- `);
346
- return true;
387
+ return writeCurrent();
347
388
  }
348
389
  function resetSettings() {
349
390
  if (!cacheEnabled()) {
@@ -598,7 +639,7 @@ function hintsFor(modal, editing, tab, authoredTab, views, copyLinks2) {
598
639
  return editing ? "enter apply \xB7 esc cancel" : "\u2191/\u2193 select \xB7 enter edit \xB7 \u2190/\u2192 toggle \xB7 s save \xB7 esc close \xB7 q quit";
599
640
  }
600
641
  if (modal === "settings") {
601
- return "\u2191/\u2193 select \xB7 enter apply \xB7 \u2190/\u2192 toggle \xB7 esc close \xB7 q quit";
642
+ return editing ? "enter apply \xB7 esc cancel" : "\u2191/\u2193 select \xB7 enter apply \xB7 \u2190/\u2192 toggle \xB7 esc close \xB7 q quit";
602
643
  }
603
644
  if (modal === "theme") {
604
645
  return editing ? "enter apply \xB7 esc cancel" : "\u2191/\u2193 select \xB7 enter edit hex \xB7 esc back \xB7 q quit";
@@ -1684,7 +1725,8 @@ function Header({
1684
1725
  options,
1685
1726
  raw,
1686
1727
  error,
1687
- spinning
1728
+ spinning,
1729
+ reloadEvery
1688
1730
  }) {
1689
1731
  const context = [
1690
1732
  raw ? `@${raw.user}` : options.user !== "" ? `@${options.user}` : "@...",
@@ -1692,7 +1734,7 @@ function Header({
1692
1734
  raw && raw.repos.length > 0 ? raw.repos.join(", ") : options.repos !== "" ? options.repos : "all repos",
1693
1735
  timeModeLabel(options)
1694
1736
  ].join(" \xB7 ");
1695
- const rightStatus = raw ? error !== null ? "reload failed \xB7 press r to retry" : `refreshed ${raw.fetchedAt.toLocaleTimeString()}` : "";
1737
+ const rightStatus = raw ? error !== null ? "reload failed \xB7 press r to retry" : `refreshed ${raw.fetchedAt.toLocaleTimeString()}${reloadEvery === null ? "" : ` \xB7 every ${reloadEvery}`}` : "";
1696
1738
  return /* @__PURE__ */ jsxs2("box", { flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, justifyContent: "space-between", children: [
1697
1739
  /* @__PURE__ */ jsxs2("text", { wrapMode: "none", children: [
1698
1740
  /* @__PURE__ */ jsx3("b", { fg: theme.accent, children: "pr-stats" }),
@@ -3005,6 +3047,31 @@ function ModalRow({ label, isSelected, children }) {
3005
3047
  }
3006
3048
  );
3007
3049
  }
3050
+ function ModalInput({
3051
+ width,
3052
+ value: value2,
3053
+ onDraft,
3054
+ onSubmit
3055
+ }) {
3056
+ return /* @__PURE__ */ jsx10(
3057
+ "input",
3058
+ {
3059
+ width,
3060
+ value: value2,
3061
+ focused: true,
3062
+ onInput: (next) => {
3063
+ onDraft(String(next));
3064
+ },
3065
+ onSubmit: () => {
3066
+ onSubmit();
3067
+ },
3068
+ backgroundColor: theme.inputBg,
3069
+ focusedBackgroundColor: theme.inputFocusedBg,
3070
+ textColor: theme.text,
3071
+ cursorColor: theme.accent
3072
+ }
3073
+ );
3074
+ }
3008
3075
 
3009
3076
  // src/tui/components/OptionsModal.tsx
3010
3077
  import { jsx as jsx11, jsxs as jsxs10 } from "@opentui/react/jsx-runtime";
@@ -3118,24 +3185,7 @@ function FieldRow({
3118
3185
  )
3119
3186
  ] });
3120
3187
  }
3121
- return /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: isEditing ? /* @__PURE__ */ jsx11(
3122
- "input",
3123
- {
3124
- width: 32,
3125
- value: String(options[field.key]),
3126
- focused: true,
3127
- onInput: (next) => {
3128
- onDraft(String(next));
3129
- },
3130
- onSubmit: () => {
3131
- onSubmit();
3132
- },
3133
- backgroundColor: theme.inputBg,
3134
- focusedBackgroundColor: theme.inputFocusedBg,
3135
- textColor: theme.text,
3136
- cursorColor: theme.accent
3137
- }
3138
- ) : isSelected && field.kind === "toggle" ? /* @__PURE__ */ jsxs10("text", { wrapMode: "none", children: [
3188
+ return /* @__PURE__ */ jsx11(ModalRow, { label: field.label, isSelected, children: isEditing ? /* @__PURE__ */ jsx11(ModalInput, { width: 32, value: String(options[field.key]), onDraft, onSubmit }) : isSelected && field.kind === "toggle" ? /* @__PURE__ */ jsxs10("text", { wrapMode: "none", children: [
3139
3189
  /* @__PURE__ */ jsx11("span", { fg: theme.muted, children: "\u2039 " }),
3140
3190
  /* @__PURE__ */ jsx11("b", { fg: theme.text, children: value2.text }),
3141
3191
  /* @__PURE__ */ jsx11("span", { fg: theme.muted, children: " \u203A" })
@@ -3175,6 +3225,7 @@ function Checklist({
3175
3225
  }
3176
3226
 
3177
3227
  // src/tui/components/SettingsModal.tsx
3228
+ import { useRenderer as useRenderer2 } from "@opentui/react";
3178
3229
  import { homedir as homedir2 } from "node:os";
3179
3230
 
3180
3231
  // src/tui/data/export.ts
@@ -4022,6 +4073,36 @@ var SETTINGS = [
4022
4073
  label: "Clear cache",
4023
4074
  hint: "deletes the cached PR data at this path, so the next reload refetches everything"
4024
4075
  },
4076
+ {
4077
+ key: "autoReload",
4078
+ section: "Reload",
4079
+ label: "Auto reload",
4080
+ hint: "reloads the data in the background on the interval below, so the charts stay fresh while the TUI keeps running"
4081
+ },
4082
+ {
4083
+ key: "reloadInterval",
4084
+ section: "Reload",
4085
+ label: "Reload interval",
4086
+ hint: "time between the background reloads while auto reload is on, like 30s, 10m, or 2h \xB7 enter edits the value"
4087
+ },
4088
+ {
4089
+ key: "notifications",
4090
+ section: "Notifications",
4091
+ label: "Desktop notifications",
4092
+ hint: "notifies you when a load finds a PR newly awaiting your review or a review re-requested from you"
4093
+ },
4094
+ {
4095
+ key: "notifyChannel",
4096
+ section: "Notifications",
4097
+ label: "Notification channel",
4098
+ hint: "auto tries the terminal, then the platform command \xB7 the others force one path \xB7 bell rings the terminal bell"
4099
+ },
4100
+ {
4101
+ key: "testNotification",
4102
+ section: "Notifications",
4103
+ label: "Send test notification",
4104
+ hint: "sends a sample notification through the channel shown, so you can check that your desktop displays it"
4105
+ },
4025
4106
  {
4026
4107
  key: "copyLinks",
4027
4108
  section: "Links",
@@ -4082,9 +4163,140 @@ var CACHE_MESSAGES = {
4082
4163
  resetDisabled: { text: "the cache is disabled for this session \xB7 nothing to reset" },
4083
4164
  exported: { text: "stats exported \xB7 the same report prints to stdout with the --json flag" },
4084
4165
  exportFailed: { text: "the export failed \xB7 the file could not be written", warn: true },
4085
- exportNoData: { text: "no loaded stats to export yet \xB7 export again once the load finishes", warn: true }
4166
+ exportNoData: { text: "no loaded stats to export yet \xB7 export again once the load finishes", warn: true },
4167
+ notified: { text: "test notification sent \xB7 a failure to deliver it reports in the footer" }
4086
4168
  };
4087
4169
 
4170
+ // src/tui/utils/notify.ts
4171
+ import { spawn } from "node:child_process";
4172
+ function notificationBoundary(renderer2) {
4173
+ const raw = renderer2;
4174
+ return {
4175
+ get capabilities() {
4176
+ return renderer2.capabilities;
4177
+ },
4178
+ triggerNotification: (message, title) => renderer2.triggerNotification(message, title),
4179
+ writeOut: (data) => {
4180
+ if (typeof raw.writeOut === "function") {
4181
+ raw.writeOut(data);
4182
+ } else {
4183
+ process.stdout.write(data);
4184
+ }
4185
+ }
4186
+ };
4187
+ }
4188
+ var BELL = "\x07";
4189
+ function terminalQuirk(renderer2) {
4190
+ const name = renderer2.capabilities?.terminal.name ?? "";
4191
+ if (/iterm/i.test(name)) {
4192
+ return "iterm";
4193
+ }
4194
+ if (/apple_terminal|terminal\.app/i.test(name)) {
4195
+ return "apple-terminal";
4196
+ }
4197
+ return null;
4198
+ }
4199
+ function createNotifier(renderer2, channel) {
4200
+ return (title, body, onError) => {
4201
+ if (channel === "bell") {
4202
+ renderer2.writeOut(BELL);
4203
+ return;
4204
+ }
4205
+ const tryTerminal = channel === "terminal" || channel === "auto" && terminalQuirk(renderer2) !== "apple-terminal";
4206
+ if (tryTerminal && renderer2.triggerNotification(body, title)) {
4207
+ return;
4208
+ }
4209
+ if (channel === "terminal") {
4210
+ onError("the terminal has no notification support, switch the channel to auto, the platform command, or bell");
4211
+ return;
4212
+ }
4213
+ sendNotification(title, body, onError);
4214
+ };
4215
+ }
4216
+ function notificationChannel(renderer2, channel) {
4217
+ if (channel === "bell" || channel === "terminal") {
4218
+ return channel;
4219
+ }
4220
+ if (channel === "auto" && renderer2.capabilities?.notifications === true && terminalQuirk(renderer2) !== "apple-terminal") {
4221
+ return "terminal";
4222
+ }
4223
+ return notificationTool();
4224
+ }
4225
+ function notificationCaveat(renderer2, channel) {
4226
+ if (channel === "command" || channel === "bell") {
4227
+ return null;
4228
+ }
4229
+ switch (terminalQuirk(renderer2)) {
4230
+ case "iterm": {
4231
+ return "iTerm2 shows these only once Notification Center Alerts is on under Settings \u203A Profiles \u203A Terminal";
4232
+ }
4233
+ case "apple-terminal": {
4234
+ return channel === "auto" ? "Terminal.app ignores terminal notifications, so auto takes the platform command here" : "Terminal.app ignores terminal notifications \xB7 switch the channel to auto, command, or bell";
4235
+ }
4236
+ default: {
4237
+ return null;
4238
+ }
4239
+ }
4240
+ }
4241
+ function notificationTool() {
4242
+ switch (process.platform) {
4243
+ case "darwin": {
4244
+ return "osascript";
4245
+ }
4246
+ case "linux": {
4247
+ return "notify-send";
4248
+ }
4249
+ default: {
4250
+ return null;
4251
+ }
4252
+ }
4253
+ }
4254
+ function escapeMarkup(text) {
4255
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
4256
+ }
4257
+ function notifyCommand(title, body) {
4258
+ switch (process.platform) {
4259
+ case "darwin": {
4260
+ return {
4261
+ command: "osascript",
4262
+ args: [
4263
+ "-e",
4264
+ "on run argv",
4265
+ "-e",
4266
+ 'display notification (item 2 of argv) with title (item 1 of argv) sound name "default"',
4267
+ "-e",
4268
+ "end run",
4269
+ title,
4270
+ body
4271
+ ]
4272
+ };
4273
+ }
4274
+ case "linux": {
4275
+ return { command: "notify-send", args: ["--app-name=pr-stats", "--", title, escapeMarkup(body)] };
4276
+ }
4277
+ default: {
4278
+ return null;
4279
+ }
4280
+ }
4281
+ }
4282
+ function sendNotification(title, body, onError) {
4283
+ const spec = notifyCommand(title, body);
4284
+ if (spec === null) {
4285
+ onError("desktop notifications are not supported on this platform");
4286
+ return;
4287
+ }
4288
+ const child = spawn(spec.command, spec.args, { stdio: "ignore", detached: true });
4289
+ child.on("error", (error) => {
4290
+ onError(`could not send the notification (${error.message})`);
4291
+ });
4292
+ child.on("exit", (code) => {
4293
+ if (code !== null && code !== 0) {
4294
+ onError(`could not send the notification (${spec.command} exited with ${code})`);
4295
+ }
4296
+ });
4297
+ child.unref();
4298
+ }
4299
+
4088
4300
  // src/tui/components/SettingsModal.tsx
4089
4301
  import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
4090
4302
  var SECTIONS2 = [];
@@ -4098,12 +4310,25 @@ for (const setting of SETTINGS) {
4098
4310
  }
4099
4311
  function SettingsModal({
4100
4312
  selected,
4313
+ editing,
4314
+ error,
4101
4315
  cacheAction,
4102
4316
  noCache: noCache2,
4317
+ autoReload: autoReload2,
4318
+ reloadInterval: reloadInterval2,
4319
+ notifications: notifications2,
4320
+ notifyChannel: notifyChannel2,
4103
4321
  copyLinks: copyLinks2,
4104
- preset
4322
+ preset,
4323
+ onDraft,
4324
+ onSubmit
4105
4325
  }) {
4106
4326
  const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
4327
+ const renderer2 = notificationBoundary(useRenderer2());
4328
+ const channelValue = notifyChannel2 === "command" ? notificationTool() ?? "unsupported" : notifyChannel2;
4329
+ const deliveryValue = notificationChannel(renderer2, notifyChannel2) ?? "unsupported";
4330
+ const caveat = SETTINGS[selected].key === "testNotification" ? notificationCaveat(renderer2, notifyChannel2) : null;
4331
+ const bottomLine = error !== null ? { text: error, fg: theme.error } : message !== null ? { text: message.text, fg: message.warn ? theme.warn : theme.muted } : caveat !== null ? { text: caveat, fg: theme.warn } : { text: SETTINGS[selected].hint, fg: theme.muted };
4107
4332
  return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
4108
4333
  SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
4109
4334
  /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
@@ -4114,24 +4339,40 @@ function SettingsModal({
4114
4339
  {
4115
4340
  setting,
4116
4341
  isSelected,
4342
+ isEditing: isSelected && editing,
4117
4343
  cacheAction,
4118
4344
  noCache: noCache2,
4345
+ autoReload: autoReload2,
4346
+ reloadInterval: reloadInterval2,
4347
+ notifications: notifications2,
4348
+ channelValue,
4349
+ deliveryValue,
4119
4350
  copyLinks: copyLinks2,
4120
- preset
4351
+ preset,
4352
+ onDraft,
4353
+ onSubmit
4121
4354
  }
4122
4355
  ) }, setting.key);
4123
4356
  })
4124
4357
  ] }, section.title)),
4125
- /* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg: message?.warn ? theme.warn : theme.muted, marginLeft: 2, marginRight: 2, children: message?.text ?? SETTINGS[selected].hint })
4358
+ /* @__PURE__ */ jsx12("text", { wrapMode: "word", height: 2, fg: bottomLine.fg, marginLeft: 2, marginRight: 2, children: bottomLine.text })
4126
4359
  ] });
4127
4360
  }
4128
4361
  function SettingValue({
4129
4362
  setting,
4130
4363
  isSelected,
4364
+ isEditing,
4131
4365
  cacheAction,
4132
4366
  noCache: noCache2,
4367
+ autoReload: autoReload2,
4368
+ reloadInterval: reloadInterval2,
4369
+ notifications: notifications2,
4370
+ channelValue,
4371
+ deliveryValue,
4133
4372
  copyLinks: copyLinks2,
4134
- preset
4373
+ preset,
4374
+ onDraft,
4375
+ onSubmit
4135
4376
  }) {
4136
4377
  switch (setting.key) {
4137
4378
  case "noCache": {
@@ -4140,6 +4381,24 @@ function SettingValue({
4140
4381
  case "clearCache": {
4141
4382
  return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
4142
4383
  }
4384
+ case "autoReload": {
4385
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: autoReload2 ? "yes" : "no", isSelected });
4386
+ }
4387
+ case "reloadInterval": {
4388
+ if (isEditing) {
4389
+ return /* @__PURE__ */ jsx12(ModalInput, { width: 16, value: reloadInterval2, onDraft, onSubmit });
4390
+ }
4391
+ return /* @__PURE__ */ jsx12(IntervalValue, { value: reloadInterval2, active: autoReload2, isSelected });
4392
+ }
4393
+ case "notifications": {
4394
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: notifications2 ? "yes" : "no", isSelected });
4395
+ }
4396
+ case "notifyChannel": {
4397
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: channelValue, isSelected });
4398
+ }
4399
+ case "testNotification": {
4400
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: deliveryValue });
4401
+ }
4143
4402
  case "copyLinks": {
4144
4403
  return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
4145
4404
  }
@@ -4170,6 +4429,15 @@ function ToggleValue({ value: value2, isSelected }) {
4170
4429
  }
4171
4430
  return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
4172
4431
  }
4432
+ function IntervalValue({ value: value2, active, isSelected }) {
4433
+ if (!active) {
4434
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.muted : theme.dim, children: value2 });
4435
+ }
4436
+ if (isSelected) {
4437
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }) });
4438
+ }
4439
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
4440
+ }
4173
4441
  function PathValue({ path, confirming, isSelected }) {
4174
4442
  if (confirming) {
4175
4443
  return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
@@ -4217,24 +4485,7 @@ function ColorRow({
4217
4485
  }) {
4218
4486
  const value2 = themeColorText(color.key);
4219
4487
  const swatch = theme[color.key];
4220
- return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(
4221
- "input",
4222
- {
4223
- width: 36,
4224
- value: value2,
4225
- focused: true,
4226
- onInput: (next) => {
4227
- onDraft(String(next));
4228
- },
4229
- onSubmit: () => {
4230
- onSubmit();
4231
- },
4232
- backgroundColor: theme.inputBg,
4233
- focusedBackgroundColor: theme.inputFocusedBg,
4234
- textColor: theme.text,
4235
- cursorColor: theme.accent
4236
- }
4237
- ) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
4488
+ return /* @__PURE__ */ jsx13(ModalRow, { label: color.key, isSelected, children: isEditing ? /* @__PURE__ */ jsx13(ModalInput, { width: 36, value: value2, onDraft, onSubmit }) : /* @__PURE__ */ jsxs12("text", { wrapMode: "none", children: [
4238
4489
  Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
4239
4490
  /* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
4240
4491
  /* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
@@ -4253,10 +4504,15 @@ function Modals({
4253
4504
  options,
4254
4505
  saved: saved2,
4255
4506
  noCache: noCache2,
4507
+ autoReload: autoReload2,
4508
+ reloadInterval: reloadInterval2,
4509
+ notifications: notifications2,
4510
+ notifyChannel: notifyChannel2,
4256
4511
  copyLinks: copyLinks2,
4257
4512
  themeState,
4258
4513
  onDraft,
4259
4514
  onSubmitField,
4515
+ onSubmitReloadInterval,
4260
4516
  onSubmitThemeColor,
4261
4517
  onToggleReviewType,
4262
4518
  onToggleWorkDay
@@ -4282,10 +4538,18 @@ function Modals({
4282
4538
  SettingsModal,
4283
4539
  {
4284
4540
  selected: ui.selectedSetting,
4541
+ editing: ui.editing,
4542
+ error: ui.settingError,
4285
4543
  cacheAction: ui.cacheAction,
4286
4544
  noCache: noCache2,
4545
+ autoReload: autoReload2,
4546
+ reloadInterval: reloadInterval2,
4547
+ notifications: notifications2,
4548
+ notifyChannel: notifyChannel2,
4287
4549
  copyLinks: copyLinks2,
4288
- preset: themeState.preset
4550
+ preset: themeState.preset,
4551
+ onDraft,
4552
+ onSubmit: onSubmitReloadInterval
4289
4553
  }
4290
4554
  );
4291
4555
  }
@@ -4306,12 +4570,31 @@ function Modals({
4306
4570
  return null;
4307
4571
  }
4308
4572
 
4573
+ // src/tui/hooks/useAutoReload.ts
4574
+ import { useEffect as useEffect4, useEffectEvent } from "react";
4575
+ function useAutoReload(intervalMs, loading, reload) {
4576
+ const tick = useEffectEvent(() => {
4577
+ reload();
4578
+ });
4579
+ useEffect4(() => {
4580
+ if (intervalMs === null || loading) {
4581
+ return void 0;
4582
+ }
4583
+ const timer = setTimeout(() => {
4584
+ tick();
4585
+ }, intervalMs);
4586
+ return () => {
4587
+ clearTimeout(timer);
4588
+ };
4589
+ }, [intervalMs, loading]);
4590
+ }
4591
+
4309
4592
  // src/tui/hooks/useDeferredLoading.ts
4310
- import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
4593
+ import { useEffect as useEffect5, useRef as useRef3, useState as useState2 } from "react";
4311
4594
  function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
4312
4595
  const [visible, setVisible] = useState2(isLoading && showDelay === 0);
4313
4596
  const shownAtRef = useRef3(null);
4314
- useEffect4(() => {
4597
+ useEffect5(() => {
4315
4598
  if (isLoading) {
4316
4599
  const timer2 = setTimeout(() => {
4317
4600
  shownAtRef.current = Date.now();
@@ -4335,7 +4618,7 @@ function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } =
4335
4618
  }
4336
4619
 
4337
4620
  // src/tui/hooks/useLoader.ts
4338
- import { useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
4621
+ import { useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
4339
4622
  function useLoader(options, noCache2, onLoaded) {
4340
4623
  const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
4341
4624
  const [raw, setRaw] = useState3(startupSnapshot);
@@ -4385,7 +4668,7 @@ function useLoader(options, noCache2, onLoaded) {
4385
4668
  void run(bypassCache || noCache2);
4386
4669
  };
4387
4670
  const startedRef = useRef4(false);
4388
- useEffect5(() => {
4671
+ useEffect6(() => {
4389
4672
  disposedRef.current = false;
4390
4673
  if (!startedRef.current) {
4391
4674
  startedRef.current = true;
@@ -4406,6 +4689,85 @@ function useLoader(options, noCache2, onLoaded) {
4406
4689
  };
4407
4690
  }
4408
4691
 
4692
+ // src/tui/hooks/useReviewNotifications.ts
4693
+ import { useRef as useRef5 } from "react";
4694
+
4695
+ // src/tui/data/notifications.ts
4696
+ function diffReviewRequests(previous, results) {
4697
+ const baseline = /* @__PURE__ */ new Map();
4698
+ const reviewedKeys = /* @__PURE__ */ new Set();
4699
+ const pending = [];
4700
+ for (const result of results) {
4701
+ const key = `${result.pr.repo}#${result.pr.number}`;
4702
+ if (result.kind === "reviewed") {
4703
+ reviewedKeys.add(key);
4704
+ } else if (result.kind === "pending" && result.pr.state === "open") {
4705
+ const requestedAt = result.requestedAt.getTime();
4706
+ baseline.set(key, requestedAt);
4707
+ pending.push({ key, pr: result.pr, requestedAt });
4708
+ }
4709
+ }
4710
+ if (previous === null) {
4711
+ return { baseline, newRequests: [], reRequests: [] };
4712
+ }
4713
+ const newRequests = [];
4714
+ const reRequests = [];
4715
+ for (const { key, pr, requestedAt } of pending) {
4716
+ const before = previous.get(key);
4717
+ if (before !== void 0 && before >= requestedAt) {
4718
+ continue;
4719
+ }
4720
+ (reviewedKeys.has(key) ? reRequests : newRequests).push(pr);
4721
+ }
4722
+ return { baseline, newRequests, reRequests };
4723
+ }
4724
+ var MAX_LISTED = 3;
4725
+ var TEST_NOTIFICATION = {
4726
+ title: "pr-stats",
4727
+ body: "Desktop notifications are working. New review requests show up like this."
4728
+ };
4729
+ function describeReviewRequests(changes) {
4730
+ const notifications2 = [];
4731
+ if (changes.newRequests.length > 0) {
4732
+ notifications2.push(describe(changes.newRequests, "Review requested on", "new PRs awaiting your review"));
4733
+ }
4734
+ if (changes.reRequests.length > 0) {
4735
+ notifications2.push(describe(changes.reRequests, "Review re-requested on", "PRs came back for review"));
4736
+ }
4737
+ return notifications2;
4738
+ }
4739
+ function describe(prs, singleTitle, pluralTitle) {
4740
+ if (prs.length === 1) {
4741
+ const [pr] = prs;
4742
+ return { title: `${singleTitle} ${refOf(pr)}`, body: pr.title };
4743
+ }
4744
+ const lines = prs.slice(0, MAX_LISTED).map((pr) => `${refOf(pr)} ${pr.title}`);
4745
+ const rest = prs.length - lines.length;
4746
+ if (rest > 0) {
4747
+ lines.push(`and ${rest} more`);
4748
+ }
4749
+ return { title: `${prs.length} ${pluralTitle}`, body: lines.join("\n") };
4750
+ }
4751
+ function refOf(pr) {
4752
+ return `${pr.repo}#${pr.number}`;
4753
+ }
4754
+
4755
+ // src/tui/hooks/useReviewNotifications.ts
4756
+ function useReviewNotifications(enabled2, notify, onError) {
4757
+ const baselineRef = useRef5(null);
4758
+ return (key, results) => {
4759
+ const previous = baselineRef.current?.key === key ? baselineRef.current.baseline : null;
4760
+ const changes = diffReviewRequests(previous, results);
4761
+ baselineRef.current = { key, baseline: changes.baseline };
4762
+ if (!enabled2) {
4763
+ return;
4764
+ }
4765
+ for (const notification of describeReviewRequests(changes)) {
4766
+ notify(notification.title, notification.body, onError);
4767
+ }
4768
+ };
4769
+ }
4770
+
4409
4771
  // src/tui/hooks/useViewModel.ts
4410
4772
  import { useMemo } from "react";
4411
4773
 
@@ -6081,6 +6443,43 @@ function handleSettingsModalKey(key, context) {
6081
6443
  }
6082
6444
  break;
6083
6445
  }
6446
+ case "autoReload": {
6447
+ const next = !context.autoReload;
6448
+ context.setAutoReload(next);
6449
+ context.dispatchUi({ type: "cacheActionReported", action: saveAutoReload(next) ? "saved" : "notSaved" });
6450
+ break;
6451
+ }
6452
+ case "reloadInterval": {
6453
+ if (key.name !== "return") {
6454
+ break;
6455
+ }
6456
+ context.beginEdit(context.reloadInterval);
6457
+ break;
6458
+ }
6459
+ case "notifications": {
6460
+ const next = !context.notifications;
6461
+ context.setNotifications(next);
6462
+ context.dispatchUi({ type: "cacheActionReported", action: saveNotifications(next) ? "saved" : "notSaved" });
6463
+ break;
6464
+ }
6465
+ case "notifyChannel": {
6466
+ const index = NOTIFY_CHANNELS.indexOf(context.notifyChannel);
6467
+ const step = key.name === "left" ? -1 : 1;
6468
+ const next = NOTIFY_CHANNELS[(index + step + NOTIFY_CHANNELS.length) % NOTIFY_CHANNELS.length];
6469
+ context.setNotifyChannel(next);
6470
+ context.dispatchUi({ type: "cacheActionReported", action: saveNotifyChannel(next) ? "saved" : "notSaved" });
6471
+ break;
6472
+ }
6473
+ case "testNotification": {
6474
+ if (key.name !== "return") {
6475
+ break;
6476
+ }
6477
+ context.notify(TEST_NOTIFICATION.title, TEST_NOTIFICATION.body, (message) => {
6478
+ context.dispatchUi({ type: "openErrorReported", message });
6479
+ });
6480
+ context.dispatchUi({ type: "cacheActionReported", action: "notified" });
6481
+ break;
6482
+ }
6084
6483
  case "copyLinks": {
6085
6484
  const next = !context.copyLinks;
6086
6485
  context.setCopyLinks(next);
@@ -6355,6 +6754,7 @@ var initialUiState = {
6355
6754
  selectedSetting: 0,
6356
6755
  selectedThemeColor: 0,
6357
6756
  fieldError: null,
6757
+ settingError: null,
6358
6758
  themeColorError: null,
6359
6759
  cacheAction: null,
6360
6760
  openError: null,
@@ -6381,7 +6781,14 @@ function uiReducer(state, action) {
6381
6781
  return { ...state, successNotice: null };
6382
6782
  }
6383
6783
  case "modalOpened": {
6384
- return { ...state, modal: action.modal, fieldError: null, themeColorError: null, cacheAction: null };
6784
+ return {
6785
+ ...state,
6786
+ modal: action.modal,
6787
+ fieldError: null,
6788
+ settingError: null,
6789
+ themeColorError: null,
6790
+ cacheAction: null
6791
+ };
6385
6792
  }
6386
6793
  case "optionsModalClosed": {
6387
6794
  return { ...state, modal: null, fieldError: null };
@@ -6390,7 +6797,7 @@ function uiReducer(state, action) {
6390
6797
  if (state.cacheAction === "confirm" || state.cacheAction === "resetConfirm") {
6391
6798
  return { ...state, cacheAction: null };
6392
6799
  }
6393
- return { ...state, modal: null, cacheAction: null };
6800
+ return { ...state, modal: null, settingError: null, cacheAction: null };
6394
6801
  }
6395
6802
  case "themeModalClosed": {
6396
6803
  return { ...state, modal: "settings", themeColorError: null, cacheAction: null };
@@ -6402,6 +6809,7 @@ function uiReducer(state, action) {
6402
6809
  return {
6403
6810
  ...state,
6404
6811
  selectedSetting: cycled2(state.selectedSetting, action.delta, SETTINGS.length),
6812
+ settingError: null,
6405
6813
  cacheAction: null
6406
6814
  };
6407
6815
  }
@@ -6414,10 +6822,10 @@ function uiReducer(state, action) {
6414
6822
  };
6415
6823
  }
6416
6824
  case "editStarted": {
6417
- return { ...state, editing: true, fieldError: null, themeColorError: null };
6825
+ return { ...state, editing: true, fieldError: null, settingError: null, themeColorError: null };
6418
6826
  }
6419
6827
  case "editCancelled": {
6420
- return { ...state, editing: false, fieldError: null, themeColorError: null };
6828
+ return { ...state, editing: false, fieldError: null, settingError: null, themeColorError: null };
6421
6829
  }
6422
6830
  case "fieldCommitted": {
6423
6831
  return { ...state, editing: false, fieldError: null };
@@ -6431,6 +6839,12 @@ function uiReducer(state, action) {
6431
6839
  }
6432
6840
  return { ...state, fieldError: "the cache is disabled for this session \xB7 options not saved" };
6433
6841
  }
6842
+ case "settingCommitted": {
6843
+ return { ...state, editing: false, settingError: null, cacheAction: action.action };
6844
+ }
6845
+ case "settingErrorReported": {
6846
+ return { ...state, settingError: action.message };
6847
+ }
6434
6848
  case "themeColorCommitted": {
6435
6849
  return { ...state, editing: false, themeColorError: null, cacheAction: action.action };
6436
6850
  }
@@ -6445,7 +6859,7 @@ function uiReducer(state, action) {
6445
6859
  }
6446
6860
 
6447
6861
  // src/tui/utils/browser.ts
6448
- import { spawn } from "node:child_process";
6862
+ import { spawn as spawn2 } from "node:child_process";
6449
6863
  function openCommand(url) {
6450
6864
  switch (process.platform) {
6451
6865
  case "darwin": {
@@ -6461,7 +6875,7 @@ function openCommand(url) {
6461
6875
  }
6462
6876
  function openInBrowser(url, onError) {
6463
6877
  const { command, args } = openCommand(url);
6464
- const child = spawn(command, args, { stdio: "ignore", detached: true });
6878
+ const child = spawn2(command, args, { stdio: "ignore", detached: true });
6465
6879
  child.on("error", (error) => {
6466
6880
  onError(`could not open the browser (${error.message})`);
6467
6881
  });
@@ -6508,14 +6922,19 @@ function App({
6508
6922
  initial: initial2,
6509
6923
  initialSaved = null,
6510
6924
  initialNoCache = false,
6925
+ initialAutoReload = false,
6926
+ initialReloadInterval = DEFAULT_RELOAD_INTERVAL,
6927
+ initialNotifications = false,
6928
+ initialNotifyChannel = "auto",
6511
6929
  initialCopyLinks = false,
6512
6930
  initialTheme = defaultThemeState(),
6513
6931
  openUrl = openInBrowser,
6514
6932
  copyUrl,
6933
+ notify,
6515
6934
  onQuit
6516
6935
  }) {
6517
6936
  const { width } = useTerminalDimensions3();
6518
- const renderer2 = useRenderer2();
6937
+ const renderer2 = useRenderer3();
6519
6938
  const defaultCopyUrl = useMemo2(() => createClipboardCopier(renderer2), [renderer2]);
6520
6939
  const copyLink = copyUrl ?? defaultCopyUrl;
6521
6940
  const [ui, dispatchUi] = useReducer(uiReducer, initialUiState);
@@ -6523,15 +6942,24 @@ function App({
6523
6942
  const [options, setOptions] = useState4(initial2);
6524
6943
  const [saved2, setSaved] = useState4(initialSaved);
6525
6944
  const [noCache2, setNoCache] = useState4(initialNoCache);
6945
+ const [autoReload2, setAutoReload] = useState4(initialAutoReload);
6946
+ const [reloadInterval2, setReloadInterval] = useState4(initialReloadInterval);
6947
+ const [notifications2, setNotifications] = useState4(initialNotifications);
6948
+ const [notifyChannel2, setNotifyChannel] = useState4(initialNotifyChannel);
6526
6949
  const [copyLinks2, setCopyLinks] = useState4(initialCopyLinks);
6527
6950
  const [themeState, setThemeState] = useState4(initialTheme);
6951
+ const defaultNotify = useMemo2(
6952
+ () => createNotifier(notificationBoundary(renderer2), notifyChannel2),
6953
+ [renderer2, notifyChannel2]
6954
+ );
6955
+ const notifier = notify ?? defaultNotify;
6528
6956
  const copyRow = (row) => {
6529
6957
  copyLink(row.url, (message) => {
6530
6958
  dispatchUi({ type: "openErrorReported", message });
6531
6959
  });
6532
6960
  dispatchUi({ type: "copyReported", message: `copied ${row.ref} to the clipboard` });
6533
6961
  };
6534
- useEffect6(() => {
6962
+ useEffect7(() => {
6535
6963
  if (ui.successNotice === null) {
6536
6964
  return void 0;
6537
6965
  }
@@ -6542,10 +6970,13 @@ function App({
6542
6970
  clearTimeout(timer);
6543
6971
  };
6544
6972
  }, [ui.successNotice]);
6545
- const reviewScrollRef = useRef5(null);
6546
- const sizeScrollRef = useRef5(null);
6547
- const commentScrollRef = useRef5(null);
6548
- const mergedScrollRef = useRef5(null);
6973
+ const reviewScrollRef = useRef6(null);
6974
+ const sizeScrollRef = useRef6(null);
6975
+ const commentScrollRef = useRef6(null);
6976
+ const mergedScrollRef = useRef6(null);
6977
+ const notifyReviewChanges = useReviewNotifications(notifications2, notifier, (message) => {
6978
+ dispatchUi({ type: "openErrorReported", message });
6979
+ });
6549
6980
  const { raw, isSnapshot, loading, load, error, stale, reload } = useLoader(options, noCache2, (data) => {
6550
6981
  dispatchBrowse({
6551
6982
  type: "dataLoaded",
@@ -6558,10 +6989,12 @@ function App({
6558
6989
  merged: buildMergedRepoOptions(data)
6559
6990
  }
6560
6991
  });
6992
+ notifyReviewChanges(fetchParamsKey(options), data.reviewResults);
6561
6993
  });
6994
+ useAutoReload(autoReload2 ? reloadIntervalMs(reloadInterval2) : null, loading, reload);
6562
6995
  const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, browse.expanded, themeState);
6563
6996
  const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
6564
- const draftRef = useRef5("");
6997
+ const draftRef = useRef6("");
6565
6998
  const commitField = () => {
6566
6999
  const field = FIELDS[ui.selectedField];
6567
7000
  const value2 = draftRef.current.trim();
@@ -6579,6 +7012,20 @@ function App({
6579
7012
  throw error2;
6580
7013
  }
6581
7014
  };
7015
+ const commitReloadInterval = () => {
7016
+ const value2 = draftRef.current.trim();
7017
+ try {
7018
+ parseReloadInterval(value2);
7019
+ setReloadInterval(value2);
7020
+ dispatchUi({ type: "settingCommitted", action: saveReloadInterval(value2) ? "saved" : "notSaved" });
7021
+ } catch (error2) {
7022
+ if (error2 instanceof CliError) {
7023
+ dispatchUi({ type: "settingErrorReported", message: error2.message });
7024
+ return;
7025
+ }
7026
+ throw error2;
7027
+ }
7028
+ };
6582
7029
  const commitThemeColor = () => {
6583
7030
  try {
6584
7031
  const next = withColorOverride(themeState, THEME_COLORS[ui.selectedThemeColor].key, draftRef.current.trim());
@@ -6594,10 +7041,17 @@ function App({
6594
7041
  }
6595
7042
  };
6596
7043
  useKeyboard((key) => {
7044
+ if (ui.editing && key.name === "escape") {
7045
+ renderer2.currentFocusedRenderable?.blur();
7046
+ }
6597
7047
  handleAppKey(key, {
6598
7048
  ui,
6599
7049
  browse,
6600
7050
  noCache: noCache2,
7051
+ autoReload: autoReload2,
7052
+ reloadInterval: reloadInterval2,
7053
+ notifications: notifications2,
7054
+ notifyChannel: notifyChannel2,
6601
7055
  copyLinks: copyLinks2,
6602
7056
  themeState,
6603
7057
  options,
@@ -6608,11 +7062,15 @@ function App({
6608
7062
  setOptions,
6609
7063
  setSaved,
6610
7064
  setNoCache,
7065
+ setAutoReload,
7066
+ setNotifications,
7067
+ setNotifyChannel,
6611
7068
  setCopyLinks,
6612
7069
  setThemeState,
6613
7070
  quit: onQuit,
6614
7071
  reload,
6615
7072
  openUrl,
7073
+ notify: notifier,
6616
7074
  copyRow,
6617
7075
  beginEdit: (value2) => {
6618
7076
  draftRef.current = value2;
@@ -6626,7 +7084,16 @@ function App({
6626
7084
  });
6627
7085
  const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
6628
7086
  return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
6629
- /* @__PURE__ */ jsx15(Header, { options, raw, error, spinning: showLoad }),
7087
+ /* @__PURE__ */ jsx15(
7088
+ Header,
7089
+ {
7090
+ options,
7091
+ raw,
7092
+ error,
7093
+ spinning: showLoad,
7094
+ reloadEvery: autoReload2 ? reloadInterval2 : null
7095
+ }
7096
+ ),
6630
7097
  /* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
6631
7098
  /* @__PURE__ */ jsx15(
6632
7099
  MainPanel,
@@ -6669,12 +7136,17 @@ function App({
6669
7136
  options,
6670
7137
  saved: saved2,
6671
7138
  noCache: noCache2,
7139
+ autoReload: autoReload2,
7140
+ reloadInterval: reloadInterval2,
7141
+ notifications: notifications2,
7142
+ notifyChannel: notifyChannel2,
6672
7143
  copyLinks: copyLinks2,
6673
7144
  themeState,
6674
7145
  onDraft: (value2) => {
6675
7146
  draftRef.current = value2;
6676
7147
  },
6677
7148
  onSubmitField: commitField,
7149
+ onSubmitReloadInterval: commitReloadInterval,
6678
7150
  onSubmitThemeColor: commitThemeColor,
6679
7151
  onToggleReviewType: (type) => {
6680
7152
  setOptions((previous) => {
@@ -6728,6 +7200,10 @@ function bootstrap() {
6728
7200
  initial: initial2,
6729
7201
  saved: saved2,
6730
7202
  noCache: values["no-cache"],
7203
+ autoReload: settings.autoReload === true,
7204
+ reloadInterval: settings.reloadInterval ?? DEFAULT_RELOAD_INTERVAL,
7205
+ notifications: settings.notifications === true,
7206
+ notifyChannel: settings.notifyChannel ?? "auto",
6731
7207
  copyLinks: settings.copyLinks === true,
6732
7208
  theme: theme3,
6733
7209
  json: values.json
@@ -6742,7 +7218,7 @@ function bootstrap() {
6742
7218
 
6743
7219
  // src/tui/main.tsx
6744
7220
  import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
6745
- var { initial, saved, noCache, copyLinks, theme: theme2, json } = bootstrap();
7221
+ var { initial, saved, noCache, autoReload, reloadInterval, notifications, notifyChannel, copyLinks, theme: theme2, json } = bootstrap();
6746
7222
  if (json) {
6747
7223
  await runJsonStats(initial, noCache);
6748
7224
  }
@@ -6761,6 +7237,10 @@ createRoot(renderer).render(
6761
7237
  initial,
6762
7238
  initialSaved: saved,
6763
7239
  initialNoCache: noCache,
7240
+ initialAutoReload: autoReload,
7241
+ initialReloadInterval: reloadInterval,
7242
+ initialNotifications: notifications,
7243
+ initialNotifyChannel: notifyChannel,
6764
7244
  initialCopyLinks: copyLinks,
6765
7245
  initialTheme: theme2,
6766
7246
  onQuit: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3lm/pr-stats",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "GitHub PR stats in an interactive terminal UI, via the gh CLI or an access token",
5
5
  "type": "module",
6
6
  "author": "Dominic Elm",