@kud/gh-ink 0.74.0 → 0.75.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/README.md CHANGED
@@ -61,6 +61,31 @@ codepoints; sibling host verbs should match them rather than invent neighbours:
61
61
  | Unsubscribe / Mute / Remove reviewer | U+F478 / U+F466 / U+F468 |
62
62
  | Close PR / and delete / issue | U+F4DC / U+F48E / U+F41D |
63
63
 
64
+ ## Inbox focus slot and tab icons
65
+
66
+ `App` draws one standing row directly above the tab strip for whatever the
67
+ reader should look at next. The fetch result carries it as
68
+ `focus?: FocusSlot | null`: absent means the host has no focus feature and no
69
+ row is drawn; `null` means nothing to point at and draws the dim empty line.
70
+ It rides the fetch (and the cache beside the rows) because it goes stale the
71
+ same way they do.
72
+
73
+ | Prop / field | What |
74
+ | ------------------ | --------------------------------------------------------------------------------------------------- |
75
+ | `focusLabel` | What to call the row (default `focus`). Passing this or `focusEmpty` reserves the row from first paint. |
76
+ | `focusEmpty` | The empty sentence, drawn dim after the label when focus comes back `null`. |
77
+ | `FocusSlot` | `label`, `ref` (a ticket key, or a PR-style `#427`), `title`, `reason`, optional `marker` (the host's priority glyph), `mark`/`markColor`/`refColor` overrides, and `target: { tab, url }`. |
78
+ | `g` | Jumps to `focus.target`: switches to that tab and lands the cursor on that row, opening a collapsed tail it is folded into. Listed in the `?` legend only while a focus is on screen. |
79
+
80
+ A row whose url matches the target draws the mark in a fixed gutter between
81
+ the tree glyph and the marker cell; every other row holds two blanks there, so
82
+ the mark moves nothing sideways. Without a focus the rows draw what they
83
+ always drew.
84
+
85
+ Tabs take an optional `Section.icon`, passed through to ink-ui's
86
+ `TabItem.icon`: one glyph naming the tab beside its label, which is also what
87
+ an inactive tab folds down to when the strip does not fit its width.
88
+
64
89
  ## Development
65
90
 
66
91
  ```sh
package/dist/index.d.ts CHANGED
@@ -179,6 +179,94 @@ declare const SidePanel: ({ sidebar, liveLabel, width, height, focused, cursor,
179
179
  cursor?: number;
180
180
  }) => React.JSX.Element;
181
181
 
182
+ /**
183
+ * The one thing the reader should look at next, in the host's words.
184
+ *
185
+ * Data, not vocabulary: `label` is the host's word for the row ("focus"), but
186
+ * everything else arrived over the wire — which ticket, what it says, where it
187
+ * lives — and goes stale the same way the rows do. That is why it rides the
188
+ * fetch result (and the cache beside the rows) rather than arriving as props.
189
+ *
190
+ * `undefined` (the field absent) means the host has no focus feature and draws
191
+ * no row at all; `null` means there is one and it currently has nothing to
192
+ * point at, which draws the dim empty line. The distinction matters because a
193
+ * host that never wired the feature must keep byte-identical output, while a
194
+ * host whose feature came back empty still owes the reader one line saying so.
195
+ */
196
+ type FocusSlot = {
197
+ /** The mark in front of the row, accent by default. */
198
+ mark?: string;
199
+ /** The mark's colour. Defaults to the accent. */
200
+ markColor?: string;
201
+ /** What the row is a slot OF, in the host's words: `focus`, say. */
202
+ label: string;
203
+ /** The thing to look at: a ticket key, or a PR-style `#427`. */
204
+ ref: string;
205
+ /** The ref's colour. A `#` ref takes the accent, anything else the ticket. */
206
+ refColor?: string;
207
+ /** The row's own title, bold. Truncated to keep the line to one row. */
208
+ title: string;
209
+ /** Why this is the focus, dim, three spaces after the title. */
210
+ reason: string;
211
+ /** The host's priority glyph for the row, drawn in the default foreground. */
212
+ marker?: string;
213
+ /** Where `g` goes: the tab id and the row url. */
214
+ target: {
215
+ tab: string;
216
+ url: string;
217
+ };
218
+ };
219
+ declare const FOCUS_MARK = "\uDB81\uDCFE";
220
+ declare const DEFAULT_FOCUS_LABEL = "focus";
221
+ declare const DEFAULT_FOCUS_EMPTY = "nothing to focus on";
222
+ /**
223
+ * What one row draws in the focus gutter, resolved by the list. `undefined`
224
+ * means no focus is on screen and the row draws nothing — the branch is
225
+ * absent, not blank, so output without the feature is byte-identical. `null`
226
+ * holds two blank columns on a row the focus is not pointing at, so the one
227
+ * row that carries the mark does not shift its neighbours.
228
+ */
229
+ type FocusGutter = {
230
+ mark: string;
231
+ color?: string;
232
+ } | null;
233
+ /**
234
+ * The gutter cell for a row url: the mark when this is the row `g` would land
235
+ * on, a blank holder on every other row while a focus is on screen, nothing at
236
+ * all without one. One function so the task rows and the PR rows cannot
237
+ * disagree about which row is marked.
238
+ */
239
+ declare const focusCellFor: (focus: FocusSlot | null | undefined, url: string | undefined) => FocusGutter | undefined;
240
+ /**
241
+ * The ref's colour, for the one decision the frame cannot show. A `#427` is a
242
+ * pull request and takes the accent — the same orange the PR rows number
243
+ * themselves in — while a ticket key takes the ticket yellow. An explicit
244
+ * `refColor` wins over both, for a host whose refs follow another vocabulary.
245
+ *
246
+ * Exported pure because the test frame carries no escape codes: a colour that
247
+ * lived only in a tint would be a colour nobody can test for.
248
+ */
249
+ declare const focusRefColor: (focus: Pick<FocusSlot, "ref" | "refColor">) => string;
250
+ /**
251
+ * One standing single-line row, the same height across loading / ready / empty
252
+ * so the tab strip below it never jumps — the same contract `StatusStripLine`
253
+ * keeps, and drawn in the same place, directly above the tabs.
254
+ *
255
+ * `undefined` while the first answer is out; `null` when the host answered
256
+ * with nothing to point at. Either prop (`label`/`empty`) reserves the row;
257
+ * with neither and no focus value the caller draws nothing at all.
258
+ */
259
+ declare const FocusSlotLine: ({ focus, label, empty, width, }: {
260
+ /** `undefined` while the first answer is out; `null` when it came back empty. */
261
+ focus: FocusSlot | null | undefined;
262
+ /** What to call the row. The host's word; defaults to `focus`. */
263
+ label?: string;
264
+ /** The empty sentence, drawn dim after the label when focus is null. */
265
+ empty?: string;
266
+ /** Columns the row may use. */
267
+ width: number;
268
+ }) => React.JSX.Element;
269
+
182
270
  /**
183
271
  * How this row is moving through the list right now.
184
272
  *
@@ -270,6 +358,15 @@ type PrRowProps = {
270
358
  * to the title rather than standing empty.
271
359
  */
272
360
  gutter?: boolean;
361
+ /**
362
+ * The focus gutter, resolved by the list — see `focusCellFor`. `undefined`
363
+ * draws nothing at all (no focus feature, byte-identical to before); `null`
364
+ * holds two blank columns so the marked row does not shift its neighbours;
365
+ * a mark draws the focus glyph there in its colour. A fixed cell either way,
366
+ * for the same reason the cursor gutter is one: a mark that moved the title
367
+ * of exactly the row being watched would defeat its own purpose.
368
+ */
369
+ focusCell?: FocusGutter;
273
370
  };
274
371
  /**
275
372
  * The trailing block, in reading order: whose, how contested, how big, how
@@ -313,7 +410,9 @@ declare const trailingColumnsOf: (items: readonly GHItem[]) => TrailingColumns;
313
410
  * three, and a six-digit number would have run straight into its title.
314
411
  */
315
412
  declare const numberColumnsOf: (items: readonly GHItem[]) => number;
316
- type LayoutInput = Pick<PrRowProps, "item" | "login" | "cols" | "prefix" | "uniformLabels" | "announcements" | "columns" | "shed" | "numberCols" | "gutter">;
413
+ type LayoutInput = Pick<PrRowProps, "item" | "login" | "cols" | "prefix" | "uniformLabels" | "announcements" | "columns" | "shed" | "numberCols" | "gutter"> & {
414
+ focusGutter?: boolean;
415
+ };
317
416
  /**
318
417
  * Which trailing columns the section has to give up, so that every row gives up
319
418
  * the same ones. Each row is laid out alone and the drops are unioned: if any
@@ -339,7 +438,7 @@ declare const sectionShedOf: (rows: readonly Pick<LayoutInput, "item" | "prefix"
339
438
  * arrival, merge and departure belongs to whoever owns the list, and reaches
340
439
  * the row as `icon`, `motion` and `announcements` after the decisions are made.
341
440
  */
342
- declare const PrRow: ({ item, active, login, cols, selectionBg, icon: iconOverride, prefix, uniformLabels, motion, announcements, columns, shed, numberCols, gutter, }: PrRowProps) => React.JSX.Element;
441
+ declare const PrRow: ({ item, active, login, cols, selectionBg, icon: iconOverride, prefix, uniformLabels, motion, announcements, columns, shed, numberCols, gutter, focusCell, }: PrRowProps) => React.JSX.Element;
343
442
 
344
443
  /**
345
444
  * One state per thing, from a closed set the strip knows how to draw. The host
@@ -517,6 +616,16 @@ type CachedCockpit = {
517
616
  * refresh — `i` did nothing and the footer did not even offer it.
518
617
  */
519
618
  sidebar?: Rails;
619
+ /**
620
+ * The focus slot, when the host supplied one. Cached beside the rows because
621
+ * it is painted beside them: a launch that trusts the cache never refetches,
622
+ * so a focus left out of the file is a slot that stays on `loading…` until
623
+ * the next refresh — or never fills in at all.
624
+ *
625
+ * Absent reads as `undefined`: no focus feature, no row. `null` is a real
626
+ * answer ("nothing to focus on") and round-trips as one.
627
+ */
628
+ focus?: FocusSlot | null;
520
629
  };
521
630
  declare const readCache: (key: string) => CachedCockpit | null;
522
631
  declare const writeCache: (key: string, data: {
@@ -524,6 +633,7 @@ declare const writeCache: (key: string, data: {
524
633
  login: string;
525
634
  budget?: InboxBudget;
526
635
  sidebar?: Rails;
636
+ focus?: FocusSlot | null;
527
637
  }) => void;
528
638
 
529
639
  /**
@@ -880,7 +990,7 @@ declare const ActionMenu: ({ item, actions, cursor, cols, }: {
880
990
  cols?: number;
881
991
  }) => React.JSX.Element;
882
992
  declare const signatureOf: (sections: Section[]) => string;
883
- declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraSetupHint, jiraTransitions, jiraTransitionsFor, tabForStatus, hasCiStatus, ciJob, ciFetcher, ciPollMs, stripFetcher, stripLabel, stripPollMs, watchPath, watchDebounceMs, watchJitterMs, extensions, tabHelp, emptyHint, liveLabel, onSettled, }: {
993
+ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraSetupHint, jiraTransitions, jiraTransitionsFor, tabForStatus, hasCiStatus, ciJob, ciFetcher, ciPollMs, stripFetcher, stripLabel, stripPollMs, focusLabel, focusEmpty, watchPath, watchDebounceMs, watchJitterMs, extensions, tabHelp, emptyHint, liveLabel, onSettled, }: {
884
994
  fetcher: () => Promise<{
885
995
  sections: Section[];
886
996
  login: string;
@@ -901,6 +1011,15 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
901
1011
  * data that goes stale the same way the rows do.
902
1012
  */
903
1013
  sidebar?: Rails;
1014
+ /**
1015
+ * What the reader should look at next, in the host's words — see
1016
+ * `FocusSlot`. Part of the fetch result for the same reason the rail is:
1017
+ * it goes stale with the rows, and the cache paints it beside them.
1018
+ *
1019
+ * Absent means the host has no focus feature and no row is drawn; `null`
1020
+ * means nothing to point at and draws the dim empty line.
1021
+ */
1022
+ focus?: FocusSlot | null;
904
1023
  }>;
905
1024
  cacheKey?: string;
906
1025
  /**
@@ -943,6 +1062,16 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
943
1062
  /** What to call the row before its first answer arrives with a title. */
944
1063
  stripLabel?: string;
945
1064
  stripPollMs?: number;
1065
+ /**
1066
+ * What to call the focus row. Passing this (or `focusEmpty`) reserves the
1067
+ * row from the first paint — `loading…` until the fetch lands — while a
1068
+ * host that never wires the feature passes neither and draws nothing.
1069
+ * Vocabulary, not data: it never goes stale, so it is a prop rather than
1070
+ * part of the fetch result.
1071
+ */
1072
+ focusLabel?: string;
1073
+ /** The empty sentence, drawn dim after the label when focus comes back null. */
1074
+ focusEmpty?: string;
946
1075
  /**
947
1076
  * A file something else touches when it has changed GitHub on your behalf —
948
1077
  * a Claude session closing an issue, a script merging a PR. Touching it makes
@@ -995,4 +1124,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
995
1124
  */
996
1125
  declare const parsePatterns: (value: string) => string[];
997
1126
 
998
- export { type Action, type ActionGroup, ActionMenu, App, COLS, type ChromeHints, type ChromeSpec, type ChromeState, CiStatusLine, type CiStatusState, type Command, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, HealthPanel, type HealthPanelProps, type InboxBudget, type InboxExtension, type JiraAvailableTransition, type JiraTransition, type JiraTransitionsFor, type LiveLabel, LookupMiss, MIN_SIDEBAR_COLS, type MenuGap, type MenuRow, PrRow, type PrRowProps, type Rails, type RepoFilter, type RowAnnouncement, type RowMotion, SIDEBAR_COLS, type SettledResult, SidePanel, type Sidebar, type SidebarRow, type StatusStrip, StatusStripLine, type StripItem, type StripState, type StripStatusState, type TabForStatus, type TrailingColumns, type TrailingShed, ageLabel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, isMenuGap, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, menuRowsOf, menuTone, moveCursor, numberColumnsOf, openInTab, parsePatterns, railCapacity, railHeights, railRowsOf, railWidth, railsOf, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sectionShedOf, signatureOf, stripGlyph, stripLayout, stripSignature, toCiStatusState, trailingColumnsOf, truncate, useActionMenu, useChrome, windowCount, withHeaders, withoutItem, workCount, writeCache };
1127
+ export { type Action, type ActionGroup, ActionMenu, App, COLS, type ChromeHints, type ChromeSpec, type ChromeState, CiStatusLine, type CiStatusState, type Command, CommentsPanel, type CommentsPanelProps, DEFAULT_FOCUS_EMPTY, DEFAULT_FOCUS_LABEL, type DetailContext, type ExplainSection, type ExtensionTarget, FOCUS_MARK, type FocusGutter, type FocusSlot, FocusSlotLine, HealthPanel, type HealthPanelProps, type InboxBudget, type InboxExtension, type JiraAvailableTransition, type JiraTransition, type JiraTransitionsFor, type LiveLabel, LookupMiss, MIN_SIDEBAR_COLS, type MenuGap, type MenuRow, PrRow, type PrRowProps, type Rails, type RepoFilter, type RowAnnouncement, type RowMotion, SIDEBAR_COLS, type SettledResult, SidePanel, type Sidebar, type SidebarRow, type StatusStrip, StatusStripLine, type StripItem, type StripState, type StripStatusState, type TabForStatus, type TrailingColumns, type TrailingShed, ageLabel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, focusCellFor, focusRefColor, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, isMenuGap, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, menuRowsOf, menuTone, moveCursor, numberColumnsOf, openInTab, parsePatterns, railCapacity, railHeights, railRowsOf, railWidth, railsOf, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sectionShedOf, signatureOf, stripGlyph, stripLayout, stripSignature, toCiStatusState, trailingColumnsOf, truncate, useActionMenu, useChrome, windowCount, withHeaders, withoutItem, workCount, writeCache };
package/dist/index.js CHANGED
@@ -876,7 +876,8 @@ var layoutOf = ({
876
876
  columns,
877
877
  shed = {},
878
878
  numberCols,
879
- gutter = true
879
+ gutter = true,
880
+ focusGutter = false
880
881
  }) => {
881
882
  const numCols = numberCols ?? numberColumnsOf([item]);
882
883
  const numStr = `#${item.number}`.padEnd(numCols);
@@ -923,7 +924,7 @@ var layoutOf = ({
923
924
  );
924
925
  const pills = announcements.reduce((w, a) => w + 2 + a.label.length, 0);
925
926
  const cell = labelCell();
926
- return (gutter ? 2 : 0) + prefix.length + 2 + 2 + numCols + (cell ? 2 + cell.length + 1 : 0) + (givingUp.repo ? 0 : repoLabel.length) + block + pills + pillCaps + 4;
927
+ return (gutter ? 2 : 0) + prefix.length + (focusGutter ? 2 : 0) + 2 + 2 + numCols + (cell ? 2 + cell.length + 1 : 0) + (givingUp.repo ? 0 : repoLabel.length) + block + pills + pillCaps + 4;
927
928
  };
928
929
  const MIN_TITLE = 24;
929
930
  for (const give of [
@@ -969,7 +970,8 @@ var PrRow = ({
969
970
  columns,
970
971
  shed,
971
972
  numberCols,
972
- gutter = true
973
+ gutter = true,
974
+ focusCell
973
975
  }) => {
974
976
  const backdropped = useBackdropped();
975
977
  const { glyph: healthIcon, color: healthColor2 } = displayFor(item.health);
@@ -1002,7 +1004,8 @@ var PrRow = ({
1002
1004
  columns,
1003
1005
  shed,
1004
1006
  numberCols,
1005
- gutter
1007
+ gutter,
1008
+ focusGutter: focusCell !== void 0
1006
1009
  });
1007
1010
  return (
1008
1011
  // The wash paints every cell of the fixed-width row — an Ink Box's
@@ -1010,6 +1013,7 @@ var PrRow = ({
1010
1013
  /* @__PURE__ */ jsxs(Box, { width: cols, backgroundColor: selectionBg, children: [
1011
1014
  gutter ? /* @__PURE__ */ jsx(Text3, { color: colors.info, children: active ? "\u276F " : " " }) : null,
1012
1015
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: prefix }),
1016
+ focusCell !== void 0 ? focusCell ? /* @__PURE__ */ jsx(Text3, { color: focusCell.color, bold: true, children: `${focusCell.mark} ` }) : /* @__PURE__ */ jsx(Text3, { children: " " }) : null,
1013
1017
  /* @__PURE__ */ jsx(Text3, { color, bold: true, children: icon + " " }),
1014
1018
  /* @__PURE__ */ jsx(Text3, { color: turnColor, bold: turnIcon === "\u2190", children: turnIcon + " " }),
1015
1019
  /* @__PURE__ */ jsx(Text3, { color: colors.accent, children: numStr }),
@@ -1372,7 +1376,7 @@ var lookupRows = (claim, lookup, query, start, openUrl) => {
1372
1376
 
1373
1377
  // src/inbox/cache.ts
1374
1378
  var cacheTtlMs = () => inboxConfig().cacheTtlMs;
1375
- var CACHE_VERSION = 5;
1379
+ var CACHE_VERSION = 6;
1376
1380
  var cacheDir = () => join(
1377
1381
  process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
1378
1382
  inboxConfig().cacheNamespace
@@ -1383,11 +1387,14 @@ var readCache = (key) => {
1383
1387
  const raw = JSON.parse(readFileSync(cacheFile(key), "utf8"));
1384
1388
  if (raw?.version !== CACHE_VERSION) return null;
1385
1389
  if (!Array.isArray(raw?.sections)) return null;
1390
+ const focusOk = raw.focus === void 0 || raw.focus === null || typeof raw.focus?.target?.tab === "string" && typeof raw.focus?.target?.url === "string";
1391
+ if (!focusOk) return null;
1386
1392
  return {
1387
1393
  sections: raw.sections,
1388
1394
  login: raw.login ?? "",
1389
1395
  at: raw.at ?? 0,
1390
1396
  budget: raw.budget,
1397
+ ...raw.focus !== void 0 ? { focus: raw.focus } : {},
1391
1398
  ...railsOf(raw.sidebar).every((s) => Array.isArray(s?.rows)) && raw.sidebar !== void 0 ? { sidebar: raw.sidebar } : {}
1392
1399
  };
1393
1400
  } catch {
@@ -1686,6 +1693,56 @@ var failureNotice = (failed) => {
1686
1693
  const more = failed.length > 1 ? ` +${failed.length - 1} more` : "";
1687
1694
  return `\u2717 Couldn't ${newest.action} ${newest.label}: ${newest.failure?.reason ?? "no reason reported"}${more} w restore`;
1688
1695
  };
1696
+ var FOCUS_MARK = "\u{F04FE}";
1697
+ var DEFAULT_FOCUS_LABEL = "focus";
1698
+ var DEFAULT_FOCUS_EMPTY = "nothing to focus on";
1699
+ var focusCellFor = (focus, url) => {
1700
+ if (focus === void 0) return void 0;
1701
+ if (!focus || url !== focus.target.url) return null;
1702
+ return { mark: focus.mark ?? FOCUS_MARK, color: focus.markColor ?? colors.accent };
1703
+ };
1704
+ var focusRefColor = (focus) => focus.refColor ?? (focus.ref.startsWith("#") ? colors.accent : colors.ticket);
1705
+ var cells = (s) => [...s].length;
1706
+ var titleBudgetOf = (focus, label, width) => {
1707
+ const mark = focus.mark ?? FOCUS_MARK;
1708
+ const head = 2 + cells(mark) + 1 + cells(label) + 2 + (focus.marker ? cells(focus.marker) + 1 : 0) + cells(focus.ref) + 2;
1709
+ const tail = 3 + cells(focus.reason);
1710
+ const titleMax = width - head - tail;
1711
+ if (titleMax >= 1)
1712
+ return { title: truncate(focus.title, titleMax), reason: focus.reason };
1713
+ return {
1714
+ title: "",
1715
+ reason: truncate(focus.reason, Math.max(0, width - head - 3 - 1))
1716
+ };
1717
+ };
1718
+ var FocusSlotLine = ({
1719
+ focus,
1720
+ label = DEFAULT_FOCUS_LABEL,
1721
+ empty = DEFAULT_FOCUS_EMPTY,
1722
+ width
1723
+ }) => {
1724
+ if (focus === void 0)
1725
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1726
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
1727
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${label} loading\u2026` })
1728
+ ] });
1729
+ if (focus === null)
1730
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1731
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${FOCUS_MARK} ` }),
1732
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${label} ${empty}` })
1733
+ ] });
1734
+ const mark = focus.mark ?? FOCUS_MARK;
1735
+ const { title, reason } = titleBudgetOf(focus, label, width);
1736
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1737
+ /* @__PURE__ */ jsx(Text, { children: " " }),
1738
+ /* @__PURE__ */ jsx(Text, { color: focus.markColor ?? colors.accent, bold: true, children: `${mark} ` }),
1739
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${label} ` }),
1740
+ focus.marker ? /* @__PURE__ */ jsx(Text, { children: `${focus.marker} ` }) : null,
1741
+ /* @__PURE__ */ jsx(Text, { color: focusRefColor(focus), bold: true, children: `${focus.ref} ` }),
1742
+ /* @__PURE__ */ jsx(Text, { bold: true, children: title }),
1743
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${reason}` })
1744
+ ] });
1745
+ };
1689
1746
  var quietly = $({ quiet: true });
1690
1747
  var isMenuGap = (row) => "gap" in row;
1691
1748
  var rowLabelOf = (item) => item.kind === "task" ? item.ticket ?? item.key : item.kind === "pr" || item.kind === "issue" ? `#${item.number}` : "";
@@ -2911,7 +2968,8 @@ var ItemRow = ({
2911
2968
  uniformLabels = [],
2912
2969
  columns,
2913
2970
  shed,
2914
- numberCols
2971
+ numberCols,
2972
+ focus
2915
2973
  }) => {
2916
2974
  const backdropped = useBackdropped();
2917
2975
  const selBg = selectionBackground(active, backdropped);
@@ -2955,12 +3013,16 @@ var ItemRow = ({
2955
3013
  const transitIcon = pending ? PENDING_GLYPH : !transient ? " " : isDeparture(transient) ? TRANSIT_OUT_FRAMES[rampAt(TRANSIT_OUT_FRAMES.length)] : isArrival(transient) ? TRANSIT_IN_FRAMES[rampAt(TRANSIT_IN_FRAMES.length)] : "\u25C9";
2956
3014
  const titleMax = Math.max(
2957
3015
  1,
2958
- cols - item.key.length - note.length - (item.pill ? pillWidth(item.pill) + 1 : 0) - (farewellLabel ? pillWidth(farewellLabel) + 2 : 0) - prefix.length - (markerCols > 0 ? markerCols + 1 : 0) - 12
3016
+ cols - item.key.length - note.length - (item.pill ? pillWidth(item.pill) + 1 : 0) - (farewellLabel ? pillWidth(farewellLabel) + 2 : 0) - prefix.length - (markerCols > 0 ? markerCols + 1 : 0) - // The focus gutter, priced only when it is drawn: two columns on every
3017
+ // url-bearing row while a focus is on screen, none at all without one.
3018
+ (focus !== void 0 ? 2 : 0) - 12
2959
3019
  );
3020
+ const focusCell = focusCellFor(focus, item.url);
2960
3021
  return /* @__PURE__ */ jsxs(Box, { marginTop: gap ? 1 : 0, width: cols, backgroundColor: selBg, children: [
2961
3022
  /* @__PURE__ */ jsx(Text3, { color: colors.info, children: active ? "\u276F " : " " }),
2962
3023
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: prefix }),
2963
3024
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: parent ? "\u252C " : " " }),
3025
+ focusCell !== void 0 ? focusCell ? /* @__PURE__ */ jsx(Text3, { color: focusCell.color, bold: true, children: `${focusCell.mark} ` }) : /* @__PURE__ */ jsx(Text3, { children: " " }) : null,
2964
3026
  pending ? /* @__PURE__ */ jsx(Text3, { dimColor: true, children: transitIcon + " " }) : /* @__PURE__ */ jsx(Text3, { bold: true, color: transient ? TRANSIT_COLOUR[transient] : void 0, children: transitIcon + " " }),
2965
3027
  markerCols > 0 ? /* @__PURE__ */ jsx(Text3, { color: item.markerColor, children: (item.marker ?? "").padEnd(markerCols) + " " }) : null,
2966
3028
  /* @__PURE__ */ jsx(Text3, { ...taskKeyStyle(), children: item.key + " " }),
@@ -3019,7 +3081,11 @@ var ItemRow = ({
3019
3081
  numberCols,
3020
3082
  icon,
3021
3083
  motion,
3022
- announcements
3084
+ announcements,
3085
+ focusCell: focusCellFor(
3086
+ focus,
3087
+ "url" in item ? item.url : void 0
3088
+ )
3023
3089
  }
3024
3090
  );
3025
3091
  };
@@ -3223,6 +3289,7 @@ var LEGEND_FOOTNOTE = "Each item lands in the FIRST tab that claims it, so count
3223
3289
  var HelpModal = ({
3224
3290
  extensions,
3225
3291
  hasJira,
3292
+ hasFocus,
3226
3293
  tabHelp,
3227
3294
  maxRows,
3228
3295
  scroll = 0,
@@ -3257,6 +3324,7 @@ var HelpModal = ({
3257
3324
  ["f", "filter by repo"],
3258
3325
  ["r", "refresh"],
3259
3326
  ["w", "restore a change that failed"],
3327
+ ...hasFocus ? [["g", "jump to focus"]] : [],
3260
3328
  ...extensionLegend(extensions),
3261
3329
  ["?", "this help"],
3262
3330
  ["q", "quit"]
@@ -3452,6 +3520,9 @@ var BrowseScreen = ({
3452
3520
  ciJob,
3453
3521
  stripState,
3454
3522
  stripLabel,
3523
+ focus,
3524
+ focusLabel,
3525
+ focusEmpty,
3455
3526
  tabHelp,
3456
3527
  origin,
3457
3528
  budget,
@@ -3503,12 +3574,13 @@ var BrowseScreen = ({
3503
3574
  const filterActive = search != null || repoFilter.size > 0;
3504
3575
  const reserveCiRow = ciStatusState != null;
3505
3576
  const reserveStripRow = stripState != null;
3577
+ const reserveFocusRow = focusLabel !== void 0 || focusEmpty !== void 0 || focus !== void 0;
3506
3578
  const ciStatus = ciStatusState?.kind === "ready" ? ciStatusState.status : null;
3507
3579
  const listHeight = Math.max(
3508
3580
  5,
3509
3581
  // -10 rather than -8: the extra 2 are the frame's top and bottom border
3510
3582
  // rows, so the tree never grows taller than the terminal inside the frame.
3511
- rows - 10 - (filterActive ? 2 : 0) - (reserveCiRow ? 2 : 0) - (reserveStripRow ? 2 : 0)
3583
+ rows - 10 - (filterActive ? 2 : 0) - (reserveCiRow ? 2 : 0) - (reserveStripRow ? 2 : 0) - (reserveFocusRow ? 2 : 0)
3512
3584
  );
3513
3585
  useEffect(() => {
3514
3586
  setLocalSections(applyWork(sections));
@@ -3654,7 +3726,10 @@ var BrowseScreen = ({
3654
3726
  cols: rowCols,
3655
3727
  uniformLabels,
3656
3728
  columns: trailingColumns,
3657
- numberCols
3729
+ numberCols,
3730
+ // Presence only: every row holds the same two gutter columns while a
3731
+ // focus is on screen, so the shed ladder prices the cost once for all.
3732
+ focusGutter: focus !== void 0
3658
3733
  }
3659
3734
  );
3660
3735
  const [sparkFrame, setSparkFrame] = useState(0);
@@ -3894,6 +3969,56 @@ var BrowseScreen = ({
3894
3969
  onRefresh?.();
3895
3970
  return;
3896
3971
  }
3972
+ if (input === "g" && focus) {
3973
+ const tabId = focus.target.tab;
3974
+ const at = localSections.findIndex((s) => s.id === tabId);
3975
+ if (at === -1) return;
3976
+ const drawn = (s) => {
3977
+ const one = search != null ? filterBySearch([s], search, login) : [s];
3978
+ const two = repoFilter.size > 0 ? filterByRepos(one, repoFilter, login) : one;
3979
+ return filterActive ? two[0]?.items ?? [] : s.items;
3980
+ };
3981
+ const urlOf = (i) => "url" in i ? i.url : void 0;
3982
+ let items = localSections[at].items;
3983
+ if (!items.some((i) => urlOf(i) === focus.target.url)) {
3984
+ const tail = items.find(
3985
+ (i) => i.kind === "show-more" && i.hidden.some((h) => h.url === focus.target.url)
3986
+ );
3987
+ if (!tail) {
3988
+ setTabIdx(at);
3989
+ return;
3990
+ }
3991
+ items = [
3992
+ ...items.slice(0, items.indexOf(tail)),
3993
+ ...tail.hidden,
3994
+ {
3995
+ kind: "show-less",
3996
+ toHide: tail.hidden,
3997
+ // The depth of the row it replaces, never a constant — a collapsed
3998
+ // row under a story sits deeper than 1, and pinning it breaks the
3999
+ // `treeUrls` walk exactly as the comment there warns.
4000
+ depth: depthOf(tail)
4001
+ },
4002
+ ...items.slice(items.indexOf(tail) + 1)
4003
+ ];
4004
+ const expanded = items;
4005
+ setLocalSections(
4006
+ (prev) => prev.map((s) => s.id === tabId ? { ...s, items: expanded } : s)
4007
+ );
4008
+ prevItems.current = { ...prevItems.current, [tabId]: expanded };
4009
+ }
4010
+ setTabIdx(at);
4011
+ const shown = drawn({ ...localSections[at], items });
4012
+ const dit = shown.findIndex((i) => urlOf(i) === focus.target.url);
4013
+ if (dit === -1) return;
4014
+ setCursors((p) => ({ ...p, [tabId]: dit }));
4015
+ setViewStarts((p) => {
4016
+ let vs = Math.min(p[tabId] ?? 0, withHeaders(shown, dit));
4017
+ while (dit >= vs + windowCount(shown, vs, listHeight)) vs++;
4018
+ return { ...p, [tabId]: vs };
4019
+ });
4020
+ return;
4021
+ }
3897
4022
  const ext = extensionFor(input, extensions);
3898
4023
  if (ext) {
3899
4024
  onOpenExt?.(ext.id, extensionTargetFor(activeItem));
@@ -4309,6 +4434,7 @@ var BrowseScreen = ({
4309
4434
  {
4310
4435
  extensions,
4311
4436
  hasJira: !!jiraBase,
4437
+ hasFocus: focus != null,
4312
4438
  tabHelp,
4313
4439
  maxRows: listHeight,
4314
4440
  scroll: helpScroll,
@@ -4365,6 +4491,15 @@ var BrowseScreen = ({
4365
4491
  width: COLS
4366
4492
  }
4367
4493
  ) : null,
4494
+ reserveFocusRow ? /* @__PURE__ */ jsx(
4495
+ FocusSlotLine,
4496
+ {
4497
+ focus,
4498
+ label: focusLabel,
4499
+ empty: focusEmpty,
4500
+ width: COLS
4501
+ }
4502
+ ) : null,
4368
4503
  skippedForBudget ? /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
4369
4504
  /* @__PURE__ */ jsx(Text3, { bold: true, color: colors.accent, children: " \u26A1 auto-refresh paused to save API budget" }),
4370
4505
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: " r refreshes anyway" })
@@ -4373,9 +4508,11 @@ var BrowseScreen = ({
4373
4508
  Tabs,
4374
4509
  {
4375
4510
  active: section.id,
4511
+ width: COLS,
4376
4512
  items: localSections.map((s) => ({
4377
4513
  value: s.id,
4378
4514
  label: s.label,
4515
+ icon: s.icon,
4379
4516
  count: countOf(s.id),
4380
4517
  /*
4381
4518
  * The denominator, and only where it can still be true.
@@ -4461,6 +4598,7 @@ var BrowseScreen = ({
4461
4598
  columns: trailingColumns,
4462
4599
  shed: trailingShed,
4463
4600
  numberCols,
4601
+ focus,
4464
4602
  gap: i > 0 && gapsAbove(section.items, viewStart + i)
4465
4603
  },
4466
4604
  `${viewStart + i}:${rowKey(item)}`
@@ -4556,6 +4694,8 @@ var App = ({
4556
4694
  stripFetcher,
4557
4695
  stripLabel,
4558
4696
  stripPollMs = 3e5,
4697
+ focusLabel,
4698
+ focusEmpty,
4559
4699
  watchPath,
4560
4700
  watchDebounceMs = 400,
4561
4701
  // Zero by default, deliberately. A package that adds randomness to its own
@@ -4738,6 +4878,7 @@ var App = ({
4738
4878
  )
4739
4879
  };
4740
4880
  if (fresh.budget) setBudget(fresh.budget);
4881
+ if (fresh.focus !== void 0) setFocus(fresh.focus);
4741
4882
  if (fresh.sidebar !== void 0) setSidebar(fresh.sidebar);
4742
4883
  if (cacheKey) writeCache(cacheKey, fresh);
4743
4884
  setRefreshing(false);
@@ -4890,6 +5031,7 @@ var App = ({
4890
5031
  displayedSections.current = cached.sections;
4891
5032
  setFetchedAt(cached.at);
4892
5033
  if (cached.sidebar) setSidebar(cached.sidebar);
5034
+ if (cached.focus !== void 0) setFocus(cached.focus);
4893
5035
  setState({
4894
5036
  phase: "browse",
4895
5037
  sections: cached.sections,
@@ -4952,6 +5094,7 @@ var App = ({
4952
5094
  if (shared && shared.at > firedAt) {
4953
5095
  receive({ sections: shared.sections, login: shared.login });
4954
5096
  if (shared.sidebar) setSidebar(shared.sidebar);
5097
+ if (shared.focus !== void 0) setFocus(shared.focus);
4955
5098
  setFetchedAt(shared.at);
4956
5099
  return;
4957
5100
  }
@@ -4972,6 +5115,7 @@ var App = ({
4972
5115
  );
4973
5116
  const [appliedSummary, setAppliedSummary] = useState("");
4974
5117
  const [sidebar, setSidebar] = useState(void 0);
5118
+ const [focus, setFocus] = useState(void 0);
4975
5119
  const [skippedForBudget, setSkippedForBudget] = useState(false);
4976
5120
  const [mergedUrls, setMergedUrls] = useState([]);
4977
5121
  const [leavingUrls, setLeavingUrls] = useState([]);
@@ -5024,6 +5168,15 @@ var App = ({
5024
5168
  width: COLS
5025
5169
  }
5026
5170
  ) : null,
5171
+ focusLabel !== void 0 || focusEmpty !== void 0 ? /* @__PURE__ */ jsx(
5172
+ FocusSlotLine,
5173
+ {
5174
+ focus: void 0,
5175
+ label: focusLabel,
5176
+ empty: focusEmpty,
5177
+ width: COLS
5178
+ }
5179
+ ) : null,
5027
5180
  /* @__PURE__ */ jsx(
5028
5181
  Box,
5029
5182
  {
@@ -5136,6 +5289,9 @@ var App = ({
5136
5289
  ciJob,
5137
5290
  stripState: hasStrip ? stripState : void 0,
5138
5291
  stripLabel,
5292
+ focus,
5293
+ focusLabel,
5294
+ focusEmpty,
5139
5295
  tabHelp,
5140
5296
  onOpenPr: (item) => {
5141
5297
  setChromeSpec({});
@@ -5184,4 +5340,4 @@ var matchesFilter = (repo, filter) => {
5184
5340
  };
5185
5341
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
5186
5342
 
5187
- export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, LookupMiss, MIN_SIDEBAR_COLS, PrRow, SIDEBAR_COLS, SidePanel, StatusStripLine, ageLabel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, isMenuGap, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, menuRowsOf, menuTone, moveCursor, numberColumnsOf, openInTab, parsePatterns, railCapacity, railHeights, railRowsOf, railWidth, railsOf, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sectionShedOf, signatureOf, stripGlyph, stripLayout, stripSignature, toCiStatusState, trailingColumnsOf, truncate, useActionMenu, useChrome, windowCount, withHeaders, withoutItem, workCount, writeCache };
5343
+ export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, DEFAULT_FOCUS_EMPTY, DEFAULT_FOCUS_LABEL, FOCUS_MARK, FocusSlotLine, HealthPanel, LookupMiss, MIN_SIDEBAR_COLS, PrRow, SIDEBAR_COLS, SidePanel, StatusStripLine, ageLabel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, focusCellFor, focusRefColor, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, isMenuGap, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, menuRowsOf, menuTone, moveCursor, numberColumnsOf, openInTab, parsePatterns, railCapacity, railHeights, railRowsOf, railWidth, railsOf, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sectionShedOf, signatureOf, stripGlyph, stripLayout, stripSignature, toCiStatusState, trailingColumnsOf, truncate, useActionMenu, useChrome, windowCount, withHeaders, withoutItem, workCount, writeCache };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/gh-ink",
3
- "version": "0.74.0",
3
+ "version": "0.75.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",
@@ -44,17 +44,17 @@
44
44
  "access": "public"
45
45
  },
46
46
  "peerDependencies": {
47
- "@kud/ink-ui": ">=0.38.0 <1",
47
+ "@kud/ink-ui": ">=0.39.0 <1",
48
48
  "ink": ">=7",
49
49
  "react": ">=19"
50
50
  },
51
51
  "dependencies": {
52
52
  "@kud/gh": "0.21.0",
53
- "@kud/gh-workflow": "0.16.0",
53
+ "@kud/gh-workflow": "0.17.0",
54
54
  "zx": "8.8.5"
55
55
  },
56
56
  "devDependencies": {
57
- "@kud/ink-ui": "0.38.0",
57
+ "@kud/ink-ui": "0.39.0",
58
58
  "@types/node": "22.20.1",
59
59
  "@types/react": "19.2.17",
60
60
  "ink": "7.1.0",