@kud/gh-ink 0.25.0 → 0.26.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/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
  }
@@ -919,22 +924,11 @@ var YOURS = {
919
924
  queued: ["waiting", "pending", "threads", "approved"],
920
925
  spoken: ["threads"]
921
926
  };
922
- var whoseMove = (health, sectionId, standing, theySpokeLast) => (
923
- // Somebody else having the last word outranks every review state below. It is
924
- // the plainest claim on you there is — a question asked, an objection raised,
925
- // a "can you rebase" and none of it shows up as a health, because a bare
926
- // comment approves nothing, fails nothing and opens no thread.
927
- //
928
- // The row already SAID this and the band disagreed with it: the turn arrow
929
- // reads lastActor and drew `←`, and the explain panel spelled out "X spoke
930
- // last, your reply is owed", while the band filed it under Their move. Two
931
- // signals on one row, pointing opposite ways, until 2026-08-27.
932
- //
933
- // Only in that direction. YOU having spoken last does not hand the row over —
934
- // red CI on your own PR is yours whether or not you commented after it — so
935
- // that case falls through to the table.
936
- theySpokeLast ? "you" : YOURS[standing ?? STANDING[sectionId] ?? "queued"].includes(health) ? "you" : "them"
937
- );
927
+ var whoseMove = (health, sectionId, standing, theySpokeLast) => {
928
+ const position = standing ?? STANDING[sectionId] ?? "queued";
929
+ if (theySpokeLast && position === "authored") return "you";
930
+ return YOURS[position].includes(health) ? "you" : "them";
931
+ };
938
932
  var BAND_LABEL = {
939
933
  you: "Your move",
940
934
  them: "Their move"
@@ -1048,6 +1042,18 @@ var moveCursor = (items, current2, dir) => {
1048
1042
  if (next < 0 || next >= items.length) return current2;
1049
1043
  return next;
1050
1044
  };
1045
+ var budgetNotice = (budget, now = Date.now()) => {
1046
+ if (!budget) return null;
1047
+ if (new Date(budget.resetAt).getTime() <= now) return null;
1048
+ const per = Math.max(budget.cost, 1);
1049
+ const fetches = Math.floor(budget.remaining / per);
1050
+ if (fetches > 8) return null;
1051
+ const mins = Math.max(
1052
+ 1,
1053
+ Math.round((new Date(budget.resetAt).getTime() - now) / 6e4)
1054
+ );
1055
+ return fetches <= 0 ? { label: `\u26A1 API budget spent \xB7 ${mins}m`, critical: true } : { label: `\u26A1 ${fetches} fetch${fetches === 1 ? "" : "es"} left`, critical: false };
1056
+ };
1051
1057
  var gapsAbove = (items, i) => {
1052
1058
  const item = items[i];
1053
1059
  if (!item || i === 0) return false;
@@ -1626,6 +1632,8 @@ var InboxHeader = ({
1626
1632
  login,
1627
1633
  brand,
1628
1634
  scopeLabel,
1635
+ budgetLabel,
1636
+ budgetCritical,
1629
1637
  loading,
1630
1638
  quiet,
1631
1639
  refreshing,
@@ -1637,17 +1645,19 @@ var InboxHeader = ({
1637
1645
  const countSeg = loading ? " loading\u2026 " : quiet ? " " : ` ${String(total).padStart(3)} item${total !== 1 ? "s" : ""} \xB7 `;
1638
1646
  const userSeg = loading || quiet ? "" : `@${login} `;
1639
1647
  const workLabel = scopeLabel ? ` ${scopeLabel} ` : "";
1648
+ const budgetSeg = budgetLabel ? budgetLabel + " " : "";
1640
1649
  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
1650
  const statusSeg = statusText ? statusText + " " : "";
1642
1651
  const fill = Math.max(
1643
1652
  4,
1644
- COLS - brand.length - countSeg.length - userSeg.length - workLabel.length - statusSeg.length
1653
+ COLS - brand.length - countSeg.length - userSeg.length - workLabel.length - budgetSeg.length - statusSeg.length
1645
1654
  );
1646
1655
  return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1647
1656
  /* @__PURE__ */ jsx(Text2, { color: "#FF8700", bold: true, children: brand }),
1648
1657
  /* @__PURE__ */ jsx(Text2, { dimColor: true, children: countSeg }),
1649
1658
  userSeg ? /* @__PURE__ */ jsx(Text2, { children: userSeg }) : null,
1650
1659
  scopeLabel ? /* @__PURE__ */ jsx(Text2, { dimColor: true, children: workLabel }) : null,
1660
+ budgetLabel ? /* @__PURE__ */ jsx(Text2, { bold: true, color: budgetCritical ? "#FF5F5F" : "#FF8700", children: budgetSeg }) : null,
1651
1661
  statusText ? /* @__PURE__ */ jsx(
1652
1662
  Text2,
1653
1663
  {
@@ -2152,6 +2162,8 @@ var BrowseScreen = ({
2152
2162
  ciJob,
2153
2163
  tabHelp,
2154
2164
  origin,
2165
+ budget,
2166
+ skippedForBudget,
2155
2167
  brand,
2156
2168
  mergedUrls,
2157
2169
  transients,
@@ -2611,6 +2623,8 @@ var BrowseScreen = ({
2611
2623
  sections: localSections,
2612
2624
  login,
2613
2625
  scopeLabel: origin?.label,
2626
+ budgetLabel: budgetNotice(budget)?.label,
2627
+ budgetCritical: budgetNotice(budget)?.critical,
2614
2628
  refreshing,
2615
2629
  hasPending,
2616
2630
  pendingSummary,
@@ -2618,6 +2632,10 @@ var BrowseScreen = ({
2618
2632
  }
2619
2633
  ),
2620
2634
  ciStatusState ? /* @__PURE__ */ jsx(CiStatusLine, { state: ciStatusState, job: ciJob }) : null,
2635
+ skippedForBudget ? /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
2636
+ /* @__PURE__ */ jsx(Text2, { bold: true, color: "#FF8700", children: " \u26A1 auto-refresh paused to save API budget" }),
2637
+ /* @__PURE__ */ jsx(Text2, { dimColor: true, children: " r refreshes anyway" })
2638
+ ] }) : null,
2621
2639
  /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsx(
2622
2640
  Tabs,
2623
2641
  {
@@ -2816,9 +2834,21 @@ var App = ({
2816
2834
  ) : "",
2817
2835
  [pending]
2818
2836
  );
2837
+ const canAffordAuto = () => {
2838
+ const known = budget ?? (cacheKey ? readCache(cacheKey)?.budget : null);
2839
+ if (!known) return true;
2840
+ if (new Date(known.resetAt).getTime() <= Date.now()) return true;
2841
+ return known.remaining >= Math.max(known.cost, 1) * 2;
2842
+ };
2819
2843
  const revalidate = (manual = false) => {
2844
+ if (!manual && !canAffordAuto()) {
2845
+ setSkippedForBudget(true);
2846
+ return;
2847
+ }
2848
+ setSkippedForBudget(false);
2820
2849
  if (manual) setRefreshing(true);
2821
2850
  fetcher().then((fresh) => {
2851
+ if (fresh.budget) setBudget(fresh.budget);
2822
2852
  if (cacheKey) writeCache(cacheKey, fresh);
2823
2853
  setRefreshing(false);
2824
2854
  setFetchedAt(Date.now());
@@ -2929,6 +2959,10 @@ var App = ({
2929
2959
  watcher.close();
2930
2960
  };
2931
2961
  }, [watchPath, watchDebounceMs]);
2962
+ const [budget, setBudget] = useState(
2963
+ () => cacheKey ? readCache(cacheKey)?.budget ?? null : null
2964
+ );
2965
+ const [skippedForBudget, setSkippedForBudget] = useState(false);
2932
2966
  const [mergedUrls, setMergedUrls] = useState([]);
2933
2967
  const mergeTimers = useRef([]);
2934
2968
  useEffect(() => () => mergeTimers.current.forEach(clearTimeout), []);
@@ -3009,6 +3043,8 @@ var App = ({
3009
3043
  fetchedAt,
3010
3044
  refreshError: refreshError ?? void 0,
3011
3045
  origin,
3046
+ budget,
3047
+ skippedForBudget,
3012
3048
  hidden: overlay !== null,
3013
3049
  mergedUrls,
3014
3050
  transients,
@@ -3065,4 +3101,4 @@ var matchesFilter = (repo, filter) => {
3065
3101
  };
3066
3102
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
3067
3103
 
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 };
3104
+ 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.1",
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
  },