@d3lm/pr-stats 0.2.13 → 0.2.14

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 +44 -1
  2. package/dist/tui-app.mjs +518 -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,34 @@ 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. 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`, and `command`. On `auto` the TUI tries the terminal first and falls back to the platform command, while the other two force one path. 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 "Send test notification" row below it 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
+ The macOS fallback 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.
133
+
91
134
  ## Theming
92
135
 
93
136
  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"];
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", or "command"`);
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 posts through the terminal and falls back to the platform command \xB7 the other values force one channel"
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,89 @@ 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 createNotifier(renderer2, channel) {
4173
+ return (title, body, onError) => {
4174
+ if (channel !== "command" && renderer2.triggerNotification(body, title)) {
4175
+ return;
4176
+ }
4177
+ if (channel === "terminal") {
4178
+ onError("the terminal has no notification support, switch the channel to auto or the platform command");
4179
+ return;
4180
+ }
4181
+ sendNotification(title, body, onError);
4182
+ };
4183
+ }
4184
+ function notificationChannel(renderer2, channel) {
4185
+ if (channel === "terminal" || channel === "auto" && renderer2.capabilities?.notifications === true) {
4186
+ return "terminal";
4187
+ }
4188
+ return notificationTool();
4189
+ }
4190
+ function notificationTool() {
4191
+ switch (process.platform) {
4192
+ case "darwin": {
4193
+ return "osascript";
4194
+ }
4195
+ case "linux": {
4196
+ return "notify-send";
4197
+ }
4198
+ default: {
4199
+ return null;
4200
+ }
4201
+ }
4202
+ }
4203
+ function escapeMarkup(text) {
4204
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
4205
+ }
4206
+ function notifyCommand(title, body) {
4207
+ switch (process.platform) {
4208
+ case "darwin": {
4209
+ return {
4210
+ command: "osascript",
4211
+ args: [
4212
+ "-e",
4213
+ "on run argv",
4214
+ "-e",
4215
+ 'display notification (item 2 of argv) with title (item 1 of argv) sound name "default"',
4216
+ "-e",
4217
+ "end run",
4218
+ title,
4219
+ body
4220
+ ]
4221
+ };
4222
+ }
4223
+ case "linux": {
4224
+ return { command: "notify-send", args: ["--app-name=pr-stats", "--", title, escapeMarkup(body)] };
4225
+ }
4226
+ default: {
4227
+ return null;
4228
+ }
4229
+ }
4230
+ }
4231
+ function sendNotification(title, body, onError) {
4232
+ const spec = notifyCommand(title, body);
4233
+ if (spec === null) {
4234
+ onError("desktop notifications are not supported on this platform");
4235
+ return;
4236
+ }
4237
+ const child = spawn(spec.command, spec.args, { stdio: "ignore", detached: true });
4238
+ child.on("error", (error) => {
4239
+ onError(`could not send the notification (${error.message})`);
4240
+ });
4241
+ child.on("exit", (code) => {
4242
+ if (code !== null && code !== 0) {
4243
+ onError(`could not send the notification (${spec.command} exited with ${code})`);
4244
+ }
4245
+ });
4246
+ child.unref();
4247
+ }
4248
+
4088
4249
  // src/tui/components/SettingsModal.tsx
4089
4250
  import { jsx as jsx12, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
4090
4251
  var SECTIONS2 = [];
@@ -4098,12 +4259,23 @@ for (const setting of SETTINGS) {
4098
4259
  }
4099
4260
  function SettingsModal({
4100
4261
  selected,
4262
+ editing,
4263
+ error,
4101
4264
  cacheAction,
4102
4265
  noCache: noCache2,
4266
+ autoReload: autoReload2,
4267
+ reloadInterval: reloadInterval2,
4268
+ notifications: notifications2,
4269
+ notifyChannel: notifyChannel2,
4103
4270
  copyLinks: copyLinks2,
4104
- preset
4271
+ preset,
4272
+ onDraft,
4273
+ onSubmit
4105
4274
  }) {
4106
4275
  const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
4276
+ const renderer2 = useRenderer2();
4277
+ const channelValue = notifyChannel2 === "command" ? notificationTool() ?? "unsupported" : notifyChannel2;
4278
+ const deliveryValue = notificationChannel(renderer2, notifyChannel2) ?? "unsupported";
4107
4279
  return /* @__PURE__ */ jsxs11(ModalFrame, { title: "Settings", children: [
4108
4280
  SECTIONS2.map((section) => /* @__PURE__ */ jsxs11("box", { flexDirection: "column", marginBottom: 1, children: [
4109
4281
  /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.accent, marginLeft: 2, children: section.title }),
@@ -4114,24 +4286,50 @@ function SettingsModal({
4114
4286
  {
4115
4287
  setting,
4116
4288
  isSelected,
4289
+ isEditing: isSelected && editing,
4117
4290
  cacheAction,
4118
4291
  noCache: noCache2,
4292
+ autoReload: autoReload2,
4293
+ reloadInterval: reloadInterval2,
4294
+ notifications: notifications2,
4295
+ channelValue,
4296
+ deliveryValue,
4119
4297
  copyLinks: copyLinks2,
4120
- preset
4298
+ preset,
4299
+ onDraft,
4300
+ onSubmit
4121
4301
  }
4122
4302
  ) }, setting.key);
4123
4303
  })
4124
4304
  ] }, 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 })
4305
+ /* @__PURE__ */ jsx12(
4306
+ "text",
4307
+ {
4308
+ wrapMode: "word",
4309
+ height: 2,
4310
+ fg: error !== null ? theme.error : message?.warn ? theme.warn : theme.muted,
4311
+ marginLeft: 2,
4312
+ marginRight: 2,
4313
+ children: error ?? message?.text ?? SETTINGS[selected].hint
4314
+ }
4315
+ )
4126
4316
  ] });
4127
4317
  }
4128
4318
  function SettingValue({
4129
4319
  setting,
4130
4320
  isSelected,
4321
+ isEditing,
4131
4322
  cacheAction,
4132
4323
  noCache: noCache2,
4324
+ autoReload: autoReload2,
4325
+ reloadInterval: reloadInterval2,
4326
+ notifications: notifications2,
4327
+ channelValue,
4328
+ deliveryValue,
4133
4329
  copyLinks: copyLinks2,
4134
- preset
4330
+ preset,
4331
+ onDraft,
4332
+ onSubmit
4135
4333
  }) {
4136
4334
  switch (setting.key) {
4137
4335
  case "noCache": {
@@ -4140,6 +4338,24 @@ function SettingValue({
4140
4338
  case "clearCache": {
4141
4339
  return /* @__PURE__ */ jsx12(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
4142
4340
  }
4341
+ case "autoReload": {
4342
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: autoReload2 ? "yes" : "no", isSelected });
4343
+ }
4344
+ case "reloadInterval": {
4345
+ if (isEditing) {
4346
+ return /* @__PURE__ */ jsx12(ModalInput, { width: 16, value: reloadInterval2, onDraft, onSubmit });
4347
+ }
4348
+ return /* @__PURE__ */ jsx12(IntervalValue, { value: reloadInterval2, active: autoReload2, isSelected });
4349
+ }
4350
+ case "notifications": {
4351
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: notifications2 ? "yes" : "no", isSelected });
4352
+ }
4353
+ case "notifyChannel": {
4354
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: channelValue, isSelected });
4355
+ }
4356
+ case "testNotification": {
4357
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.text : theme.muted, children: deliveryValue });
4358
+ }
4143
4359
  case "copyLinks": {
4144
4360
  return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
4145
4361
  }
@@ -4170,6 +4386,15 @@ function ToggleValue({ value: value2, isSelected }) {
4170
4386
  }
4171
4387
  return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
4172
4388
  }
4389
+ function IntervalValue({ value: value2, active, isSelected }) {
4390
+ if (!active) {
4391
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: isSelected ? theme.muted : theme.dim, children: value2 });
4392
+ }
4393
+ if (isSelected) {
4394
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }) });
4395
+ }
4396
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
4397
+ }
4173
4398
  function PathValue({ path, confirming, isSelected }) {
4174
4399
  if (confirming) {
4175
4400
  return /* @__PURE__ */ jsx12("text", { wrapMode: "none", children: /* @__PURE__ */ jsx12("b", { fg: theme.warn, children: "enter to confirm" }) });
@@ -4217,24 +4442,7 @@ function ColorRow({
4217
4442
  }) {
4218
4443
  const value2 = themeColorText(color.key);
4219
4444
  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: [
4445
+ 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
4446
  Array.isArray(swatch) ? /* @__PURE__ */ jsxs12(Fragment4, { children: [
4239
4447
  /* @__PURE__ */ jsx13("span", { fg: swatch[0], children: "\u2588" }),
4240
4448
  /* @__PURE__ */ jsx13("span", { fg: swatch[1], children: "\u2588" }),
@@ -4253,10 +4461,15 @@ function Modals({
4253
4461
  options,
4254
4462
  saved: saved2,
4255
4463
  noCache: noCache2,
4464
+ autoReload: autoReload2,
4465
+ reloadInterval: reloadInterval2,
4466
+ notifications: notifications2,
4467
+ notifyChannel: notifyChannel2,
4256
4468
  copyLinks: copyLinks2,
4257
4469
  themeState,
4258
4470
  onDraft,
4259
4471
  onSubmitField,
4472
+ onSubmitReloadInterval,
4260
4473
  onSubmitThemeColor,
4261
4474
  onToggleReviewType,
4262
4475
  onToggleWorkDay
@@ -4282,10 +4495,18 @@ function Modals({
4282
4495
  SettingsModal,
4283
4496
  {
4284
4497
  selected: ui.selectedSetting,
4498
+ editing: ui.editing,
4499
+ error: ui.settingError,
4285
4500
  cacheAction: ui.cacheAction,
4286
4501
  noCache: noCache2,
4502
+ autoReload: autoReload2,
4503
+ reloadInterval: reloadInterval2,
4504
+ notifications: notifications2,
4505
+ notifyChannel: notifyChannel2,
4287
4506
  copyLinks: copyLinks2,
4288
- preset: themeState.preset
4507
+ preset: themeState.preset,
4508
+ onDraft,
4509
+ onSubmit: onSubmitReloadInterval
4289
4510
  }
4290
4511
  );
4291
4512
  }
@@ -4306,12 +4527,31 @@ function Modals({
4306
4527
  return null;
4307
4528
  }
4308
4529
 
4530
+ // src/tui/hooks/useAutoReload.ts
4531
+ import { useEffect as useEffect4, useEffectEvent } from "react";
4532
+ function useAutoReload(intervalMs, loading, reload) {
4533
+ const tick = useEffectEvent(() => {
4534
+ reload();
4535
+ });
4536
+ useEffect4(() => {
4537
+ if (intervalMs === null || loading) {
4538
+ return void 0;
4539
+ }
4540
+ const timer = setTimeout(() => {
4541
+ tick();
4542
+ }, intervalMs);
4543
+ return () => {
4544
+ clearTimeout(timer);
4545
+ };
4546
+ }, [intervalMs, loading]);
4547
+ }
4548
+
4309
4549
  // src/tui/hooks/useDeferredLoading.ts
4310
- import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
4550
+ import { useEffect as useEffect5, useRef as useRef3, useState as useState2 } from "react";
4311
4551
  function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
4312
4552
  const [visible, setVisible] = useState2(isLoading && showDelay === 0);
4313
4553
  const shownAtRef = useRef3(null);
4314
- useEffect4(() => {
4554
+ useEffect5(() => {
4315
4555
  if (isLoading) {
4316
4556
  const timer2 = setTimeout(() => {
4317
4557
  shownAtRef.current = Date.now();
@@ -4335,7 +4575,7 @@ function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } =
4335
4575
  }
4336
4576
 
4337
4577
  // src/tui/hooks/useLoader.ts
4338
- import { useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
4578
+ import { useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
4339
4579
  function useLoader(options, noCache2, onLoaded) {
4340
4580
  const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
4341
4581
  const [raw, setRaw] = useState3(startupSnapshot);
@@ -4385,7 +4625,7 @@ function useLoader(options, noCache2, onLoaded) {
4385
4625
  void run(bypassCache || noCache2);
4386
4626
  };
4387
4627
  const startedRef = useRef4(false);
4388
- useEffect5(() => {
4628
+ useEffect6(() => {
4389
4629
  disposedRef.current = false;
4390
4630
  if (!startedRef.current) {
4391
4631
  startedRef.current = true;
@@ -4406,6 +4646,85 @@ function useLoader(options, noCache2, onLoaded) {
4406
4646
  };
4407
4647
  }
4408
4648
 
4649
+ // src/tui/hooks/useReviewNotifications.ts
4650
+ import { useRef as useRef5 } from "react";
4651
+
4652
+ // src/tui/data/notifications.ts
4653
+ function diffReviewRequests(previous, results) {
4654
+ const baseline = /* @__PURE__ */ new Map();
4655
+ const reviewedKeys = /* @__PURE__ */ new Set();
4656
+ const pending = [];
4657
+ for (const result of results) {
4658
+ const key = `${result.pr.repo}#${result.pr.number}`;
4659
+ if (result.kind === "reviewed") {
4660
+ reviewedKeys.add(key);
4661
+ } else if (result.kind === "pending" && result.pr.state === "open") {
4662
+ const requestedAt = result.requestedAt.getTime();
4663
+ baseline.set(key, requestedAt);
4664
+ pending.push({ key, pr: result.pr, requestedAt });
4665
+ }
4666
+ }
4667
+ if (previous === null) {
4668
+ return { baseline, newRequests: [], reRequests: [] };
4669
+ }
4670
+ const newRequests = [];
4671
+ const reRequests = [];
4672
+ for (const { key, pr, requestedAt } of pending) {
4673
+ const before = previous.get(key);
4674
+ if (before !== void 0 && before >= requestedAt) {
4675
+ continue;
4676
+ }
4677
+ (reviewedKeys.has(key) ? reRequests : newRequests).push(pr);
4678
+ }
4679
+ return { baseline, newRequests, reRequests };
4680
+ }
4681
+ var MAX_LISTED = 3;
4682
+ var TEST_NOTIFICATION = {
4683
+ title: "pr-stats",
4684
+ body: "Desktop notifications are working. New review requests show up like this."
4685
+ };
4686
+ function describeReviewRequests(changes) {
4687
+ const notifications2 = [];
4688
+ if (changes.newRequests.length > 0) {
4689
+ notifications2.push(describe(changes.newRequests, "Review requested on", "new PRs awaiting your review"));
4690
+ }
4691
+ if (changes.reRequests.length > 0) {
4692
+ notifications2.push(describe(changes.reRequests, "Review re-requested on", "PRs came back for review"));
4693
+ }
4694
+ return notifications2;
4695
+ }
4696
+ function describe(prs, singleTitle, pluralTitle) {
4697
+ if (prs.length === 1) {
4698
+ const [pr] = prs;
4699
+ return { title: `${singleTitle} ${refOf(pr)}`, body: pr.title };
4700
+ }
4701
+ const lines = prs.slice(0, MAX_LISTED).map((pr) => `${refOf(pr)} ${pr.title}`);
4702
+ const rest = prs.length - lines.length;
4703
+ if (rest > 0) {
4704
+ lines.push(`and ${rest} more`);
4705
+ }
4706
+ return { title: `${prs.length} ${pluralTitle}`, body: lines.join("\n") };
4707
+ }
4708
+ function refOf(pr) {
4709
+ return `${pr.repo}#${pr.number}`;
4710
+ }
4711
+
4712
+ // src/tui/hooks/useReviewNotifications.ts
4713
+ function useReviewNotifications(enabled2, notify, onError) {
4714
+ const baselineRef = useRef5(null);
4715
+ return (key, results) => {
4716
+ const previous = baselineRef.current?.key === key ? baselineRef.current.baseline : null;
4717
+ const changes = diffReviewRequests(previous, results);
4718
+ baselineRef.current = { key, baseline: changes.baseline };
4719
+ if (!enabled2) {
4720
+ return;
4721
+ }
4722
+ for (const notification of describeReviewRequests(changes)) {
4723
+ notify(notification.title, notification.body, onError);
4724
+ }
4725
+ };
4726
+ }
4727
+
4409
4728
  // src/tui/hooks/useViewModel.ts
4410
4729
  import { useMemo } from "react";
4411
4730
 
@@ -6081,6 +6400,43 @@ function handleSettingsModalKey(key, context) {
6081
6400
  }
6082
6401
  break;
6083
6402
  }
6403
+ case "autoReload": {
6404
+ const next = !context.autoReload;
6405
+ context.setAutoReload(next);
6406
+ context.dispatchUi({ type: "cacheActionReported", action: saveAutoReload(next) ? "saved" : "notSaved" });
6407
+ break;
6408
+ }
6409
+ case "reloadInterval": {
6410
+ if (key.name !== "return") {
6411
+ break;
6412
+ }
6413
+ context.beginEdit(context.reloadInterval);
6414
+ break;
6415
+ }
6416
+ case "notifications": {
6417
+ const next = !context.notifications;
6418
+ context.setNotifications(next);
6419
+ context.dispatchUi({ type: "cacheActionReported", action: saveNotifications(next) ? "saved" : "notSaved" });
6420
+ break;
6421
+ }
6422
+ case "notifyChannel": {
6423
+ const index = NOTIFY_CHANNELS.indexOf(context.notifyChannel);
6424
+ const step = key.name === "left" ? -1 : 1;
6425
+ const next = NOTIFY_CHANNELS[(index + step + NOTIFY_CHANNELS.length) % NOTIFY_CHANNELS.length];
6426
+ context.setNotifyChannel(next);
6427
+ context.dispatchUi({ type: "cacheActionReported", action: saveNotifyChannel(next) ? "saved" : "notSaved" });
6428
+ break;
6429
+ }
6430
+ case "testNotification": {
6431
+ if (key.name !== "return") {
6432
+ break;
6433
+ }
6434
+ context.notify(TEST_NOTIFICATION.title, TEST_NOTIFICATION.body, (message) => {
6435
+ context.dispatchUi({ type: "openErrorReported", message });
6436
+ });
6437
+ context.dispatchUi({ type: "cacheActionReported", action: "notified" });
6438
+ break;
6439
+ }
6084
6440
  case "copyLinks": {
6085
6441
  const next = !context.copyLinks;
6086
6442
  context.setCopyLinks(next);
@@ -6355,6 +6711,7 @@ var initialUiState = {
6355
6711
  selectedSetting: 0,
6356
6712
  selectedThemeColor: 0,
6357
6713
  fieldError: null,
6714
+ settingError: null,
6358
6715
  themeColorError: null,
6359
6716
  cacheAction: null,
6360
6717
  openError: null,
@@ -6381,7 +6738,14 @@ function uiReducer(state, action) {
6381
6738
  return { ...state, successNotice: null };
6382
6739
  }
6383
6740
  case "modalOpened": {
6384
- return { ...state, modal: action.modal, fieldError: null, themeColorError: null, cacheAction: null };
6741
+ return {
6742
+ ...state,
6743
+ modal: action.modal,
6744
+ fieldError: null,
6745
+ settingError: null,
6746
+ themeColorError: null,
6747
+ cacheAction: null
6748
+ };
6385
6749
  }
6386
6750
  case "optionsModalClosed": {
6387
6751
  return { ...state, modal: null, fieldError: null };
@@ -6390,7 +6754,7 @@ function uiReducer(state, action) {
6390
6754
  if (state.cacheAction === "confirm" || state.cacheAction === "resetConfirm") {
6391
6755
  return { ...state, cacheAction: null };
6392
6756
  }
6393
- return { ...state, modal: null, cacheAction: null };
6757
+ return { ...state, modal: null, settingError: null, cacheAction: null };
6394
6758
  }
6395
6759
  case "themeModalClosed": {
6396
6760
  return { ...state, modal: "settings", themeColorError: null, cacheAction: null };
@@ -6402,6 +6766,7 @@ function uiReducer(state, action) {
6402
6766
  return {
6403
6767
  ...state,
6404
6768
  selectedSetting: cycled2(state.selectedSetting, action.delta, SETTINGS.length),
6769
+ settingError: null,
6405
6770
  cacheAction: null
6406
6771
  };
6407
6772
  }
@@ -6414,10 +6779,10 @@ function uiReducer(state, action) {
6414
6779
  };
6415
6780
  }
6416
6781
  case "editStarted": {
6417
- return { ...state, editing: true, fieldError: null, themeColorError: null };
6782
+ return { ...state, editing: true, fieldError: null, settingError: null, themeColorError: null };
6418
6783
  }
6419
6784
  case "editCancelled": {
6420
- return { ...state, editing: false, fieldError: null, themeColorError: null };
6785
+ return { ...state, editing: false, fieldError: null, settingError: null, themeColorError: null };
6421
6786
  }
6422
6787
  case "fieldCommitted": {
6423
6788
  return { ...state, editing: false, fieldError: null };
@@ -6431,6 +6796,12 @@ function uiReducer(state, action) {
6431
6796
  }
6432
6797
  return { ...state, fieldError: "the cache is disabled for this session \xB7 options not saved" };
6433
6798
  }
6799
+ case "settingCommitted": {
6800
+ return { ...state, editing: false, settingError: null, cacheAction: action.action };
6801
+ }
6802
+ case "settingErrorReported": {
6803
+ return { ...state, settingError: action.message };
6804
+ }
6434
6805
  case "themeColorCommitted": {
6435
6806
  return { ...state, editing: false, themeColorError: null, cacheAction: action.action };
6436
6807
  }
@@ -6445,7 +6816,7 @@ function uiReducer(state, action) {
6445
6816
  }
6446
6817
 
6447
6818
  // src/tui/utils/browser.ts
6448
- import { spawn } from "node:child_process";
6819
+ import { spawn as spawn2 } from "node:child_process";
6449
6820
  function openCommand(url) {
6450
6821
  switch (process.platform) {
6451
6822
  case "darwin": {
@@ -6461,7 +6832,7 @@ function openCommand(url) {
6461
6832
  }
6462
6833
  function openInBrowser(url, onError) {
6463
6834
  const { command, args } = openCommand(url);
6464
- const child = spawn(command, args, { stdio: "ignore", detached: true });
6835
+ const child = spawn2(command, args, { stdio: "ignore", detached: true });
6465
6836
  child.on("error", (error) => {
6466
6837
  onError(`could not open the browser (${error.message})`);
6467
6838
  });
@@ -6508,14 +6879,19 @@ function App({
6508
6879
  initial: initial2,
6509
6880
  initialSaved = null,
6510
6881
  initialNoCache = false,
6882
+ initialAutoReload = false,
6883
+ initialReloadInterval = DEFAULT_RELOAD_INTERVAL,
6884
+ initialNotifications = false,
6885
+ initialNotifyChannel = "auto",
6511
6886
  initialCopyLinks = false,
6512
6887
  initialTheme = defaultThemeState(),
6513
6888
  openUrl = openInBrowser,
6514
6889
  copyUrl,
6890
+ notify,
6515
6891
  onQuit
6516
6892
  }) {
6517
6893
  const { width } = useTerminalDimensions3();
6518
- const renderer2 = useRenderer2();
6894
+ const renderer2 = useRenderer3();
6519
6895
  const defaultCopyUrl = useMemo2(() => createClipboardCopier(renderer2), [renderer2]);
6520
6896
  const copyLink = copyUrl ?? defaultCopyUrl;
6521
6897
  const [ui, dispatchUi] = useReducer(uiReducer, initialUiState);
@@ -6523,15 +6899,21 @@ function App({
6523
6899
  const [options, setOptions] = useState4(initial2);
6524
6900
  const [saved2, setSaved] = useState4(initialSaved);
6525
6901
  const [noCache2, setNoCache] = useState4(initialNoCache);
6902
+ const [autoReload2, setAutoReload] = useState4(initialAutoReload);
6903
+ const [reloadInterval2, setReloadInterval] = useState4(initialReloadInterval);
6904
+ const [notifications2, setNotifications] = useState4(initialNotifications);
6905
+ const [notifyChannel2, setNotifyChannel] = useState4(initialNotifyChannel);
6526
6906
  const [copyLinks2, setCopyLinks] = useState4(initialCopyLinks);
6527
6907
  const [themeState, setThemeState] = useState4(initialTheme);
6908
+ const defaultNotify = useMemo2(() => createNotifier(renderer2, notifyChannel2), [renderer2, notifyChannel2]);
6909
+ const notifier = notify ?? defaultNotify;
6528
6910
  const copyRow = (row) => {
6529
6911
  copyLink(row.url, (message) => {
6530
6912
  dispatchUi({ type: "openErrorReported", message });
6531
6913
  });
6532
6914
  dispatchUi({ type: "copyReported", message: `copied ${row.ref} to the clipboard` });
6533
6915
  };
6534
- useEffect6(() => {
6916
+ useEffect7(() => {
6535
6917
  if (ui.successNotice === null) {
6536
6918
  return void 0;
6537
6919
  }
@@ -6542,10 +6924,13 @@ function App({
6542
6924
  clearTimeout(timer);
6543
6925
  };
6544
6926
  }, [ui.successNotice]);
6545
- const reviewScrollRef = useRef5(null);
6546
- const sizeScrollRef = useRef5(null);
6547
- const commentScrollRef = useRef5(null);
6548
- const mergedScrollRef = useRef5(null);
6927
+ const reviewScrollRef = useRef6(null);
6928
+ const sizeScrollRef = useRef6(null);
6929
+ const commentScrollRef = useRef6(null);
6930
+ const mergedScrollRef = useRef6(null);
6931
+ const notifyReviewChanges = useReviewNotifications(notifications2, notifier, (message) => {
6932
+ dispatchUi({ type: "openErrorReported", message });
6933
+ });
6549
6934
  const { raw, isSnapshot, loading, load, error, stale, reload } = useLoader(options, noCache2, (data) => {
6550
6935
  dispatchBrowse({
6551
6936
  type: "dataLoaded",
@@ -6558,10 +6943,12 @@ function App({
6558
6943
  merged: buildMergedRepoOptions(data)
6559
6944
  }
6560
6945
  });
6946
+ notifyReviewChanges(fetchParamsKey(options), data.reviewResults);
6561
6947
  });
6948
+ useAutoReload(autoReload2 ? reloadIntervalMs(reloadInterval2) : null, loading, reload);
6562
6949
  const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, browse.expanded, themeState);
6563
6950
  const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
6564
- const draftRef = useRef5("");
6951
+ const draftRef = useRef6("");
6565
6952
  const commitField = () => {
6566
6953
  const field = FIELDS[ui.selectedField];
6567
6954
  const value2 = draftRef.current.trim();
@@ -6579,6 +6966,20 @@ function App({
6579
6966
  throw error2;
6580
6967
  }
6581
6968
  };
6969
+ const commitReloadInterval = () => {
6970
+ const value2 = draftRef.current.trim();
6971
+ try {
6972
+ parseReloadInterval(value2);
6973
+ setReloadInterval(value2);
6974
+ dispatchUi({ type: "settingCommitted", action: saveReloadInterval(value2) ? "saved" : "notSaved" });
6975
+ } catch (error2) {
6976
+ if (error2 instanceof CliError) {
6977
+ dispatchUi({ type: "settingErrorReported", message: error2.message });
6978
+ return;
6979
+ }
6980
+ throw error2;
6981
+ }
6982
+ };
6582
6983
  const commitThemeColor = () => {
6583
6984
  try {
6584
6985
  const next = withColorOverride(themeState, THEME_COLORS[ui.selectedThemeColor].key, draftRef.current.trim());
@@ -6594,10 +6995,17 @@ function App({
6594
6995
  }
6595
6996
  };
6596
6997
  useKeyboard((key) => {
6998
+ if (ui.editing && key.name === "escape") {
6999
+ renderer2.currentFocusedRenderable?.blur();
7000
+ }
6597
7001
  handleAppKey(key, {
6598
7002
  ui,
6599
7003
  browse,
6600
7004
  noCache: noCache2,
7005
+ autoReload: autoReload2,
7006
+ reloadInterval: reloadInterval2,
7007
+ notifications: notifications2,
7008
+ notifyChannel: notifyChannel2,
6601
7009
  copyLinks: copyLinks2,
6602
7010
  themeState,
6603
7011
  options,
@@ -6608,11 +7016,15 @@ function App({
6608
7016
  setOptions,
6609
7017
  setSaved,
6610
7018
  setNoCache,
7019
+ setAutoReload,
7020
+ setNotifications,
7021
+ setNotifyChannel,
6611
7022
  setCopyLinks,
6612
7023
  setThemeState,
6613
7024
  quit: onQuit,
6614
7025
  reload,
6615
7026
  openUrl,
7027
+ notify: notifier,
6616
7028
  copyRow,
6617
7029
  beginEdit: (value2) => {
6618
7030
  draftRef.current = value2;
@@ -6626,7 +7038,16 @@ function App({
6626
7038
  });
6627
7039
  const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
6628
7040
  return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
6629
- /* @__PURE__ */ jsx15(Header, { options, raw, error, spinning: showLoad }),
7041
+ /* @__PURE__ */ jsx15(
7042
+ Header,
7043
+ {
7044
+ options,
7045
+ raw,
7046
+ error,
7047
+ spinning: showLoad,
7048
+ reloadEvery: autoReload2 ? reloadInterval2 : null
7049
+ }
7050
+ ),
6630
7051
  /* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
6631
7052
  /* @__PURE__ */ jsx15(
6632
7053
  MainPanel,
@@ -6669,12 +7090,17 @@ function App({
6669
7090
  options,
6670
7091
  saved: saved2,
6671
7092
  noCache: noCache2,
7093
+ autoReload: autoReload2,
7094
+ reloadInterval: reloadInterval2,
7095
+ notifications: notifications2,
7096
+ notifyChannel: notifyChannel2,
6672
7097
  copyLinks: copyLinks2,
6673
7098
  themeState,
6674
7099
  onDraft: (value2) => {
6675
7100
  draftRef.current = value2;
6676
7101
  },
6677
7102
  onSubmitField: commitField,
7103
+ onSubmitReloadInterval: commitReloadInterval,
6678
7104
  onSubmitThemeColor: commitThemeColor,
6679
7105
  onToggleReviewType: (type) => {
6680
7106
  setOptions((previous) => {
@@ -6728,6 +7154,10 @@ function bootstrap() {
6728
7154
  initial: initial2,
6729
7155
  saved: saved2,
6730
7156
  noCache: values["no-cache"],
7157
+ autoReload: settings.autoReload === true,
7158
+ reloadInterval: settings.reloadInterval ?? DEFAULT_RELOAD_INTERVAL,
7159
+ notifications: settings.notifications === true,
7160
+ notifyChannel: settings.notifyChannel ?? "auto",
6731
7161
  copyLinks: settings.copyLinks === true,
6732
7162
  theme: theme3,
6733
7163
  json: values.json
@@ -6742,7 +7172,7 @@ function bootstrap() {
6742
7172
 
6743
7173
  // src/tui/main.tsx
6744
7174
  import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
6745
- var { initial, saved, noCache, copyLinks, theme: theme2, json } = bootstrap();
7175
+ var { initial, saved, noCache, autoReload, reloadInterval, notifications, notifyChannel, copyLinks, theme: theme2, json } = bootstrap();
6746
7176
  if (json) {
6747
7177
  await runJsonStats(initial, noCache);
6748
7178
  }
@@ -6761,6 +7191,10 @@ createRoot(renderer).render(
6761
7191
  initial,
6762
7192
  initialSaved: saved,
6763
7193
  initialNoCache: noCache,
7194
+ initialAutoReload: autoReload,
7195
+ initialReloadInterval: reloadInterval,
7196
+ initialNotifications: notifications,
7197
+ initialNotifyChannel: notifyChannel,
6764
7198
  initialCopyLinks: copyLinks,
6765
7199
  initialTheme: theme2,
6766
7200
  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.14",
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",