@d3lm/pr-stats 0.2.0 → 0.2.1

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.
package/README.md CHANGED
@@ -76,7 +76,7 @@ The custom theme joins the Theme row cycle as a sixth entry and persists across
76
76
  }
77
77
  ```
78
78
 
79
- The `preset` key names the active theme, one of `default`, `green`, `blue`, `purple`, `yellow`, and `custom`. The `base` key names the built-in theme the custom colors start from and defaults to `default`, and every other key sets one custom color on top of that base. The color keys are `bg`, `border`, `text`, `muted`, `dim`, `accent`, `selectedBg`, `inputBg`, `inputFocusedBg`, `warn`, `error`, `chartBar`, `chartLine`, `chartDim`, and `heat`. The `heat` key colors the weekly heatmap and takes four colors from cool to hot. A file that sets colors without a `preset` activates the custom theme they define, and a `custom` preset without any colors falls back to its `base` theme. A typo in a key or a color fails the start with a message naming the mistake, so a broken theme never renders.
79
+ The `preset` key names the active theme, one of `default`, `green`, `blue`, `purple`, `yellow`, and `custom`. The `base` key names the built-in theme the custom colors start from and defaults to `default`, and every other key sets one custom color on top of that base. The color keys are `bg`, `border`, `text`, `muted`, `dim`, `accent`, `selectedBg`, `inputBg`, `inputFocusedBg`, `warn`, `error`, `success`, `chartBar`, `chartLine`, `chartDim`, and `heat`. The `heat` key colors the weekly heatmap and takes four colors from cool to hot. A file that sets colors without a `preset` activates the custom theme they define, and a `custom` preset without any colors falls back to its `base` theme. A typo in a key or a color fails the start with a message naming the mistake, so a broken theme never renders.
80
80
 
81
81
  ## Development
82
82
 
package/dist/tui-app.mjs CHANGED
@@ -136,7 +136,7 @@ import { createRoot } from "@opentui/react";
136
136
 
137
137
  // tui/App.tsx
138
138
  import { useKeyboard, useTerminalDimensions as useTerminalDimensions3 } from "@opentui/react";
139
- import { useReducer, useRef as useRef5, useState as useState4 } from "react";
139
+ import { useEffect as useEffect6, useReducer, useRef as useRef5, useState as useState4 } from "react";
140
140
 
141
141
  // settings.ts
142
142
  import { readFileSync as readFileSync2, rmSync as rmSync2 } from "node:fs";
@@ -307,6 +307,9 @@ function loadSettings() {
307
307
  if (settings.noCache !== void 0 && typeof settings.noCache !== "boolean") {
308
308
  throw new CliError(`"noCache" in ${settingsFile()} must be true or false`);
309
309
  }
310
+ if (settings.copyLinks !== void 0 && typeof settings.copyLinks !== "boolean") {
311
+ throw new CliError(`"copyLinks" in ${settingsFile()} must be true or false`);
312
+ }
310
313
  current = settings;
311
314
  return current;
312
315
  }
@@ -319,6 +322,15 @@ function saveNoCache(on) {
319
322
  `);
320
323
  return true;
321
324
  }
325
+ function saveCopyLinks(on) {
326
+ current = { ...current, copyLinks: on };
327
+ if (!cacheEnabled()) {
328
+ return false;
329
+ }
330
+ writeFileAtomic(settingsFile(), `${JSON.stringify(current, null, 2)}
331
+ `);
332
+ return true;
333
+ }
322
334
  function saveTheme(value2) {
323
335
  current = { ...current };
324
336
  if (value2 === void 0) {
@@ -362,6 +374,7 @@ var BASE = {
362
374
  inputFocusedBg: "#303030",
363
375
  warn: "#f0b689",
364
376
  error: "#ff8080",
377
+ success: "#89f0ab",
365
378
  chartBar: "#b98d63",
366
379
  chartLine: "#c99a6f",
367
380
  chartDim: "#6e563f",
@@ -542,9 +555,15 @@ function Footer({
542
555
  editing,
543
556
  tab,
544
557
  views,
558
+ copyLinks: copyLinks2,
545
559
  openError,
560
+ copyNotice,
546
561
  stale
547
562
  }) {
563
+ const notice = openError ?? copyNotice ?? (stale ? "options changed \xB7 press r to reload" : "");
564
+ const check = openError === null && copyNotice !== null;
565
+ const noticeWidth = notice === "" ? 0 : notice.length + (check ? 2 : 0) + 2;
566
+ const hints = truncated(hintsFor(modal, editing, tab, views, copyLinks2), width - 2 - noticeWidth);
548
567
  return /* @__PURE__ */ jsxs(Fragment, { children: [
549
568
  /* @__PURE__ */ jsx("box", { height: 1, children: /* @__PURE__ */ jsx("text", { wrapMode: "none", fg: theme.border, children: "\u2500".repeat(width) }) }),
550
569
  /* @__PURE__ */ jsxs(
@@ -557,14 +576,23 @@ function Footer({
557
576
  paddingRight: 1,
558
577
  justifyContent: "space-between",
559
578
  children: [
560
- /* @__PURE__ */ jsx("text", { wrapMode: "none", fg: theme.dim, children: hintsFor(modal, editing, tab, views) }),
561
- /* @__PURE__ */ jsx("text", { wrapMode: "none", fg: openError !== null ? theme.error : theme.warn, children: openError ?? (stale ? "options changed \xB7 press r to reload" : "") })
579
+ /* @__PURE__ */ jsx("text", { wrapMode: "none", fg: theme.dim, children: hints }),
580
+ /* @__PURE__ */ jsxs("text", { wrapMode: "none", children: [
581
+ check && /* @__PURE__ */ jsx("span", { fg: theme.success, children: "\u2714 " }),
582
+ /* @__PURE__ */ jsx("span", { fg: openError !== null ? theme.error : copyNotice !== null ? theme.muted : theme.warn, children: notice })
583
+ ] })
562
584
  ]
563
585
  }
564
586
  )
565
587
  ] });
566
588
  }
567
- function hintsFor(modal, editing, tab, views) {
589
+ function truncated(text, limit2) {
590
+ if (text.length <= limit2) {
591
+ return text;
592
+ }
593
+ return limit2 <= 1 ? "" : `${text.slice(0, limit2 - 1).trimEnd()}\u2026`;
594
+ }
595
+ function hintsFor(modal, editing, tab, views, copyLinks2) {
568
596
  if (modal === "options") {
569
597
  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";
570
598
  }
@@ -580,10 +608,11 @@ function hintsFor(modal, editing, tab, views) {
580
608
  if (scope2?.view === "list") {
581
609
  return "\u2191/\u2193 select \xB7 enter open \xB7 \u2190/\u2192 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit";
582
610
  }
611
+ const action = copyLinks2 ? "enter copy link" : "enter open";
583
612
  if (scope2 !== null && repos2.length > 0) {
584
- return scope2.repo === null ? "\u2191/\u2193 select \xB7 enter open \xB7 g group by repo \xB7 esc back \xB7 o options \xB7 s settings \xB7 r reload \xB7 q quit" : "\u2191/\u2193 select \xB7 enter open \xB7 esc back \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit";
613
+ return scope2.repo === null ? `\u2191/\u2193 select \xB7 ${action} \xB7 g group by repo \xB7 esc back \xB7 o options \xB7 s settings \xB7 r reload \xB7 q quit` : `\u2191/\u2193 select \xB7 ${action} \xB7 esc back \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
585
614
  }
586
- return "\u2191/\u2193 select \xB7 enter open in browser \xB7 \u2190/\u2192 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit";
615
+ return `\u2191/\u2193 select \xB7 ${copyLinks2 ? "enter copy link" : "enter open in browser"} \xB7 \u2190/\u2192 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
587
616
  }
588
617
  const scope = views === null ? null : tab === 2 ? views.reviewScope : tab === 3 ? views.sizeScope : views.commentScope;
589
618
  const repos = views === null ? [] : tab === 2 ? views.reviewRepos : tab === 3 ? views.sizeRepos : views.commentRepos;
@@ -996,7 +1025,8 @@ function lineLength(line) {
996
1025
  return line.reduce((sum, span) => sum + span.text.length, 0);
997
1026
  }
998
1027
  function cardWidth(card) {
999
- return Math.max(card.title.length + 2 + card.subtitle.length, ...card.lines.map(lineLength));
1028
+ const subtitleLength = typeof card.subtitle === "string" ? card.subtitle.length : lineLength(card.subtitle);
1029
+ return Math.max(card.title.length + 2 + subtitleLength, ...card.lines.map(lineLength));
1000
1030
  }
1001
1031
  function formatDuration(hours) {
1002
1032
  if (hours < 1) {
@@ -1105,10 +1135,12 @@ function ChartLine({ line }) {
1105
1135
  ) });
1106
1136
  }
1107
1137
  function ChartCard({ card }) {
1138
+ const subtitle = typeof card.subtitle === "string" ? [{ text: card.subtitle, fg: theme.muted }] : card.subtitle;
1108
1139
  return /* @__PURE__ */ jsxs3("box", { flexDirection: "column", children: [
1109
1140
  /* @__PURE__ */ jsxs3("text", { wrapMode: "none", children: [
1110
1141
  /* @__PURE__ */ jsx4("b", { fg: theme.text, children: card.title }),
1111
- /* @__PURE__ */ jsx4("span", { fg: theme.muted, children: ` ${card.subtitle}` })
1142
+ /* @__PURE__ */ jsx4("span", { fg: theme.muted, children: " " }),
1143
+ keyed(subtitle, spanKey).map(({ key, item }) => /* @__PURE__ */ jsx4("span", { fg: item.fg ?? theme.muted, children: item.text }, key))
1112
1144
  ] }),
1113
1145
  /* @__PURE__ */ jsx4("box", { flexDirection: "column", marginTop: 1, children: keyed(card.lines, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) })
1114
1146
  ] });
@@ -1157,10 +1189,12 @@ function QueuePanel({
1157
1189
  warning,
1158
1190
  empty,
1159
1191
  lists,
1160
- cursor
1192
+ cursor,
1193
+ onRefClick
1161
1194
  }) {
1162
1195
  const scrollRef = useRef(null);
1163
1196
  const { width } = useTerminalDimensions2();
1197
+ const lastDown = useRef(null);
1164
1198
  useScrollbarSettle(scrollRef, empty === null);
1165
1199
  useEffect2(() => {
1166
1200
  scrollRef.current?.scrollChildIntoView(`queue-row-${cursor}`);
@@ -1203,18 +1237,41 @@ function QueuePanel({
1203
1237
  const index = offsets[listIndex] + rowIndex;
1204
1238
  const isSelected = index === cursor;
1205
1239
  const bg = isSelected ? theme.selectedBg : void 0;
1206
- return /* @__PURE__ */ jsxs5("text", { id: `queue-row-${index}`, wrapMode: "none", children: [
1207
- /* @__PURE__ */ jsx6("span", { fg: theme.accent, children: isSelected ? "\u25B8 " : " " }),
1208
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1209
- row.lead,
1210
- " "
1211
- ] }),
1212
- /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }),
1213
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1214
- " ",
1215
- row.title
1216
- ] })
1217
- ] }, row.url);
1240
+ const refStart = 2 + row.lead.length + 2;
1241
+ return /* @__PURE__ */ jsxs5(
1242
+ "text",
1243
+ {
1244
+ id: `queue-row-${index}`,
1245
+ wrapMode: "none",
1246
+ onMouseDown: onRefClick === null ? void 0 : (event) => {
1247
+ lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
1248
+ },
1249
+ onMouseUp: onRefClick === null ? void 0 : (event) => {
1250
+ const down = lastDown.current;
1251
+ lastDown.current = null;
1252
+ if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
1253
+ return;
1254
+ }
1255
+ const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
1256
+ if (local >= refStart && local < refStart + row.ref.length) {
1257
+ onRefClick(row);
1258
+ }
1259
+ },
1260
+ children: [
1261
+ /* @__PURE__ */ jsx6("span", { fg: theme.accent, children: isSelected ? "\u25B8 " : " " }),
1262
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1263
+ row.lead,
1264
+ " "
1265
+ ] }),
1266
+ 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 }),
1267
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1268
+ " ",
1269
+ row.title
1270
+ ] })
1271
+ ]
1272
+ },
1273
+ row.url
1274
+ );
1218
1275
  })
1219
1276
  ] }, list.title))
1220
1277
  ]
@@ -1273,7 +1330,8 @@ function QueueTab({
1273
1330
  repoCursor,
1274
1331
  rowCursor,
1275
1332
  grouped,
1276
- warning
1333
+ warning,
1334
+ onRefClick
1277
1335
  }) {
1278
1336
  if (scope.view === "list") {
1279
1337
  return /* @__PURE__ */ jsx8(RepoList, { prompt, options: repos, cursor: Math.min(repoCursor, repos.length - 1) });
@@ -1288,7 +1346,8 @@ function QueueTab({
1288
1346
  warning,
1289
1347
  empty: view.empty,
1290
1348
  lists: view.lists,
1291
- cursor: Math.min(rowCursor, queueRows(view).length - 1)
1349
+ cursor: Math.min(rowCursor, queueRows(view).length - 1),
1350
+ onRefClick
1292
1351
  }
1293
1352
  );
1294
1353
  }
@@ -1326,7 +1385,8 @@ function MainPanel({
1326
1385
  scrollRefs,
1327
1386
  error,
1328
1387
  loading,
1329
- load
1388
+ load,
1389
+ onRefClick
1330
1390
  }) {
1331
1391
  return /* @__PURE__ */ jsx8("box", { flexGrow: 1, flexDirection: "column", marginTop: 1, children: views === null ? /* @__PURE__ */ jsx8(Placeholder, { error, loading, load }) : browse.tab === 0 ? /* @__PURE__ */ jsx8(
1332
1392
  QueueTab,
@@ -1338,7 +1398,8 @@ function MainPanel({
1338
1398
  repoCursor: browse.repoCursors.pending,
1339
1399
  rowCursor: browse.rowCursors.pending,
1340
1400
  grouped: browse.grouped.pending,
1341
- warning
1401
+ warning,
1402
+ onRefClick
1342
1403
  }
1343
1404
  ) : browse.tab === 1 ? /* @__PURE__ */ jsx8(
1344
1405
  QueueTab,
@@ -1350,7 +1411,8 @@ function MainPanel({
1350
1411
  repoCursor: browse.repoCursors.open,
1351
1412
  rowCursor: browse.rowCursors.open,
1352
1413
  grouped: browse.grouped.open,
1353
- warning
1414
+ warning,
1415
+ onRefClick
1354
1416
  }
1355
1417
  ) : browse.tab === 2 ? /* @__PURE__ */ jsx8(
1356
1418
  StatsTab,
@@ -2568,6 +2630,12 @@ var SETTINGS = [
2568
2630
  label: "Clear cache",
2569
2631
  hint: "deletes the cached PR data at this path, so the next reload refetches everything"
2570
2632
  },
2633
+ {
2634
+ key: "copyLinks",
2635
+ section: "Links",
2636
+ label: "Copy instead of open",
2637
+ hint: "enter and a click on a PR reference copy its link to the clipboard instead of opening the browser"
2638
+ },
2571
2639
  {
2572
2640
  key: "themePreset",
2573
2641
  section: "Theme",
@@ -2599,6 +2667,7 @@ var THEME_COLORS = [
2599
2667
  { key: "inputFocusedBg", hint: "background of the focused text input" },
2600
2668
  { key: "warn", hint: "notices like the reload reminder and confirm prompts" },
2601
2669
  { key: "error", hint: "error messages and failed loads" },
2670
+ { key: "success", hint: "the checkmark on the copied-link notice" },
2602
2671
  { key: "chartBar", hint: "histogram and volume bars" },
2603
2672
  { key: "chartLine", hint: "trend lines and the scatter dots" },
2604
2673
  { key: "chartDim", hint: "de-emphasized chart parts like the over-target share" },
@@ -2630,6 +2699,7 @@ function SettingsModal({
2630
2699
  selected,
2631
2700
  cacheAction,
2632
2701
  noCache: noCache2,
2702
+ copyLinks: copyLinks2,
2633
2703
  preset
2634
2704
  }) {
2635
2705
  const message = cacheAction === null ? null : CACHE_MESSAGES[cacheAction];
@@ -2645,6 +2715,7 @@ function SettingsModal({
2645
2715
  isSelected,
2646
2716
  cacheAction,
2647
2717
  noCache: noCache2,
2718
+ copyLinks: copyLinks2,
2648
2719
  preset
2649
2720
  }
2650
2721
  ) }, setting.key);
@@ -2658,6 +2729,7 @@ function SettingValue({
2658
2729
  isSelected,
2659
2730
  cacheAction,
2660
2731
  noCache: noCache2,
2732
+ copyLinks: copyLinks2,
2661
2733
  preset
2662
2734
  }) {
2663
2735
  switch (setting.key) {
@@ -2667,6 +2739,9 @@ function SettingValue({
2667
2739
  case "clearCache": {
2668
2740
  return /* @__PURE__ */ jsx11(PathValue, { path: cacheDir(), confirming: cacheAction === "confirm", isSelected });
2669
2741
  }
2742
+ case "copyLinks": {
2743
+ return /* @__PURE__ */ jsx11(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
2744
+ }
2670
2745
  case "themePreset": {
2671
2746
  return /* @__PURE__ */ jsx11(ToggleValue, { value: preset, isSelected });
2672
2747
  }
@@ -3986,9 +4061,9 @@ function buildSpreadCard(title, metrics, format) {
3986
4061
  const span = Math.log1p(max) - Math.log1p(min);
3987
4062
  const pos = (value2) => {
3988
4063
  if (span === 0) {
3989
- return 0;
4064
+ return 1;
3990
4065
  }
3991
- return Math.round((Math.log1p(value2) - Math.log1p(min)) / span * (SPREAD_STRIP_WIDTH - 1));
4066
+ return 1 + Math.round((Math.log1p(value2) - Math.log1p(min)) / span * (SPREAD_STRIP_WIDTH - 3));
3992
4067
  };
3993
4068
  const cells = blankCells(SPREAD_STRIP_WIDTH);
3994
4069
  for (let i = 0; i < SPREAD_STRIP_WIDTH; i++) {
@@ -4012,7 +4087,13 @@ function buildSpreadCard(title, metrics, format) {
4012
4087
  { text: ` ${row.max}`, fg: theme.text }
4013
4088
  ];
4014
4089
  });
4015
- return { title, subtitle: "\u2592 p25-p75, \u2588 p50, log scale", lines };
4090
+ const subtitle = [
4091
+ { text: "\u2592", fg: theme.chartBar },
4092
+ { text: " p25-p75, ", fg: theme.muted },
4093
+ { text: "\u2588", fg: theme.accent },
4094
+ { text: " p50, log scale", fg: theme.muted }
4095
+ ];
4096
+ return { title, subtitle, lines };
4016
4097
  }
4017
4098
 
4018
4099
  // tui/views/charts/trend.ts
@@ -4648,6 +4729,12 @@ function handleSettingsModalKey(key, context) {
4648
4729
  }
4649
4730
  break;
4650
4731
  }
4732
+ case "copyLinks": {
4733
+ const next = !context.copyLinks;
4734
+ context.setCopyLinks(next);
4735
+ context.dispatchUi({ type: "cacheActionReported", action: saveCopyLinks(next) ? "saved" : "notSaved" });
4736
+ break;
4737
+ }
4651
4738
  case "themePreset": {
4652
4739
  const next = cycleTheme(context.themeState, key.name === "left" ? -1 : 1);
4653
4740
  applyThemeState(next);
@@ -4770,9 +4857,14 @@ function handleQueueKey(key, context) {
4770
4857
  }
4771
4858
  case "return": {
4772
4859
  const cursor = context.browse.rowCursors[tab];
4773
- context.openUrl(rows[Math.min(cursor, rows.length - 1)].url, (message) => {
4774
- context.dispatchUi({ type: "openErrorReported", message });
4775
- });
4860
+ const row = rows[Math.min(cursor, rows.length - 1)];
4861
+ if (context.copyLinks) {
4862
+ context.copyRow(row);
4863
+ } else {
4864
+ context.openUrl(row.url, (message) => {
4865
+ context.dispatchUi({ type: "openErrorReported", message });
4866
+ });
4867
+ }
4776
4868
  break;
4777
4869
  }
4778
4870
  }
@@ -4828,7 +4920,7 @@ function handleStatsKey(key, context) {
4828
4920
  }
4829
4921
  }
4830
4922
  function handleAppKey(key, context) {
4831
- context.dispatchUi({ type: "openErrorDismissed" });
4923
+ context.dispatchUi({ type: "noticesDismissed" });
4832
4924
  if (context.ui.editing) {
4833
4925
  if (key.name === "escape") {
4834
4926
  context.dispatchUi({ type: "editCancelled" });
@@ -4882,19 +4974,29 @@ var initialUiState = {
4882
4974
  fieldError: null,
4883
4975
  themeColorError: null,
4884
4976
  cacheAction: null,
4885
- openError: null
4977
+ openError: null,
4978
+ copyNotice: null
4886
4979
  };
4887
4980
  function cycled2(previous, delta, count2) {
4888
4981
  return (previous + count2 + delta) % count2;
4889
4982
  }
4890
4983
  function uiReducer(state, action) {
4891
4984
  switch (action.type) {
4892
- case "openErrorDismissed": {
4893
- return state.openError === null ? state : { ...state, openError: null };
4985
+ case "noticesDismissed": {
4986
+ if (state.openError === null && state.copyNotice === null) {
4987
+ return state;
4988
+ }
4989
+ return { ...state, openError: null, copyNotice: null };
4894
4990
  }
4895
4991
  case "openErrorReported": {
4896
4992
  return { ...state, openError: action.message };
4897
4993
  }
4994
+ case "copyReported": {
4995
+ return { ...state, openError: null, copyNotice: { text: action.message } };
4996
+ }
4997
+ case "copyNoticeExpired": {
4998
+ return { ...state, copyNotice: null };
4999
+ }
4898
5000
  case "modalOpened": {
4899
5001
  return { ...state, modal: action.modal, fieldError: null, themeColorError: null, cacheAction: null };
4900
5002
  }
@@ -4988,14 +5090,50 @@ function openInBrowser(url, onError) {
4988
5090
  child.unref();
4989
5091
  }
4990
5092
 
5093
+ // tui/utils/clipboard.ts
5094
+ import { spawn as spawn2 } from "node:child_process";
5095
+ function copyCommand() {
5096
+ switch (process.platform) {
5097
+ case "darwin": {
5098
+ return { command: "pbcopy", args: [] };
5099
+ }
5100
+ case "win32": {
5101
+ return { command: "clip", args: [] };
5102
+ }
5103
+ default: {
5104
+ if (process.env.WAYLAND_DISPLAY !== void 0 && process.env.WAYLAND_DISPLAY !== "") {
5105
+ return { command: "wl-copy", args: [] };
5106
+ }
5107
+ return { command: "xclip", args: ["-selection", "clipboard"] };
5108
+ }
5109
+ }
5110
+ }
5111
+ function copyToClipboard(text, onError) {
5112
+ const { command, args } = copyCommand();
5113
+ const child = spawn2(command, args, { stdio: ["pipe", "ignore", "ignore"] });
5114
+ child.on("error", (error) => {
5115
+ onError(`could not copy the link (${error.message})`);
5116
+ });
5117
+ child.on("exit", (code) => {
5118
+ if (code !== null && code !== 0) {
5119
+ onError(`could not copy the link (${command} exited with ${code})`);
5120
+ }
5121
+ });
5122
+ child.stdin.on("error", () => {
5123
+ });
5124
+ child.stdin.end(text);
5125
+ }
5126
+
4991
5127
  // tui/App.tsx
4992
5128
  import { jsx as jsx14, jsxs as jsxs11 } from "@opentui/react/jsx-runtime";
4993
5129
  function App({
4994
5130
  initial: initial2,
4995
5131
  initialSaved = null,
4996
5132
  initialNoCache = false,
5133
+ initialCopyLinks = false,
4997
5134
  initialTheme = defaultThemeState(),
4998
5135
  openUrl = openInBrowser,
5136
+ copyUrl = copyToClipboard,
4999
5137
  onQuit
5000
5138
  }) {
5001
5139
  const { width } = useTerminalDimensions3();
@@ -5004,7 +5142,25 @@ function App({
5004
5142
  const [options, setOptions] = useState4(initial2);
5005
5143
  const [saved2, setSaved] = useState4(initialSaved);
5006
5144
  const [noCache2, setNoCache] = useState4(initialNoCache);
5145
+ const [copyLinks2, setCopyLinks] = useState4(initialCopyLinks);
5007
5146
  const [themeState, setThemeState] = useState4(initialTheme);
5147
+ const copyRow = (row) => {
5148
+ copyUrl(row.url, (message) => {
5149
+ dispatchUi({ type: "openErrorReported", message });
5150
+ });
5151
+ dispatchUi({ type: "copyReported", message: `copied ${row.ref} to the clipboard` });
5152
+ };
5153
+ useEffect6(() => {
5154
+ if (ui.copyNotice === null) {
5155
+ return void 0;
5156
+ }
5157
+ const timer = setTimeout(() => {
5158
+ dispatchUi({ type: "copyNoticeExpired" });
5159
+ }, 2500);
5160
+ return () => {
5161
+ clearTimeout(timer);
5162
+ };
5163
+ }, [ui.copyNotice]);
5008
5164
  const reviewScrollRef = useRef5(null);
5009
5165
  const sizeScrollRef = useRef5(null);
5010
5166
  const commentScrollRef = useRef5(null);
@@ -5059,6 +5215,7 @@ function App({
5059
5215
  ui,
5060
5216
  browse,
5061
5217
  noCache: noCache2,
5218
+ copyLinks: copyLinks2,
5062
5219
  themeState,
5063
5220
  options,
5064
5221
  views,
@@ -5067,10 +5224,12 @@ function App({
5067
5224
  setOptions,
5068
5225
  setSaved,
5069
5226
  setNoCache,
5227
+ setCopyLinks,
5070
5228
  setThemeState,
5071
5229
  quit: onQuit,
5072
5230
  reload,
5073
5231
  openUrl,
5232
+ copyRow,
5074
5233
  beginEdit: (value2) => {
5075
5234
  draftRef.current = value2;
5076
5235
  dispatchUi({ type: "editStarted" });
@@ -5095,7 +5254,8 @@ function App({
5095
5254
  scrollRefs: { review: reviewScrollRef, size: sizeScrollRef, comment: commentScrollRef },
5096
5255
  error,
5097
5256
  loading,
5098
- load
5257
+ load,
5258
+ onRefClick: copyLinks2 ? copyRow : null
5099
5259
  }
5100
5260
  ),
5101
5261
  /* @__PURE__ */ jsx14(
@@ -5106,7 +5266,9 @@ function App({
5106
5266
  editing: ui.editing,
5107
5267
  tab: browse.tab,
5108
5268
  views,
5269
+ copyLinks: copyLinks2,
5109
5270
  openError: ui.openError,
5271
+ copyNotice: ui.copyNotice === null ? null : ui.copyNotice.text,
5110
5272
  stale
5111
5273
  }
5112
5274
  ),
@@ -5130,6 +5292,7 @@ function App({
5130
5292
  selected: ui.selectedSetting,
5131
5293
  cacheAction: ui.cacheAction,
5132
5294
  noCache: noCache2,
5295
+ copyLinks: copyLinks2,
5133
5296
  preset: themeState.preset
5134
5297
  }
5135
5298
  ),
@@ -5180,7 +5343,7 @@ function bootstrap() {
5180
5343
  validateField(field.key, value2);
5181
5344
  }
5182
5345
  }
5183
- return { initial: initial2, saved: saved2, noCache: values["no-cache"], theme: theme3 };
5346
+ return { initial: initial2, saved: saved2, noCache: values["no-cache"], copyLinks: settings.copyLinks === true, theme: theme3 };
5184
5347
  } catch (error) {
5185
5348
  if (error instanceof CliError) {
5186
5349
  fail(error.message);
@@ -5191,7 +5354,7 @@ function bootstrap() {
5191
5354
 
5192
5355
  // tui/main.tsx
5193
5356
  import { jsx as jsx15 } from "@opentui/react/jsx-runtime";
5194
- var { initial, saved, noCache, theme: theme2 } = bootstrap();
5357
+ var { initial, saved, noCache, copyLinks, theme: theme2 } = bootstrap();
5195
5358
  var renderer = await createCliRenderer({ exitOnCtrlC: true });
5196
5359
  for (const signal of ["SIGTERM", "SIGHUP"]) {
5197
5360
  process.on(signal, () => {
@@ -5206,6 +5369,7 @@ createRoot(renderer).render(
5206
5369
  initial,
5207
5370
  initialSaved: saved,
5208
5371
  initialNoCache: noCache,
5372
+ initialCopyLinks: copyLinks,
5209
5373
  initialTheme: theme2,
5210
5374
  onQuit: () => {
5211
5375
  renderer.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3lm/pr-stats",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -36,7 +36,7 @@
36
36
  "@types/node": "^26.3.0",
37
37
  "@types/react": "^19.2.18",
38
38
  "esbuild": "^0.28.2",
39
- "typescript": "^7.0.2"
39
+ "typescript": "^6.0.3"
40
40
  },
41
41
  "dependencies": {
42
42
  "@opentui/core": "^0.5.8",