@kud/gh-ink 0.24.0 → 0.26.0

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/dist/index.d.ts CHANGED
@@ -48,6 +48,44 @@ interface InboxExtension {
48
48
  body: (onExit: () => void, target?: ExtensionTarget) => ReactNode;
49
49
  }
50
50
 
51
+ /**
52
+ * What the last fetch cost and what was left afterwards, as reported INSIDE the
53
+ * query response by `rateLimit { cost remaining resetAt }`.
54
+ *
55
+ * Cached because the budget is shared by every instance and every other tool on
56
+ * the account, so the useful reading is the most recent one from anywhere — not
57
+ * whatever this process happens to remember. A cockpit launching cold needs it
58
+ * BEFORE its first fetch, which is the one moment it has no response to read it
59
+ * from.
60
+ *
61
+ * Never from `GET /rate_limit`. That endpoint is served from replicas that do
62
+ * not share state: on 2026-08-27 it reported `used: 0, remaining: 5000` while
63
+ * GraphQL was refusing every call, returned a `reset` exactly 3600s ahead of
64
+ * each read, and gave `used` values that DECREASED between two calls a second
65
+ * apart. This figure comes from the service that enforces the limit, in the same
66
+ * response as the data, so it cannot disagree with itself.
67
+ */
68
+ type InboxBudget = {
69
+ /** Points left in the window. */
70
+ remaining: number;
71
+ /** What the query just cost, so a caller can price the next one. */
72
+ cost: number;
73
+ /** ISO-8601, from GitHub rather than computed locally. */
74
+ resetAt: string;
75
+ };
76
+ type CachedCockpit = {
77
+ sections: Section[];
78
+ login: string;
79
+ at: number;
80
+ budget?: InboxBudget;
81
+ };
82
+ declare const readCache: (key: string) => CachedCockpit | null;
83
+ declare const writeCache: (key: string, data: {
84
+ sections: Section[];
85
+ login: string;
86
+ budget?: InboxBudget;
87
+ }) => void;
88
+
51
89
  type GHDetail = {
52
90
  reviewDecision?: string;
53
91
  mergeable?: string;
@@ -238,6 +276,21 @@ declare const filterByRepos: (sections: Section[], repos: Set<string>) => Sectio
238
276
  declare const withoutItem: (sections: Section[], target: GHItem) => Section[];
239
277
  declare const reposInSections: (sections: Section[]) => string[];
240
278
  declare const moveCursor: (items: AnyItem[], current: number, dir: 1 | -1) => number;
279
+ /**
280
+ * What to say about the remaining budget, or nothing at all.
281
+ *
282
+ * Priced in whole fetches because that is the unit that runs out: the query
283
+ * costs ~111 points against a 5,000 pool, so "some points left" and "another
284
+ * fetch left" are different questions and only the second one is actionable.
285
+ *
286
+ * Silent above the threshold. A counter on a healthy account is noise in a
287
+ * header already carrying four things, and noise is what gets skimmed past on
288
+ * the one day it matters.
289
+ */
290
+ declare const budgetNotice: (budget: InboxBudget | null | undefined, now?: number) => {
291
+ label: string;
292
+ critical: boolean;
293
+ } | null;
241
294
  /**
242
295
  * Whether this row draws a blank line above it.
243
296
  *
@@ -270,6 +323,7 @@ declare const runHere: (cmd: string) => void;
270
323
  declare const COLS: number;
271
324
  declare const topLevelCount: (s: Section) => number;
272
325
  declare const drillCmd: (item: AnyItem) => string | null;
326
+ declare const explainGhAction: (e: unknown) => string;
273
327
  declare const buildActions: (item: AnyItem, login: string, showFlash: (msg: string) => void, jiraBase?: string, jiraKeyRe?: RegExp, jiraTransitions?: JiraTransition[], onRefresh?: () => void, onRemove?: (item: GHItem) => void, onOpenView?: (item: AnyItem) => boolean, ext?: {
274
328
  extensions?: InboxExtension[];
275
329
  onOpenExt?: (id: string, target: ExtensionTarget) => void;
@@ -314,11 +368,18 @@ declare const ActionMenu: ({ item, actions, cursor, }: {
314
368
  cursor: number;
315
369
  }) => React.JSX.Element;
316
370
  declare const signatureOf: (sections: Section[]) => string;
317
- declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraTransitions, hasCiStatus, ciJob, ciFetcher, ciPollMs, watchPath, watchDebounceMs, extensions, tabHelp, emptyHint, }: {
371
+ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraTransitions, hasCiStatus, ciJob, ciFetcher, ciPollMs, watchPath, watchDebounceMs, watchJitterMs, extensions, tabHelp, emptyHint, }: {
318
372
  fetcher: () => Promise<{
319
373
  sections: Section[];
320
374
  login: string;
321
375
  ciStatus?: CiStatus | null;
376
+ /**
377
+ * What that fetch cost and what is left, if the host's query asked. GitHub
378
+ * answers `rateLimit { cost remaining resetAt }` inside the response for
379
+ * free, so a host that asks pays nothing to know — and an inbox that knows
380
+ * can decline to spend the last of it on a refresh nobody requested.
381
+ */
382
+ budget?: InboxBudget;
322
383
  }>;
323
384
  cacheKey?: string;
324
385
  title?: string;
@@ -342,20 +403,18 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
342
403
  watchPath?: string;
343
404
  /** Bursts of writes to collapse into one refetch. */
344
405
  watchDebounceMs?: number;
406
+ /**
407
+ * Random spread added to `watchDebounceMs`, so several cockpits woken by one
408
+ * signal take turns instead of stampeding. The first to wake fetches and
409
+ * writes the shared cache; the others adopt it and pay nothing.
410
+ *
411
+ * Defaults to 0 — no randomness unless a host asks for it. Set it to comfortably
412
+ * more than one round trip when you expect several instances to be open.
413
+ */
414
+ watchJitterMs?: number;
345
415
  extensions?: InboxExtension[];
346
416
  }) => React.JSX.Element;
347
417
 
348
- type CachedCockpit = {
349
- sections: Section[];
350
- login: string;
351
- at: number;
352
- };
353
- declare const readCache: (key: string) => CachedCockpit | null;
354
- declare const writeCache: (key: string, data: {
355
- sections: Section[];
356
- login: string;
357
- }) => void;
358
-
359
418
  /**
360
419
  * A named slice of the reader's repos. Two or more turn on the in-app toggle;
361
420
  * one or none means an undivided set and no toggle at all.
@@ -443,4 +502,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
443
502
  */
444
503
  declare const parsePatterns: (value: string) => string[];
445
504
 
446
- export { type Action, ActionMenu, type AnyItem, App, COLS, type CiStatus, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, type GHDetail, type GHItem, HealthPanel, type HealthPanelProps, type InboxConfig, type InboxExtension, type JiraTransition, type OriginSplit, type RepoFilter, type RepoHeader, type RepoProfile, type Section, type ShowLess, type ShowMore, type Standing, type SubgroupHeader, type TaskRow, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
505
+ export { type Action, ActionMenu, type AnyItem, App, COLS, type CiStatus, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, type GHDetail, type GHItem, HealthPanel, type HealthPanelProps, type InboxBudget, type InboxConfig, type InboxExtension, type JiraTransition, type OriginSplit, type RepoFilter, type RepoHeader, type RepoProfile, type Section, type ShowLess, type ShowMore, type Standing, type SubgroupHeader, type TaskRow, budgetNotice, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainGhAction, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
package/dist/index.js CHANGED
@@ -632,7 +632,7 @@ var checkoutDirs = () => [
632
632
 
633
633
  // src/inbox/cache.ts
634
634
  var cacheTtlMs = () => inboxConfig().cacheTtlMs;
635
- var CACHE_VERSION = 2;
635
+ var CACHE_VERSION = 3;
636
636
  var cacheDir = () => join(
637
637
  process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
638
638
  inboxConfig().cacheNamespace
@@ -643,7 +643,12 @@ var readCache = (key) => {
643
643
  const raw = JSON.parse(readFileSync(cacheFile(key), "utf8"));
644
644
  if (raw?.version !== CACHE_VERSION) return null;
645
645
  if (!Array.isArray(raw?.sections)) return null;
646
- return { sections: raw.sections, login: raw.login ?? "", at: raw.at ?? 0 };
646
+ return {
647
+ sections: raw.sections,
648
+ login: raw.login ?? "",
649
+ at: raw.at ?? 0,
650
+ budget: raw.budget
651
+ };
647
652
  } catch {
648
653
  return null;
649
654
  }
@@ -1048,6 +1053,18 @@ var moveCursor = (items, current2, dir) => {
1048
1053
  if (next < 0 || next >= items.length) return current2;
1049
1054
  return next;
1050
1055
  };
1056
+ var budgetNotice = (budget, now = Date.now()) => {
1057
+ if (!budget) return null;
1058
+ if (new Date(budget.resetAt).getTime() <= now) return null;
1059
+ const per = Math.max(budget.cost, 1);
1060
+ const fetches = Math.floor(budget.remaining / per);
1061
+ if (fetches > 8) return null;
1062
+ const mins = Math.max(
1063
+ 1,
1064
+ Math.round((new Date(budget.resetAt).getTime() - now) / 6e4)
1065
+ );
1066
+ return fetches <= 0 ? { label: `\u26A1 API budget spent \xB7 ${mins}m`, critical: true } : { label: `\u26A1 ${fetches} fetch${fetches === 1 ? "" : "es"} left`, critical: false };
1067
+ };
1051
1068
  var gapsAbove = (items, i) => {
1052
1069
  const item = items[i];
1053
1070
  if (!item || i === 0) return false;
@@ -1317,9 +1334,22 @@ var UNSUBSCRIBE_MUTATION = `
1317
1334
  }
1318
1335
  }
1319
1336
  `;
1337
+ var GH_ACTION_FAILURES = [
1338
+ [/rate limit|RATE_LIMIT/i, "GitHub rate limit spent \u2014 it resets within the hour"],
1339
+ [/\b401\b|not logged in|authentication/i, "gh is not authenticated"],
1340
+ [/\b403\b|must have admin|not authorized|permission/i, "not permitted on this repo"],
1341
+ [/\b50[0234]\b/, "GitHub's API is failing (5xx)"],
1342
+ [/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNREFUSED/i, "no route to GitHub"]
1343
+ ];
1344
+ var explainGhAction = (e) => {
1345
+ const err = e;
1346
+ const raw = (err.stderr ?? "").trim().split("\n").filter(Boolean).pop() ?? err.message ?? "no reason reported";
1347
+ const human = GH_ACTION_FAILURES.find(([re]) => re.test(raw))?.[1];
1348
+ return human ? `${human} (${raw})` : raw;
1349
+ };
1320
1350
  var unsubscribeFrom = async (item) => {
1321
- const view = item.kind === "pr" ? "pr" : "issue";
1322
- const found = await quietly`gh ${view} view ${item.number} --repo ${item.repo} --json id --jq .id`;
1351
+ const route = item.kind === "pr" ? "pulls" : "issues";
1352
+ const found = await quietly`gh api repos/${item.repo}/${route}/${item.number} --jq .node_id`;
1323
1353
  const id = found.stdout.trim();
1324
1354
  if (!id) throw new Error(`no node id for ${item.repo}#${item.number}`);
1325
1355
  await quietly`gh api graphql -f query=${UNSUBSCRIBE_MUTATION} -f id=${id}`;
@@ -1419,8 +1449,8 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
1419
1449
  void quietly`gh pr edit ${item.number} --repo ${item.repo} --remove-reviewer ${login}`.then(() => {
1420
1450
  showFlash(`\u2713 Removed you as reviewer on #${item.number}`);
1421
1451
  ext?.onActed?.();
1422
- }).catch(() => {
1423
- showFlash(`\u2717 Could not remove you from #${item.number}`);
1452
+ }).catch((e) => {
1453
+ showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`);
1424
1454
  onRefresh?.();
1425
1455
  });
1426
1456
  }
@@ -1431,7 +1461,9 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
1431
1461
  hint: "u",
1432
1462
  run: () => {
1433
1463
  showFlash(`\u22EF Unsubscribing from #${item.number}\u2026`);
1434
- void unsubscribeFrom(item).then(() => showFlash(`\u2713 Unsubscribed from #${item.number}`)).catch(() => showFlash(`\u2717 Unsubscribe failed for #${item.number}`));
1464
+ void unsubscribeFrom(item).then(() => showFlash(`\u2713 Unsubscribed from #${item.number}`)).catch(
1465
+ (e) => showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`)
1466
+ );
1435
1467
  }
1436
1468
  });
1437
1469
  if (item.kind === "pr" && item.branch) {
@@ -1611,6 +1643,8 @@ var InboxHeader = ({
1611
1643
  login,
1612
1644
  brand,
1613
1645
  scopeLabel,
1646
+ budgetLabel,
1647
+ budgetCritical,
1614
1648
  loading,
1615
1649
  quiet,
1616
1650
  refreshing,
@@ -1622,17 +1656,19 @@ var InboxHeader = ({
1622
1656
  const countSeg = loading ? " loading\u2026 " : quiet ? " " : ` ${String(total).padStart(3)} item${total !== 1 ? "s" : ""} \xB7 `;
1623
1657
  const userSeg = loading || quiet ? "" : `@${login} `;
1624
1658
  const workLabel = scopeLabel ? ` ${scopeLabel} ` : "";
1659
+ const budgetSeg = budgetLabel ? budgetLabel + " " : "";
1625
1660
  const [statusText, statusColor] = hasPending ? [`\u25CF ${pendingSummary || "new"} \xB7 r apply`, "#FF8700"] : refreshing ? ["\u21BB refreshing\u2026", "cyan"] : fetchedAt ? [`updated ${agoText(fetchedAt)}`, void 0] : ["", void 0];
1626
1661
  const statusSeg = statusText ? statusText + " " : "";
1627
1662
  const fill = Math.max(
1628
1663
  4,
1629
- COLS - brand.length - countSeg.length - userSeg.length - workLabel.length - statusSeg.length
1664
+ COLS - brand.length - countSeg.length - userSeg.length - workLabel.length - budgetSeg.length - statusSeg.length
1630
1665
  );
1631
1666
  return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1632
1667
  /* @__PURE__ */ jsx(Text2, { color: "#FF8700", bold: true, children: brand }),
1633
1668
  /* @__PURE__ */ jsx(Text2, { dimColor: true, children: countSeg }),
1634
1669
  userSeg ? /* @__PURE__ */ jsx(Text2, { children: userSeg }) : null,
1635
1670
  scopeLabel ? /* @__PURE__ */ jsx(Text2, { dimColor: true, children: workLabel }) : null,
1671
+ budgetLabel ? /* @__PURE__ */ jsx(Text2, { bold: true, color: budgetCritical ? "#FF5F5F" : "#FF8700", children: budgetSeg }) : null,
1636
1672
  statusText ? /* @__PURE__ */ jsx(
1637
1673
  Text2,
1638
1674
  {
@@ -2137,6 +2173,8 @@ var BrowseScreen = ({
2137
2173
  ciJob,
2138
2174
  tabHelp,
2139
2175
  origin,
2176
+ budget,
2177
+ skippedForBudget,
2140
2178
  brand,
2141
2179
  mergedUrls,
2142
2180
  transients,
@@ -2466,7 +2504,9 @@ var BrowseScreen = ({
2466
2504
  }
2467
2505
  if (input === "u" && (activeItem.kind === "pr" || activeItem.kind === "issue")) {
2468
2506
  showFlash(`\u22EF Unsubscribing from #${activeItem.number}\u2026`);
2469
- void unsubscribeFrom(activeItem).then(() => showFlash(`\u2713 Unsubscribed from #${activeItem.number}`)).catch(() => showFlash(`\u2717 Unsubscribe failed for #${activeItem.number}`));
2507
+ void unsubscribeFrom(activeItem).then(() => showFlash(`\u2713 Unsubscribed from #${activeItem.number}`)).catch(
2508
+ (e) => showFlash(`\u2717 #${activeItem.number}: ${explainGhAction(e)}`)
2509
+ );
2470
2510
  return;
2471
2511
  }
2472
2512
  if (input === "x" && activeItem.kind === "pr" && activeItem.standing === "queued" && login) {
@@ -2475,8 +2515,8 @@ var BrowseScreen = ({
2475
2515
  void quietly`gh pr edit ${activeItem.number} --repo ${activeItem.repo} --remove-reviewer ${login}`.then(() => {
2476
2516
  showFlash(`\u2713 Removed you as reviewer on #${activeItem.number}`);
2477
2517
  onActed?.();
2478
- }).catch(() => {
2479
- showFlash(`\u2717 Could not remove you from #${activeItem.number}`);
2518
+ }).catch((e) => {
2519
+ showFlash(`\u2717 #${activeItem.number}: ${explainGhAction(e)}`);
2480
2520
  onRefresh?.();
2481
2521
  });
2482
2522
  return;
@@ -2594,6 +2634,8 @@ var BrowseScreen = ({
2594
2634
  sections: localSections,
2595
2635
  login,
2596
2636
  scopeLabel: origin?.label,
2637
+ budgetLabel: budgetNotice(budget)?.label,
2638
+ budgetCritical: budgetNotice(budget)?.critical,
2597
2639
  refreshing,
2598
2640
  hasPending,
2599
2641
  pendingSummary,
@@ -2601,6 +2643,10 @@ var BrowseScreen = ({
2601
2643
  }
2602
2644
  ),
2603
2645
  ciStatusState ? /* @__PURE__ */ jsx(CiStatusLine, { state: ciStatusState, job: ciJob }) : null,
2646
+ skippedForBudget ? /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
2647
+ /* @__PURE__ */ jsx(Text2, { bold: true, color: "#FF8700", children: " \u26A1 auto-refresh paused to save API budget" }),
2648
+ /* @__PURE__ */ jsx(Text2, { dimColor: true, children: " r refreshes anyway" })
2649
+ ] }) : null,
2604
2650
  /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsx(
2605
2651
  Tabs,
2606
2652
  {
@@ -2705,6 +2751,11 @@ var App = ({
2705
2751
  ciPollMs = 6e4,
2706
2752
  watchPath,
2707
2753
  watchDebounceMs = 400,
2754
+ // Zero by default, deliberately. A package that adds randomness to its own
2755
+ // timing makes every host's tests flaky — this one's went green locally on a
2756
+ // lucky draw and red in CI. It is also a host concern: only somebody running
2757
+ // several cockpits at once needs the stagger, and only they know how many.
2758
+ watchJitterMs = 0,
2708
2759
  extensions,
2709
2760
  tabHelp,
2710
2761
  emptyHint
@@ -2794,32 +2845,27 @@ var App = ({
2794
2845
  ) : "",
2795
2846
  [pending]
2796
2847
  );
2848
+ const canAffordAuto = () => {
2849
+ const known = budget ?? (cacheKey ? readCache(cacheKey)?.budget : null);
2850
+ if (!known) return true;
2851
+ if (new Date(known.resetAt).getTime() <= Date.now()) return true;
2852
+ return known.remaining >= Math.max(known.cost, 1) * 2;
2853
+ };
2797
2854
  const revalidate = (manual = false) => {
2855
+ if (!manual && !canAffordAuto()) {
2856
+ setSkippedForBudget(true);
2857
+ return;
2858
+ }
2859
+ setSkippedForBudget(false);
2798
2860
  if (manual) setRefreshing(true);
2799
2861
  fetcher().then((fresh) => {
2862
+ if (fresh.budget) setBudget(fresh.budget);
2800
2863
  if (cacheKey) writeCache(cacheKey, fresh);
2801
2864
  setRefreshing(false);
2802
2865
  setFetchedAt(Date.now());
2803
2866
  setRefreshError(null);
2804
2867
  if (hasCiStatus) applyCiStatus(fresh.ciStatus ?? null);
2805
- const freshKey = signatureOf(fresh.sections);
2806
- if (!displayedKey.current) {
2807
- if (fresh.sections.length === 0) {
2808
- setState({ phase: "empty" });
2809
- return;
2810
- }
2811
- showData(fresh.sections, fresh.login);
2812
- } else if (freshKey !== displayedKey.current) {
2813
- const { counts } = diffSections(
2814
- displayedSections.current,
2815
- fresh.sections
2816
- );
2817
- if (counts.added + counts.removed + counts.changed === 0)
2818
- showData(fresh.sections, fresh.login);
2819
- else setPending(fresh);
2820
- } else {
2821
- setPending(null);
2822
- }
2868
+ receive(fresh);
2823
2869
  }).catch((err) => {
2824
2870
  setRefreshing(false);
2825
2871
  const message = err.message;
@@ -2830,6 +2876,26 @@ var App = ({
2830
2876
  setRefreshError({ message, at: Date.now() });
2831
2877
  });
2832
2878
  };
2879
+ const receive = (fresh) => {
2880
+ const freshKey = signatureOf(fresh.sections);
2881
+ if (!displayedKey.current) {
2882
+ if (fresh.sections.length === 0) {
2883
+ setState({ phase: "empty" });
2884
+ return;
2885
+ }
2886
+ showData(fresh.sections, fresh.login);
2887
+ } else if (freshKey !== displayedKey.current) {
2888
+ const { counts } = diffSections(
2889
+ displayedSections.current,
2890
+ fresh.sections
2891
+ );
2892
+ if (counts.added + counts.removed + counts.changed === 0)
2893
+ showData(fresh.sections, fresh.login);
2894
+ else setPending(fresh);
2895
+ } else {
2896
+ setPending(null);
2897
+ }
2898
+ };
2833
2899
  const applyOrRefresh = () => {
2834
2900
  if (pending) showData(pending.sections, pending.login);
2835
2901
  else revalidate(true);
@@ -2882,7 +2948,18 @@ var App = ({
2882
2948
  watcher = watch(dir, (_event, changed) => {
2883
2949
  if (!live || changed && changed !== name) return;
2884
2950
  if (timer) clearTimeout(timer);
2885
- timer = setTimeout(() => live && revalidate(), watchDebounceMs);
2951
+ const firedAt = Date.now();
2952
+ const delay = watchDebounceMs + Math.floor(Math.random() * watchJitterMs);
2953
+ timer = setTimeout(() => {
2954
+ if (!live) return;
2955
+ const shared = cacheKey ? readCache(cacheKey) : null;
2956
+ if (shared && shared.at > firedAt) {
2957
+ receive({ sections: shared.sections, login: shared.login });
2958
+ setFetchedAt(shared.at);
2959
+ return;
2960
+ }
2961
+ revalidate();
2962
+ }, delay);
2886
2963
  });
2887
2964
  } catch {
2888
2965
  return;
@@ -2893,6 +2970,10 @@ var App = ({
2893
2970
  watcher.close();
2894
2971
  };
2895
2972
  }, [watchPath, watchDebounceMs]);
2973
+ const [budget, setBudget] = useState(
2974
+ () => cacheKey ? readCache(cacheKey)?.budget ?? null : null
2975
+ );
2976
+ const [skippedForBudget, setSkippedForBudget] = useState(false);
2896
2977
  const [mergedUrls, setMergedUrls] = useState([]);
2897
2978
  const mergeTimers = useRef([]);
2898
2979
  useEffect(() => () => mergeTimers.current.forEach(clearTimeout), []);
@@ -2973,6 +3054,8 @@ var App = ({
2973
3054
  fetchedAt,
2974
3055
  refreshError: refreshError ?? void 0,
2975
3056
  origin,
3057
+ budget,
3058
+ skippedForBudget,
2976
3059
  hidden: overlay !== null,
2977
3060
  mergedUrls,
2978
3061
  transients,
@@ -3029,4 +3112,4 @@ var matchesFilter = (repo, filter) => {
3029
3112
  };
3030
3113
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
3031
3114
 
3032
- export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
3115
+ export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, budgetNotice, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainGhAction, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/gh-ink",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Ink components for rendering GitHub PR review comments and health — controlled, presentation-only, built on @kud/ink-ui and fed by @kud/gh.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -48,7 +48,7 @@
48
48
  "react": ">=19"
49
49
  },
50
50
  "dependencies": {
51
- "@kud/gh": "0.5.1",
51
+ "@kud/gh": "0.6.0",
52
52
  "@kud/ink-ui": "0.14.0",
53
53
  "zx": "8.8.5"
54
54
  },