@d3lm/pr-stats 0.2.5 → 0.2.6

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 +2 -2
  2. package/dist/tui-app.mjs +210 -113
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pr-stats
2
2
 
3
- An interactive terminal UI, built with [OpenTUI](https://github.com/anomalyco/opentui) and React, that shows statistics for a GitHub user, e.g., time to review, size of authored PRs, or comments received on them. It works with any repository your GitHub login can see. It has a queue tab for the open PRs awaiting your review, a Your PRs tab that splits into your open PRs and a merged-and-closed report, chart tabs for the review-time, size, and comments reports, and a live options modal.
3
+ An interactive terminal UI, built with [OpenTUI](https://github.com/anomalyco/opentui) and React, that shows statistics for a GitHub user, e.g., time to review, size of authored PRs, or comments received on them. It works with any repository your GitHub login can see. It has a queue tab for the open PRs on your reviewing plate, split into those awaiting your review and those you already reviewed, a Your PRs tab that splits into your open PRs and a merged-and-closed report, chart tabs for the review-time, size, and comments reports, and a live options modal.
4
4
 
5
5
  ![pr-stats](./assets/screenshot.png)
6
6
 
@@ -25,7 +25,7 @@ You can also run it without installing through `npx @d3lm/pr-stats`. The bunx eq
25
25
  pr-stats
26
26
  ```
27
27
 
28
- Without flags, it looks at PRs from the last 90 days across all repositories you can access. It opens on a tab that lists the open PRs awaiting your review with how long each has been waiting. The Your PRs tab next to it has two sub-tabs, which the `t` key switches. The first lists your own authored PRs that are still open with their age and size. The second charts how your authored PRs got created, merged, and closed, telling a merge apart from a close without one, with time-to-merge percentiles, a histogram and trend, a merge-time heatmap, weekly created and merged volumes, an outcome gauge, and the most recently merged and closed PRs. The remaining tabs hold the time-to-review report, the PR size report, and the comments report with a histogram of comments per PR, a scatter of comments against PR size, and the most commented PRs. When the data spans multiple repos, every tab opens on a repo picker that drills into one repo or the aggregate across all of them, and on the two queue lists the `g` key groups the aggregate list by repo.
28
+ Without flags, it looks at PRs from the last 90 days across all repositories you can access. It opens on a tab with two lists, the open PRs awaiting your review with how long each has been waiting, and below them the open PRs you already reviewed or commented on with how long ago that was, so a PR stays visible until it merges or closes. A fresh review request moves a PR from the reviewing list back into the awaiting one. The Your PRs tab next to it has two sub-tabs, which the `t` key switches. The first lists your own authored PRs that are still open with their age and size. The second charts how your authored PRs got created, merged, and closed, telling a merge apart from a close without one, with time-to-merge percentiles, a histogram and trend, a merge-time heatmap, weekly created and merged volumes, an outcome gauge, and the most recently merged and closed PRs. The remaining tabs hold the time-to-review report, the PR size report, and the comments report with a histogram of comments per PR, a scatter of comments against PR size, and the most commented PRs. When the data spans multiple repos, every tab opens on a repo picker that drills into one repo or the aggregate across all of them, and on the two queue lists the `g` key groups the aggregate list by repo.
29
29
 
30
30
  Node gates the FFI that OpenTUI renders through behind the `--experimental-ffi` flag, and the launcher re-executes itself with that flag when it is missing, so a plain `pr-stats` works without extra flags on both runtimes.
31
31
 
package/dist/tui-app.mjs CHANGED
@@ -787,6 +787,24 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
787
787
  }
788
788
  }
789
789
  pending.sort((a, b) => a.requestedAt.getTime() - b.requestedAt.getTime());
790
+ const pendingKeys = new Set(pending.map((entry) => `${entry.pr.repo}#${entry.pr.number}`));
791
+ const latestReviews = /* @__PURE__ */ new Map();
792
+ for (const result of results) {
793
+ if (result.kind !== "reviewed" && result.kind !== "unrequested" || result.pr.state !== "open") {
794
+ continue;
795
+ }
796
+ const key = `${result.pr.repo}#${result.pr.number}`;
797
+ if (pendingKeys.has(key)) {
798
+ continue;
799
+ }
800
+ const latest = latestReviews.get(key);
801
+ if (latest === void 0 || result.reviewedAt > latest.reviewedAt) {
802
+ latestReviews.set(key, { pr: result.pr, reviewedAt: result.reviewedAt });
803
+ }
804
+ }
805
+ const reviewing = [...latestReviews.values()].map(({ pr, reviewedAt }) => {
806
+ return { pr, reviewedAt, hours: durationHours(reviewedAt, now) };
807
+ }).toSorted((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
790
808
  const expired = results.filter((result) => result.kind === "pending" && result.pr.state !== "open");
791
809
  const unrequested = results.filter((result) => result.kind === "unrequested");
792
810
  const allHours = reviewed.map((result) => result.hours);
@@ -798,7 +816,7 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
798
816
  }
799
817
  const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
800
818
  const misses = targetHours === void 0 ? [] : reviewed.filter((result) => result.hours > targetHours).toSorted((a, b) => b.hours - a.hours);
801
- return { reviewed, pending, expired, unrequested, allHours, byRepo, misses };
819
+ return { reviewed, pending, reviewing, expired, unrequested, allHours, byRepo, misses };
802
820
  }
803
821
  function computeSizeStats(sizes, { sizeTarget } = {}) {
804
822
  const metrics = [
@@ -948,7 +966,7 @@ function toPrRows(entries, leads) {
948
966
 
949
967
  // src/tui/views/queue.ts
950
968
  function queueRows(view) {
951
- return view.lists.flatMap((list) => list.rows);
969
+ return view.sections.flatMap((section) => [...section.rows, ...section.lists.flatMap((list) => list.rows)]);
952
970
  }
953
971
  function groupedLists(entries, rowsOf2) {
954
972
  const groups = /* @__PURE__ */ new Map();
@@ -962,23 +980,26 @@ function groupedLists(entries, rowsOf2) {
962
980
  });
963
981
  }
964
982
  function buildPendingReviewView(raw, repo = null, grouped = false) {
965
- const { pending } = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
966
- const entries = repo === null ? pending : pending.filter((entry) => entry.pr.repo === repo);
967
- if (entries.length === 0) {
968
- return { empty: "No PRs are awaiting your review.", lists: [] };
969
- }
970
- if (repo === null && grouped) {
971
- return { empty: null, lists: groupedLists(entries, rowsOf) };
972
- }
983
+ const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
984
+ const awaiting = repo === null ? stats.pending : stats.pending.filter((entry) => entry.pr.repo === repo);
985
+ const reviewing = repo === null ? stats.reviewing : stats.reviewing.filter((entry) => entry.pr.repo === repo);
986
+ if (awaiting.length === 0 && reviewing.length === 0) {
987
+ return { empty: "No PRs are awaiting your review, and none you reviewed are still open.", sections: [] };
988
+ }
989
+ const split = repo === null && grouped;
990
+ const sectionOf = (title, entries) => split ? { title, rows: [], lists: groupedLists(entries, rowsOf) } : { title, rows: rowsOf(entries), lists: [] };
973
991
  return {
974
992
  empty: null,
975
- lists: [{ title: `Open and awaiting your review (n=${entries.length})`, rows: rowsOf(entries) }]
993
+ sections: [
994
+ ...awaiting.length === 0 ? [] : [sectionOf(`Awaiting your review (n=${awaiting.length})`, awaiting)],
995
+ ...reviewing.length === 0 ? [] : [sectionOf(`Reviewing (n=${reviewing.length})`, reviewing)]
996
+ ]
976
997
  };
977
998
  }
978
999
  function buildOpenAuthoredView(raw, repo = null, grouped = false) {
979
1000
  const open = raw.sizes.filter((entry) => entry.pr.state === "open" && (repo === null || entry.pr.repo === repo)).toSorted((a, b) => a.pr.createdAt.getTime() - b.pr.createdAt.getTime());
980
1001
  if (open.length === 0) {
981
- return { empty: "No open authored PRs found.", lists: [] };
1002
+ return { empty: "No open authored PRs found.", sections: [] };
982
1003
  }
983
1004
  const rowsOf2 = (group) => {
984
1005
  const ages = group.map((entry) => durationLead({ hours: durationHours(entry.pr.createdAt, raw.fetchedAt) }));
@@ -990,13 +1011,11 @@ function buildOpenAuthoredView(raw, repo = null, grouped = false) {
990
1011
  )
991
1012
  );
992
1013
  };
1014
+ const title = `Your open authored PRs (n=${open.length})`;
993
1015
  if (repo === null && grouped) {
994
- return { empty: null, lists: groupedLists(open, rowsOf2) };
1016
+ return { empty: null, sections: [{ title, rows: [], lists: groupedLists(open, rowsOf2) }] };
995
1017
  }
996
- return {
997
- empty: null,
998
- lists: [{ title: `Your open authored PRs (n=${open.length})`, rows: rowsOf2(open) }]
999
- };
1018
+ return { empty: null, sections: [{ title, rows: rowsOf2(open), lists: [] }] };
1000
1019
  }
1001
1020
  function rowsOf(group) {
1002
1021
  return toPrRows(
@@ -1210,7 +1229,7 @@ function QueuePanel({
1210
1229
  heading,
1211
1230
  warning,
1212
1231
  empty,
1213
- lists,
1232
+ sections,
1214
1233
  cursor,
1215
1234
  onRefClick
1216
1235
  }) {
@@ -1237,10 +1256,57 @@ function QueuePanel({
1237
1256
  }
1238
1257
  const offsets = [];
1239
1258
  let total = 0;
1240
- for (const list of lists) {
1241
- offsets.push(total);
1242
- total += list.rows.length;
1243
- }
1259
+ for (const section of sections) {
1260
+ const entry = { rows: total, lists: [] };
1261
+ total += section.rows.length;
1262
+ for (const list of section.lists) {
1263
+ entry.lists.push(total);
1264
+ total += list.rows.length;
1265
+ }
1266
+ offsets.push(entry);
1267
+ }
1268
+ const renderRow = (row, index, indent) => {
1269
+ const isSelected = index === cursor;
1270
+ const bg = isSelected ? theme.selectedBg : void 0;
1271
+ const refStart = indent.length + 2 + row.lead.length + 2;
1272
+ return /* @__PURE__ */ jsxs5(
1273
+ "text",
1274
+ {
1275
+ id: `queue-row-${index}`,
1276
+ wrapMode: "none",
1277
+ onMouseDown: onRefClick === null ? void 0 : (event) => {
1278
+ lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
1279
+ },
1280
+ onMouseUp: onRefClick === null ? void 0 : (event) => {
1281
+ const down = lastDown.current;
1282
+ lastDown.current = null;
1283
+ if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
1284
+ return;
1285
+ }
1286
+ const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
1287
+ if (local >= refStart && local < refStart + row.ref.length) {
1288
+ onRefClick(row);
1289
+ }
1290
+ },
1291
+ children: [
1292
+ /* @__PURE__ */ jsxs5("span", { fg: theme.accent, children: [
1293
+ indent,
1294
+ isSelected ? "\u25B8 " : " "
1295
+ ] }),
1296
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1297
+ row.lead,
1298
+ " "
1299
+ ] }),
1300
+ onRefClick === null ? /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }) : /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: row.ref }),
1301
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1302
+ " ",
1303
+ row.title
1304
+ ] })
1305
+ ]
1306
+ },
1307
+ row.url
1308
+ );
1309
+ };
1244
1310
  return /* @__PURE__ */ jsxs5("box", { flexGrow: 1, flexDirection: "column", children: [
1245
1311
  header,
1246
1312
  /* @__PURE__ */ jsxs5(
@@ -1253,49 +1319,19 @@ function QueuePanel({
1253
1319
  verticalScrollbarOptions: overlayScrollbar,
1254
1320
  children: [
1255
1321
  warning !== null && /* @__PURE__ */ jsx6("text", { wrapMode: "word", fg: theme.warn, marginTop: 1, children: warning }),
1256
- lists.map((list, listIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: 1, children: [
1257
- /* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children: list.title }),
1258
- list.rows.map((row, rowIndex) => {
1259
- const index = offsets[listIndex] + rowIndex;
1260
- const isSelected = index === cursor;
1261
- const bg = isSelected ? theme.selectedBg : void 0;
1262
- const refStart = 2 + row.lead.length + 2;
1263
- return /* @__PURE__ */ jsxs5(
1264
- "text",
1265
- {
1266
- id: `queue-row-${index}`,
1267
- wrapMode: "none",
1268
- onMouseDown: onRefClick === null ? void 0 : (event) => {
1269
- lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
1270
- },
1271
- onMouseUp: onRefClick === null ? void 0 : (event) => {
1272
- const down = lastDown.current;
1273
- lastDown.current = null;
1274
- if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
1275
- return;
1276
- }
1277
- const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
1278
- if (local >= refStart && local < refStart + row.ref.length) {
1279
- onRefClick(row);
1280
- }
1281
- },
1282
- children: [
1283
- /* @__PURE__ */ jsx6("span", { fg: theme.accent, children: isSelected ? "\u25B8 " : " " }),
1284
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1285
- row.lead,
1286
- " "
1287
- ] }),
1288
- onRefClick === null ? /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }) : /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: row.ref }),
1289
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1290
- " ",
1291
- row.title
1292
- ] })
1293
- ]
1294
- },
1295
- row.url
1296
- );
1297
- })
1298
- ] }, list.title))
1322
+ sections.map((section, sectionIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: 1, children: [
1323
+ /* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children: section.title }),
1324
+ section.rows.map((row, rowIndex) => renderRow(row, offsets[sectionIndex].rows + rowIndex, "")),
1325
+ section.lists.map((list, listIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: listIndex > 0 ? 1 : 0, children: [
1326
+ /* @__PURE__ */ jsxs5("text", { wrapMode: "none", fg: theme.accent, children: [
1327
+ " ",
1328
+ list.title
1329
+ ] }),
1330
+ list.rows.map(
1331
+ (row, rowIndex) => renderRow(row, offsets[sectionIndex].lists[listIndex] + rowIndex, " ")
1332
+ )
1333
+ ] }, list.title))
1334
+ ] }, section.title))
1299
1335
  ]
1300
1336
  }
1301
1337
  )
@@ -1482,7 +1518,7 @@ function QueueTab({
1482
1518
  heading: repos.length > 0 ? scope.repo ?? (grouped ? "All repos \xB7 grouped by repo" : "All repos") : null,
1483
1519
  warning,
1484
1520
  empty: view.empty,
1485
- lists: view.lists,
1521
+ sections: view.sections,
1486
1522
  cursor: Math.min(rowCursor, queueRows(view).length - 1),
1487
1523
  onRefClick
1488
1524
  }
@@ -2993,6 +3029,62 @@ function ColorRow({
2993
3029
  ] }) });
2994
3030
  }
2995
3031
 
3032
+ // src/tui/components/Modals.tsx
3033
+ import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
3034
+ function Modals({
3035
+ ui,
3036
+ options,
3037
+ saved: saved2,
3038
+ noCache: noCache2,
3039
+ copyLinks: copyLinks2,
3040
+ themeState,
3041
+ onDraft,
3042
+ onSubmitField,
3043
+ onSubmitThemeColor
3044
+ }) {
3045
+ if (ui.modal === "options") {
3046
+ return /* @__PURE__ */ jsx14(
3047
+ OptionsModal,
3048
+ {
3049
+ options,
3050
+ saved: saved2,
3051
+ selected: ui.selectedField,
3052
+ editing: ui.editing,
3053
+ fieldError: ui.fieldError,
3054
+ onDraft,
3055
+ onSubmit: onSubmitField
3056
+ }
3057
+ );
3058
+ }
3059
+ if (ui.modal === "settings") {
3060
+ return /* @__PURE__ */ jsx14(
3061
+ SettingsModal,
3062
+ {
3063
+ selected: ui.selectedSetting,
3064
+ cacheAction: ui.cacheAction,
3065
+ noCache: noCache2,
3066
+ copyLinks: copyLinks2,
3067
+ preset: themeState.preset
3068
+ }
3069
+ );
3070
+ }
3071
+ if (ui.modal === "theme") {
3072
+ return /* @__PURE__ */ jsx14(
3073
+ ThemeModal,
3074
+ {
3075
+ selected: ui.selectedThemeColor,
3076
+ editing: ui.editing,
3077
+ error: ui.themeColorError,
3078
+ cacheAction: ui.cacheAction,
3079
+ overrides: themeState.preset === "custom" ? themeState.overrides : {},
3080
+ onDraft,
3081
+ onSubmit: onSubmitThemeColor
3082
+ }
3083
+ );
3084
+ }
3085
+ return null;
3086
+ }
3087
+
2996
3088
  // src/tui/hooks/useDeferredLoading.ts
2997
3089
  import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
2998
3090
  function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
@@ -3379,7 +3471,10 @@ function classifyPr(pr, details, user) {
3379
3471
  (node) => node?.author?.login === user && node.submittedAt ? [new Date(node.submittedAt)] : []
3380
3472
  );
3381
3473
  if (requests.length === 0) {
3382
- return reviews.length > 0 ? [{ kind: "unrequested", pr }] : [{ kind: "inaccessible", pr }];
3474
+ if (reviews.length === 0) {
3475
+ return [{ kind: "inaccessible", pr }];
3476
+ }
3477
+ return [{ kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.getTime()))) }];
3383
3478
  }
3384
3479
  const events = [
3385
3480
  ...requests.map((at) => {
@@ -3492,6 +3587,9 @@ function reviveRawData(data) {
3492
3587
  if (result.kind === "reviewed") {
3493
3588
  return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
3494
3589
  }
3590
+ if (result.kind === "unrequested") {
3591
+ return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
3592
+ }
3495
3593
  return { ...result, pr };
3496
3594
  }),
3497
3595
  sizes: data.sizes.map((entry) => {
@@ -3518,6 +3616,9 @@ function loadSnapshot(options) {
3518
3616
  return null;
3519
3617
  }
3520
3618
  const data = reviveRawData(stored.data);
3619
+ if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3620
+ return null;
3621
+ }
3521
3622
  if (sinceIso === data.sinceIso) {
3522
3623
  return data;
3523
3624
  }
@@ -3726,24 +3827,42 @@ function buildSizeRepoOptions(raw) {
3726
3827
  })
3727
3828
  ];
3728
3829
  }
3729
- function pendingDetail(count2) {
3730
- return `${count2} ${count2 === 1 ? "PR" : "PRs"} awaiting your review`;
3830
+ function pendingDetail(counts) {
3831
+ const awaiting = `${counts.awaiting} ${counts.awaiting === 1 ? "PR" : "PRs"} awaiting your review`;
3832
+ return awaiting + (counts.reviewing > 0 ? `, ${counts.reviewing} reviewing` : "");
3731
3833
  }
3732
3834
  function buildPendingRepoOptions(raw) {
3733
- const countByRepo = /* @__PURE__ */ new Map();
3835
+ const countsByRepo = /* @__PURE__ */ new Map();
3836
+ const countsOf = (repo) => {
3837
+ const counts = countsByRepo.get(repo) ?? { awaiting: 0, reviewing: 0 };
3838
+ countsByRepo.set(repo, counts);
3839
+ return counts;
3840
+ };
3734
3841
  for (const result of raw.reviewResults) {
3735
- const pending = result.kind === "pending" && result.pr.state === "open" ? 1 : 0;
3736
- countByRepo.set(result.pr.repo, (countByRepo.get(result.pr.repo) ?? 0) + pending);
3842
+ countsOf(result.pr.repo);
3737
3843
  }
3738
- if (countByRepo.size < 2) {
3844
+ if (countsByRepo.size < 2) {
3739
3845
  return [];
3740
3846
  }
3741
- const entries = [...countByRepo.entries()].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
3742
- const total = entries.reduce((sum, [, count2]) => sum + count2, 0);
3847
+ const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
3848
+ for (const entry of stats.pending) {
3849
+ countsOf(entry.pr.repo).awaiting += 1;
3850
+ }
3851
+ for (const entry of stats.reviewing) {
3852
+ countsOf(entry.pr.repo).reviewing += 1;
3853
+ }
3854
+ const entries = [...countsByRepo.entries()].toSorted(
3855
+ (a, b) => b[1].awaiting - a[1].awaiting || b[1].reviewing - a[1].reviewing || a[0].localeCompare(b[0])
3856
+ );
3857
+ const totals = { awaiting: 0, reviewing: 0 };
3858
+ for (const [, counts] of entries) {
3859
+ totals.awaiting += counts.awaiting;
3860
+ totals.reviewing += counts.reviewing;
3861
+ }
3743
3862
  return [
3744
- { repo: null, label: "All repos", detail: pendingDetail(total) },
3745
- ...entries.map(([repo, count2]) => {
3746
- return { repo, label: repo, detail: pendingDetail(count2) };
3863
+ { repo: null, label: "All repos", detail: pendingDetail(totals) },
3864
+ ...entries.map(([repo, counts]) => {
3865
+ return { repo, label: repo, detail: pendingDetail(counts) };
3747
3866
  })
3748
3867
  ];
3749
3868
  }
@@ -5351,7 +5470,7 @@ function createClipboardCopier(renderer2) {
5351
5470
  }
5352
5471
 
5353
5472
  // src/tui/App.tsx
5354
- import { jsx as jsx14, jsxs as jsxs13 } from "@opentui/react/jsx-runtime";
5473
+ import { jsx as jsx15, jsxs as jsxs13 } from "@opentui/react/jsx-runtime";
5355
5474
  function App({
5356
5475
  initial: initial2,
5357
5476
  initialSaved = null,
@@ -5473,9 +5592,9 @@ function App({
5473
5592
  });
5474
5593
  const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
5475
5594
  return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
5476
- /* @__PURE__ */ jsx14(Header, { options, raw, error, spinning: showLoad }),
5477
- /* @__PURE__ */ jsx14(TabBar, { tab: browse.tab }),
5478
- /* @__PURE__ */ jsx14(
5595
+ /* @__PURE__ */ jsx15(Header, { options, raw, error, spinning: showLoad }),
5596
+ /* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
5597
+ /* @__PURE__ */ jsx15(
5479
5598
  MainPanel,
5480
5599
  {
5481
5600
  views,
@@ -5494,7 +5613,7 @@ function App({
5494
5613
  onRefClick: copyLinks2 ? copyRow : null
5495
5614
  }
5496
5615
  ),
5497
- /* @__PURE__ */ jsx14(
5616
+ /* @__PURE__ */ jsx15(
5498
5617
  Footer,
5499
5618
  {
5500
5619
  width,
@@ -5509,42 +5628,20 @@ function App({
5509
5628
  stale
5510
5629
  }
5511
5630
  ),
5512
- ui.modal === "options" && /* @__PURE__ */ jsx14(
5513
- OptionsModal,
5631
+ /* @__PURE__ */ jsx15(
5632
+ Modals,
5514
5633
  {
5634
+ ui,
5515
5635
  options,
5516
5636
  saved: saved2,
5517
- selected: ui.selectedField,
5518
- editing: ui.editing,
5519
- fieldError: ui.fieldError,
5520
- onDraft: (value2) => {
5521
- draftRef.current = value2;
5522
- },
5523
- onSubmit: commitField
5524
- }
5525
- ),
5526
- ui.modal === "settings" && /* @__PURE__ */ jsx14(
5527
- SettingsModal,
5528
- {
5529
- selected: ui.selectedSetting,
5530
- cacheAction: ui.cacheAction,
5531
5637
  noCache: noCache2,
5532
5638
  copyLinks: copyLinks2,
5533
- preset: themeState.preset
5534
- }
5535
- ),
5536
- ui.modal === "theme" && /* @__PURE__ */ jsx14(
5537
- ThemeModal,
5538
- {
5539
- selected: ui.selectedThemeColor,
5540
- editing: ui.editing,
5541
- error: ui.themeColorError,
5542
- cacheAction: ui.cacheAction,
5543
- overrides: themeState.preset === "custom" ? themeState.overrides : {},
5639
+ themeState,
5544
5640
  onDraft: (value2) => {
5545
5641
  draftRef.current = value2;
5546
5642
  },
5547
- onSubmit: commitThemeColor
5643
+ onSubmitField: commitField,
5644
+ onSubmitThemeColor: commitThemeColor
5548
5645
  }
5549
5646
  )
5550
5647
  ] });
@@ -5590,7 +5687,7 @@ function bootstrap() {
5590
5687
  }
5591
5688
 
5592
5689
  // src/tui/main.tsx
5593
- import { jsx as jsx15 } from "@opentui/react/jsx-runtime";
5690
+ import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
5594
5691
  var { initial, saved, noCache, copyLinks, theme: theme2 } = bootstrap();
5595
5692
  var exitSignals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGBUS"];
5596
5693
  var renderer = await createCliRenderer({ exitOnCtrlC: true, exitSignals });
@@ -5601,7 +5698,7 @@ for (const signal of ["SIGTERM", "SIGHUP"]) {
5601
5698
  });
5602
5699
  }
5603
5700
  createRoot(renderer).render(
5604
- /* @__PURE__ */ jsx15(
5701
+ /* @__PURE__ */ jsx16(
5605
5702
  App,
5606
5703
  {
5607
5704
  initial,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3lm/pr-stats",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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",