@kud/gh-ink 0.25.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
  *
@@ -320,6 +373,13 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
320
373
  sections: Section[];
321
374
  login: string;
322
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;
323
383
  }>;
324
384
  cacheKey?: string;
325
385
  title?: string;
@@ -355,17 +415,6 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
355
415
  extensions?: InboxExtension[];
356
416
  }) => React.JSX.Element;
357
417
 
358
- type CachedCockpit = {
359
- sections: Section[];
360
- login: string;
361
- at: number;
362
- };
363
- declare const readCache: (key: string) => CachedCockpit | null;
364
- declare const writeCache: (key: string, data: {
365
- sections: Section[];
366
- login: string;
367
- }) => void;
368
-
369
418
  /**
370
419
  * A named slice of the reader's repos. Two or more turn on the in-app toggle;
371
420
  * one or none means an undivided set and no toggle at all.
@@ -453,4 +502,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
453
502
  */
454
503
  declare const parsePatterns: (value: string) => string[];
455
504
 
456
- 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, 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 };
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;
@@ -1626,6 +1643,8 @@ var InboxHeader = ({
1626
1643
  login,
1627
1644
  brand,
1628
1645
  scopeLabel,
1646
+ budgetLabel,
1647
+ budgetCritical,
1629
1648
  loading,
1630
1649
  quiet,
1631
1650
  refreshing,
@@ -1637,17 +1656,19 @@ var InboxHeader = ({
1637
1656
  const countSeg = loading ? " loading\u2026 " : quiet ? " " : ` ${String(total).padStart(3)} item${total !== 1 ? "s" : ""} \xB7 `;
1638
1657
  const userSeg = loading || quiet ? "" : `@${login} `;
1639
1658
  const workLabel = scopeLabel ? ` ${scopeLabel} ` : "";
1659
+ const budgetSeg = budgetLabel ? budgetLabel + " " : "";
1640
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];
1641
1661
  const statusSeg = statusText ? statusText + " " : "";
1642
1662
  const fill = Math.max(
1643
1663
  4,
1644
- 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
1645
1665
  );
1646
1666
  return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1647
1667
  /* @__PURE__ */ jsx(Text2, { color: "#FF8700", bold: true, children: brand }),
1648
1668
  /* @__PURE__ */ jsx(Text2, { dimColor: true, children: countSeg }),
1649
1669
  userSeg ? /* @__PURE__ */ jsx(Text2, { children: userSeg }) : null,
1650
1670
  scopeLabel ? /* @__PURE__ */ jsx(Text2, { dimColor: true, children: workLabel }) : null,
1671
+ budgetLabel ? /* @__PURE__ */ jsx(Text2, { bold: true, color: budgetCritical ? "#FF5F5F" : "#FF8700", children: budgetSeg }) : null,
1651
1672
  statusText ? /* @__PURE__ */ jsx(
1652
1673
  Text2,
1653
1674
  {
@@ -2152,6 +2173,8 @@ var BrowseScreen = ({
2152
2173
  ciJob,
2153
2174
  tabHelp,
2154
2175
  origin,
2176
+ budget,
2177
+ skippedForBudget,
2155
2178
  brand,
2156
2179
  mergedUrls,
2157
2180
  transients,
@@ -2611,6 +2634,8 @@ var BrowseScreen = ({
2611
2634
  sections: localSections,
2612
2635
  login,
2613
2636
  scopeLabel: origin?.label,
2637
+ budgetLabel: budgetNotice(budget)?.label,
2638
+ budgetCritical: budgetNotice(budget)?.critical,
2614
2639
  refreshing,
2615
2640
  hasPending,
2616
2641
  pendingSummary,
@@ -2618,6 +2643,10 @@ var BrowseScreen = ({
2618
2643
  }
2619
2644
  ),
2620
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,
2621
2650
  /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsx(
2622
2651
  Tabs,
2623
2652
  {
@@ -2816,9 +2845,21 @@ var App = ({
2816
2845
  ) : "",
2817
2846
  [pending]
2818
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
+ };
2819
2854
  const revalidate = (manual = false) => {
2855
+ if (!manual && !canAffordAuto()) {
2856
+ setSkippedForBudget(true);
2857
+ return;
2858
+ }
2859
+ setSkippedForBudget(false);
2820
2860
  if (manual) setRefreshing(true);
2821
2861
  fetcher().then((fresh) => {
2862
+ if (fresh.budget) setBudget(fresh.budget);
2822
2863
  if (cacheKey) writeCache(cacheKey, fresh);
2823
2864
  setRefreshing(false);
2824
2865
  setFetchedAt(Date.now());
@@ -2929,6 +2970,10 @@ var App = ({
2929
2970
  watcher.close();
2930
2971
  };
2931
2972
  }, [watchPath, watchDebounceMs]);
2973
+ const [budget, setBudget] = useState(
2974
+ () => cacheKey ? readCache(cacheKey)?.budget ?? null : null
2975
+ );
2976
+ const [skippedForBudget, setSkippedForBudget] = useState(false);
2932
2977
  const [mergedUrls, setMergedUrls] = useState([]);
2933
2978
  const mergeTimers = useRef([]);
2934
2979
  useEffect(() => () => mergeTimers.current.forEach(clearTimeout), []);
@@ -3009,6 +3054,8 @@ var App = ({
3009
3054
  fetchedAt,
3010
3055
  refreshError: refreshError ?? void 0,
3011
3056
  origin,
3057
+ budget,
3058
+ skippedForBudget,
3012
3059
  hidden: overlay !== null,
3013
3060
  mergedUrls,
3014
3061
  transients,
@@ -3065,4 +3112,4 @@ var matchesFilter = (repo, filter) => {
3065
3112
  };
3066
3113
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
3067
3114
 
3068
- export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, 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 };
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.25.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
  },