@kud/gh-ink 0.74.0 → 0.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,101 @@ 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 FOCUS_JUMP_KEY = "g";
221
+ declare const DEFAULT_FOCUS_LABEL = "focus";
222
+ declare const DEFAULT_FOCUS_EMPTY = "nothing to focus on";
223
+ /**
224
+ * What one row draws in the focus gutter, resolved by the list. `undefined`
225
+ * means no focus is on screen and the row draws nothing — the branch is
226
+ * absent, not blank, so output without the feature is byte-identical. `null`
227
+ * holds two blank columns on a row the focus is not pointing at, so the one
228
+ * row that carries the mark does not shift its neighbours.
229
+ */
230
+ type FocusGutter = {
231
+ mark: string;
232
+ color?: string;
233
+ } | null;
234
+ /**
235
+ * The gutter cell for a row url: the mark when this is the row `g` would land
236
+ * on, a blank holder on every other row while a focus is on screen, nothing at
237
+ * all without one. One function so the task rows and the PR rows cannot
238
+ * disagree about which row is marked.
239
+ */
240
+ declare const focusCellFor: (focus: FocusSlot | null | undefined, url: string | undefined) => FocusGutter | undefined;
241
+ /**
242
+ * The ref's colour, for the one decision the frame cannot show. A `#427` is a
243
+ * pull request and takes the accent — the same orange the PR rows number
244
+ * themselves in — while a ticket key takes the ticket yellow. An explicit
245
+ * `refColor` wins over both, for a host whose refs follow another vocabulary.
246
+ *
247
+ * Exported pure because the test frame carries no escape codes: a colour that
248
+ * lived only in a tint would be a colour nobody can test for.
249
+ */
250
+ declare const focusRefColor: (focus: Pick<FocusSlot, "ref" | "refColor">) => string;
251
+ /**
252
+ * One standing single-line row, the same height across loading / ready / empty
253
+ * so the tab strip below it never jumps — the same contract `StatusStripLine`
254
+ * keeps, and drawn in the same place, directly above the tabs.
255
+ *
256
+ * `undefined` while the first answer is out; `null` when the host answered
257
+ * with nothing to point at. Either prop (`label`/`empty`) reserves the row;
258
+ * with neither and no focus value the caller draws nothing at all.
259
+ */
260
+ declare const FocusSlotLine: ({ focus, label, empty, width, jumpKey, }: {
261
+ /** `undefined` while the first answer is out; `null` when it came back empty. */
262
+ focus: FocusSlot | null | undefined;
263
+ /** What to call the row. The host's word; defaults to `focus`. */
264
+ label?: string;
265
+ /** The empty sentence, drawn dim after the label when focus is null. */
266
+ empty?: string;
267
+ /** Columns the row may use. */
268
+ width: number;
269
+ /**
270
+ * The key that jumps to the focus row, drawn as a quiet hint after the
271
+ * reason. Ready rows only: loading and empty have no row to jump to, so
272
+ * they never draw it even when it is set.
273
+ */
274
+ jumpKey?: string;
275
+ }) => React.JSX.Element;
276
+
182
277
  /**
183
278
  * How this row is moving through the list right now.
184
279
  *
@@ -270,6 +365,15 @@ type PrRowProps = {
270
365
  * to the title rather than standing empty.
271
366
  */
272
367
  gutter?: boolean;
368
+ /**
369
+ * The focus gutter, resolved by the list — see `focusCellFor`. `undefined`
370
+ * draws nothing at all (no focus feature, byte-identical to before); `null`
371
+ * holds two blank columns so the marked row does not shift its neighbours;
372
+ * a mark draws the focus glyph there in its colour. A fixed cell either way,
373
+ * for the same reason the cursor gutter is one: a mark that moved the title
374
+ * of exactly the row being watched would defeat its own purpose.
375
+ */
376
+ focusCell?: FocusGutter;
273
377
  };
274
378
  /**
275
379
  * The trailing block, in reading order: whose, how contested, how big, how
@@ -313,7 +417,9 @@ declare const trailingColumnsOf: (items: readonly GHItem[]) => TrailingColumns;
313
417
  * three, and a six-digit number would have run straight into its title.
314
418
  */
315
419
  declare const numberColumnsOf: (items: readonly GHItem[]) => number;
316
- type LayoutInput = Pick<PrRowProps, "item" | "login" | "cols" | "prefix" | "uniformLabels" | "announcements" | "columns" | "shed" | "numberCols" | "gutter">;
420
+ type LayoutInput = Pick<PrRowProps, "item" | "login" | "cols" | "prefix" | "uniformLabels" | "announcements" | "columns" | "shed" | "numberCols" | "gutter"> & {
421
+ focusGutter?: boolean;
422
+ };
317
423
  /**
318
424
  * Which trailing columns the section has to give up, so that every row gives up
319
425
  * the same ones. Each row is laid out alone and the drops are unioned: if any
@@ -339,7 +445,7 @@ declare const sectionShedOf: (rows: readonly Pick<LayoutInput, "item" | "prefix"
339
445
  * arrival, merge and departure belongs to whoever owns the list, and reaches
340
446
  * the row as `icon`, `motion` and `announcements` after the decisions are made.
341
447
  */
342
- declare const PrRow: ({ item, active, login, cols, selectionBg, icon: iconOverride, prefix, uniformLabels, motion, announcements, columns, shed, numberCols, gutter, }: PrRowProps) => React.JSX.Element;
448
+ declare const PrRow: ({ item, active, login, cols, selectionBg, icon: iconOverride, prefix, uniformLabels, motion, announcements, columns, shed, numberCols, gutter, focusCell, }: PrRowProps) => React.JSX.Element;
343
449
 
344
450
  /**
345
451
  * One state per thing, from a closed set the strip knows how to draw. The host
@@ -517,6 +623,16 @@ type CachedCockpit = {
517
623
  * refresh — `i` did nothing and the footer did not even offer it.
518
624
  */
519
625
  sidebar?: Rails;
626
+ /**
627
+ * The focus slot, when the host supplied one. Cached beside the rows because
628
+ * it is painted beside them: a launch that trusts the cache never refetches,
629
+ * so a focus left out of the file is a slot that stays on `loading…` until
630
+ * the next refresh — or never fills in at all.
631
+ *
632
+ * Absent reads as `undefined`: no focus feature, no row. `null` is a real
633
+ * answer ("nothing to focus on") and round-trips as one.
634
+ */
635
+ focus?: FocusSlot | null;
520
636
  };
521
637
  declare const readCache: (key: string) => CachedCockpit | null;
522
638
  declare const writeCache: (key: string, data: {
@@ -524,6 +640,7 @@ declare const writeCache: (key: string, data: {
524
640
  login: string;
525
641
  budget?: InboxBudget;
526
642
  sidebar?: Rails;
643
+ focus?: FocusSlot | null;
527
644
  }) => void;
528
645
 
529
646
  /**
@@ -880,7 +997,7 @@ declare const ActionMenu: ({ item, actions, cursor, cols, }: {
880
997
  cols?: number;
881
998
  }) => React.JSX.Element;
882
999
  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, }: {
1000
+ 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
1001
  fetcher: () => Promise<{
885
1002
  sections: Section[];
886
1003
  login: string;
@@ -901,6 +1018,15 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
901
1018
  * data that goes stale the same way the rows do.
902
1019
  */
903
1020
  sidebar?: Rails;
1021
+ /**
1022
+ * What the reader should look at next, in the host's words — see
1023
+ * `FocusSlot`. Part of the fetch result for the same reason the rail is:
1024
+ * it goes stale with the rows, and the cache paints it beside them.
1025
+ *
1026
+ * Absent means the host has no focus feature and no row is drawn; `null`
1027
+ * means nothing to point at and draws the dim empty line.
1028
+ */
1029
+ focus?: FocusSlot | null;
904
1030
  }>;
905
1031
  cacheKey?: string;
906
1032
  /**
@@ -943,6 +1069,16 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
943
1069
  /** What to call the row before its first answer arrives with a title. */
944
1070
  stripLabel?: string;
945
1071
  stripPollMs?: number;
1072
+ /**
1073
+ * What to call the focus row. Passing this (or `focusEmpty`) reserves the
1074
+ * row from the first paint — `loading…` until the fetch lands — while a
1075
+ * host that never wires the feature passes neither and draws nothing.
1076
+ * Vocabulary, not data: it never goes stale, so it is a prop rather than
1077
+ * part of the fetch result.
1078
+ */
1079
+ focusLabel?: string;
1080
+ /** The empty sentence, drawn dim after the label when focus comes back null. */
1081
+ focusEmpty?: string;
946
1082
  /**
947
1083
  * A file something else touches when it has changed GitHub on your behalf —
948
1084
  * a Claude session closing an issue, a script merging a PR. Touching it makes
@@ -995,4 +1131,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
995
1131
  */
996
1132
  declare const parsePatterns: (value: string) => string[];
997
1133
 
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 };
1134
+ 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_JUMP_KEY, 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,73 @@ 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 FOCUS_JUMP_KEY = "g";
1698
+ var DEFAULT_FOCUS_LABEL = "focus";
1699
+ var DEFAULT_FOCUS_EMPTY = "nothing to focus on";
1700
+ var focusCellFor = (focus, url) => {
1701
+ if (focus === void 0) return void 0;
1702
+ if (!focus || url !== focus.target.url) return null;
1703
+ return { mark: focus.mark ?? FOCUS_MARK, color: focus.markColor ?? colors.accent };
1704
+ };
1705
+ var focusRefColor = (focus) => focus.refColor ?? (focus.ref.startsWith("#") ? colors.accent : colors.ticket);
1706
+ var cells = (s) => [...s].length;
1707
+ var titleBudgetOf = (focus, label, width, jumpKey) => {
1708
+ const hint = jumpKey ? jumpKey : void 0;
1709
+ const mark = focus.mark ?? FOCUS_MARK;
1710
+ const head = 2 + cells(mark) + 1 + cells(label) + 2 + (focus.marker ? cells(focus.marker) + 1 : 0) + cells(focus.ref) + 2;
1711
+ const hintCells = hint ? 3 + cells(hint) + 5 : 0;
1712
+ const tail = 3 + cells(focus.reason);
1713
+ const titleMax = width - head - tail - hintCells;
1714
+ if (titleMax >= 1)
1715
+ return { title: truncate(focus.title, titleMax), reason: focus.reason, hint };
1716
+ const reasonMax = width - head - 3 - 1 - hintCells;
1717
+ if (hint === void 0 || reasonMax >= 1)
1718
+ return {
1719
+ title: "",
1720
+ reason: truncate(focus.reason, Math.max(0, width - head - 3 - 1 - hintCells)),
1721
+ hint
1722
+ };
1723
+ return {
1724
+ title: "",
1725
+ reason: truncate(focus.reason, Math.max(0, width - head - 3 - 1)),
1726
+ hint: void 0
1727
+ };
1728
+ };
1729
+ var FocusSlotLine = ({
1730
+ focus,
1731
+ label = DEFAULT_FOCUS_LABEL,
1732
+ empty = DEFAULT_FOCUS_EMPTY,
1733
+ width,
1734
+ jumpKey
1735
+ }) => {
1736
+ if (focus === void 0)
1737
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1738
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
1739
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${label} loading\u2026` })
1740
+ ] });
1741
+ if (focus === null)
1742
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1743
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${FOCUS_MARK} ` }),
1744
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${label} ${empty}` })
1745
+ ] });
1746
+ const mark = focus.mark ?? FOCUS_MARK;
1747
+ const { title, reason, hint } = titleBudgetOf(focus, label, width, jumpKey);
1748
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1749
+ /* @__PURE__ */ jsx(Text, { children: " " }),
1750
+ /* @__PURE__ */ jsx(Text, { color: focus.markColor ?? colors.accent, bold: true, children: `${mark} ` }),
1751
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${label} ` }),
1752
+ focus.marker ? /* @__PURE__ */ jsx(Text, { children: `${focus.marker} ` }) : null,
1753
+ /* @__PURE__ */ jsx(Text, { color: focusRefColor(focus), bold: true, children: `${focus.ref} ` }),
1754
+ /* @__PURE__ */ jsx(Text, { bold: true, children: title }),
1755
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${reason}` }),
1756
+ hint ? /* @__PURE__ */ jsxs(Fragment, { children: [
1757
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " }),
1758
+ /* @__PURE__ */ jsx(Text, { color: colors.accent, bold: true, children: hint }),
1759
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " jump" })
1760
+ ] }) : null
1761
+ ] });
1762
+ };
1689
1763
  var quietly = $({ quiet: true });
1690
1764
  var isMenuGap = (row) => "gap" in row;
1691
1765
  var rowLabelOf = (item) => item.kind === "task" ? item.ticket ?? item.key : item.kind === "pr" || item.kind === "issue" ? `#${item.number}` : "";
@@ -2911,7 +2985,8 @@ var ItemRow = ({
2911
2985
  uniformLabels = [],
2912
2986
  columns,
2913
2987
  shed,
2914
- numberCols
2988
+ numberCols,
2989
+ focus
2915
2990
  }) => {
2916
2991
  const backdropped = useBackdropped();
2917
2992
  const selBg = selectionBackground(active, backdropped);
@@ -2955,12 +3030,16 @@ var ItemRow = ({
2955
3030
  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
3031
  const titleMax = Math.max(
2957
3032
  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
3033
+ 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
3034
+ // url-bearing row while a focus is on screen, none at all without one.
3035
+ (focus !== void 0 ? 2 : 0) - 12
2959
3036
  );
3037
+ const focusCell = focusCellFor(focus, item.url);
2960
3038
  return /* @__PURE__ */ jsxs(Box, { marginTop: gap ? 1 : 0, width: cols, backgroundColor: selBg, children: [
2961
3039
  /* @__PURE__ */ jsx(Text3, { color: colors.info, children: active ? "\u276F " : " " }),
2962
3040
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: prefix }),
2963
3041
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: parent ? "\u252C " : " " }),
3042
+ focusCell !== void 0 ? focusCell ? /* @__PURE__ */ jsx(Text3, { color: focusCell.color, bold: true, children: `${focusCell.mark} ` }) : /* @__PURE__ */ jsx(Text3, { children: " " }) : null,
2964
3043
  pending ? /* @__PURE__ */ jsx(Text3, { dimColor: true, children: transitIcon + " " }) : /* @__PURE__ */ jsx(Text3, { bold: true, color: transient ? TRANSIT_COLOUR[transient] : void 0, children: transitIcon + " " }),
2965
3044
  markerCols > 0 ? /* @__PURE__ */ jsx(Text3, { color: item.markerColor, children: (item.marker ?? "").padEnd(markerCols) + " " }) : null,
2966
3045
  /* @__PURE__ */ jsx(Text3, { ...taskKeyStyle(), children: item.key + " " }),
@@ -3019,7 +3098,11 @@ var ItemRow = ({
3019
3098
  numberCols,
3020
3099
  icon,
3021
3100
  motion,
3022
- announcements
3101
+ announcements,
3102
+ focusCell: focusCellFor(
3103
+ focus,
3104
+ "url" in item ? item.url : void 0
3105
+ )
3023
3106
  }
3024
3107
  );
3025
3108
  };
@@ -3223,6 +3306,7 @@ var LEGEND_FOOTNOTE = "Each item lands in the FIRST tab that claims it, so count
3223
3306
  var HelpModal = ({
3224
3307
  extensions,
3225
3308
  hasJira,
3309
+ hasFocus,
3226
3310
  tabHelp,
3227
3311
  maxRows,
3228
3312
  scroll = 0,
@@ -3257,6 +3341,7 @@ var HelpModal = ({
3257
3341
  ["f", "filter by repo"],
3258
3342
  ["r", "refresh"],
3259
3343
  ["w", "restore a change that failed"],
3344
+ ...hasFocus ? [[FOCUS_JUMP_KEY, "jump to focus"]] : [],
3260
3345
  ...extensionLegend(extensions),
3261
3346
  ["?", "this help"],
3262
3347
  ["q", "quit"]
@@ -3452,6 +3537,9 @@ var BrowseScreen = ({
3452
3537
  ciJob,
3453
3538
  stripState,
3454
3539
  stripLabel,
3540
+ focus,
3541
+ focusLabel,
3542
+ focusEmpty,
3455
3543
  tabHelp,
3456
3544
  origin,
3457
3545
  budget,
@@ -3503,12 +3591,13 @@ var BrowseScreen = ({
3503
3591
  const filterActive = search != null || repoFilter.size > 0;
3504
3592
  const reserveCiRow = ciStatusState != null;
3505
3593
  const reserveStripRow = stripState != null;
3594
+ const reserveFocusRow = focusLabel !== void 0 || focusEmpty !== void 0 || focus !== void 0;
3506
3595
  const ciStatus = ciStatusState?.kind === "ready" ? ciStatusState.status : null;
3507
3596
  const listHeight = Math.max(
3508
3597
  5,
3509
3598
  // -10 rather than -8: the extra 2 are the frame's top and bottom border
3510
3599
  // 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)
3600
+ rows - 10 - (filterActive ? 2 : 0) - (reserveCiRow ? 2 : 0) - (reserveStripRow ? 2 : 0) - (reserveFocusRow ? 2 : 0)
3512
3601
  );
3513
3602
  useEffect(() => {
3514
3603
  setLocalSections(applyWork(sections));
@@ -3654,7 +3743,10 @@ var BrowseScreen = ({
3654
3743
  cols: rowCols,
3655
3744
  uniformLabels,
3656
3745
  columns: trailingColumns,
3657
- numberCols
3746
+ numberCols,
3747
+ // Presence only: every row holds the same two gutter columns while a
3748
+ // focus is on screen, so the shed ladder prices the cost once for all.
3749
+ focusGutter: focus !== void 0
3658
3750
  }
3659
3751
  );
3660
3752
  const [sparkFrame, setSparkFrame] = useState(0);
@@ -3894,6 +3986,56 @@ var BrowseScreen = ({
3894
3986
  onRefresh?.();
3895
3987
  return;
3896
3988
  }
3989
+ if (input === FOCUS_JUMP_KEY && focus) {
3990
+ const tabId = focus.target.tab;
3991
+ const at = localSections.findIndex((s) => s.id === tabId);
3992
+ if (at === -1) return;
3993
+ const drawn = (s) => {
3994
+ const one = search != null ? filterBySearch([s], search, login) : [s];
3995
+ const two = repoFilter.size > 0 ? filterByRepos(one, repoFilter, login) : one;
3996
+ return filterActive ? two[0]?.items ?? [] : s.items;
3997
+ };
3998
+ const urlOf = (i) => "url" in i ? i.url : void 0;
3999
+ let items = localSections[at].items;
4000
+ if (!items.some((i) => urlOf(i) === focus.target.url)) {
4001
+ const tail = items.find(
4002
+ (i) => i.kind === "show-more" && i.hidden.some((h) => h.url === focus.target.url)
4003
+ );
4004
+ if (!tail) {
4005
+ setTabIdx(at);
4006
+ return;
4007
+ }
4008
+ items = [
4009
+ ...items.slice(0, items.indexOf(tail)),
4010
+ ...tail.hidden,
4011
+ {
4012
+ kind: "show-less",
4013
+ toHide: tail.hidden,
4014
+ // The depth of the row it replaces, never a constant — a collapsed
4015
+ // row under a story sits deeper than 1, and pinning it breaks the
4016
+ // `treeUrls` walk exactly as the comment there warns.
4017
+ depth: depthOf(tail)
4018
+ },
4019
+ ...items.slice(items.indexOf(tail) + 1)
4020
+ ];
4021
+ const expanded = items;
4022
+ setLocalSections(
4023
+ (prev) => prev.map((s) => s.id === tabId ? { ...s, items: expanded } : s)
4024
+ );
4025
+ prevItems.current = { ...prevItems.current, [tabId]: expanded };
4026
+ }
4027
+ setTabIdx(at);
4028
+ const shown = drawn({ ...localSections[at], items });
4029
+ const dit = shown.findIndex((i) => urlOf(i) === focus.target.url);
4030
+ if (dit === -1) return;
4031
+ setCursors((p) => ({ ...p, [tabId]: dit }));
4032
+ setViewStarts((p) => {
4033
+ let vs = Math.min(p[tabId] ?? 0, withHeaders(shown, dit));
4034
+ while (dit >= vs + windowCount(shown, vs, listHeight)) vs++;
4035
+ return { ...p, [tabId]: vs };
4036
+ });
4037
+ return;
4038
+ }
3897
4039
  const ext = extensionFor(input, extensions);
3898
4040
  if (ext) {
3899
4041
  onOpenExt?.(ext.id, extensionTargetFor(activeItem));
@@ -4309,6 +4451,7 @@ var BrowseScreen = ({
4309
4451
  {
4310
4452
  extensions,
4311
4453
  hasJira: !!jiraBase,
4454
+ hasFocus: focus != null,
4312
4455
  tabHelp,
4313
4456
  maxRows: listHeight,
4314
4457
  scroll: helpScroll,
@@ -4365,6 +4508,16 @@ var BrowseScreen = ({
4365
4508
  width: COLS
4366
4509
  }
4367
4510
  ) : null,
4511
+ reserveFocusRow ? /* @__PURE__ */ jsx(
4512
+ FocusSlotLine,
4513
+ {
4514
+ focus,
4515
+ label: focusLabel,
4516
+ empty: focusEmpty,
4517
+ width: COLS,
4518
+ jumpKey: FOCUS_JUMP_KEY
4519
+ }
4520
+ ) : null,
4368
4521
  skippedForBudget ? /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
4369
4522
  /* @__PURE__ */ jsx(Text3, { bold: true, color: colors.accent, children: " \u26A1 auto-refresh paused to save API budget" }),
4370
4523
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: " r refreshes anyway" })
@@ -4373,9 +4526,11 @@ var BrowseScreen = ({
4373
4526
  Tabs,
4374
4527
  {
4375
4528
  active: section.id,
4529
+ width: COLS,
4376
4530
  items: localSections.map((s) => ({
4377
4531
  value: s.id,
4378
4532
  label: s.label,
4533
+ icon: s.icon,
4379
4534
  count: countOf(s.id),
4380
4535
  /*
4381
4536
  * The denominator, and only where it can still be true.
@@ -4461,6 +4616,7 @@ var BrowseScreen = ({
4461
4616
  columns: trailingColumns,
4462
4617
  shed: trailingShed,
4463
4618
  numberCols,
4619
+ focus,
4464
4620
  gap: i > 0 && gapsAbove(section.items, viewStart + i)
4465
4621
  },
4466
4622
  `${viewStart + i}:${rowKey(item)}`
@@ -4556,6 +4712,8 @@ var App = ({
4556
4712
  stripFetcher,
4557
4713
  stripLabel,
4558
4714
  stripPollMs = 3e5,
4715
+ focusLabel,
4716
+ focusEmpty,
4559
4717
  watchPath,
4560
4718
  watchDebounceMs = 400,
4561
4719
  // Zero by default, deliberately. A package that adds randomness to its own
@@ -4610,6 +4768,7 @@ var App = ({
4610
4768
  return sameCiStatusState(prev, next) ? prev : next;
4611
4769
  });
4612
4770
  const hasStrip = !!stripFetcher;
4771
+ const hasFocusSlot = focusLabel !== void 0 || focusEmpty !== void 0;
4613
4772
  const [stripState, setStripState] = useState({
4614
4773
  kind: "loading"
4615
4774
  });
@@ -4738,6 +4897,7 @@ var App = ({
4738
4897
  )
4739
4898
  };
4740
4899
  if (fresh.budget) setBudget(fresh.budget);
4900
+ if (fresh.focus !== void 0) setFocus(fresh.focus);
4741
4901
  if (fresh.sidebar !== void 0) setSidebar(fresh.sidebar);
4742
4902
  if (cacheKey) writeCache(cacheKey, fresh);
4743
4903
  setRefreshing(false);
@@ -4890,6 +5050,7 @@ var App = ({
4890
5050
  displayedSections.current = cached.sections;
4891
5051
  setFetchedAt(cached.at);
4892
5052
  if (cached.sidebar) setSidebar(cached.sidebar);
5053
+ if (cached.focus !== void 0) setFocus(cached.focus);
4893
5054
  setState({
4894
5055
  phase: "browse",
4895
5056
  sections: cached.sections,
@@ -4952,6 +5113,7 @@ var App = ({
4952
5113
  if (shared && shared.at > firedAt) {
4953
5114
  receive({ sections: shared.sections, login: shared.login });
4954
5115
  if (shared.sidebar) setSidebar(shared.sidebar);
5116
+ if (shared.focus !== void 0) setFocus(shared.focus);
4955
5117
  setFetchedAt(shared.at);
4956
5118
  return;
4957
5119
  }
@@ -4972,6 +5134,7 @@ var App = ({
4972
5134
  );
4973
5135
  const [appliedSummary, setAppliedSummary] = useState("");
4974
5136
  const [sidebar, setSidebar] = useState(void 0);
5137
+ const [focus, setFocus] = useState(void 0);
4975
5138
  const [skippedForBudget, setSkippedForBudget] = useState(false);
4976
5139
  const [mergedUrls, setMergedUrls] = useState([]);
4977
5140
  const [leavingUrls, setLeavingUrls] = useState([]);
@@ -5024,11 +5187,23 @@ var App = ({
5024
5187
  width: COLS
5025
5188
  }
5026
5189
  ) : null,
5190
+ hasFocusSlot ? /* @__PURE__ */ jsx(
5191
+ FocusSlotLine,
5192
+ {
5193
+ focus: void 0,
5194
+ label: focusLabel,
5195
+ empty: focusEmpty,
5196
+ width: COLS
5197
+ }
5198
+ ) : null,
5027
5199
  /* @__PURE__ */ jsx(
5028
5200
  Box,
5029
5201
  {
5030
5202
  flexDirection: "column",
5031
- minHeight: Math.max(5, rows - 6 - (hasCiStatus ? 2 : 0)),
5203
+ minHeight: Math.max(
5204
+ 5,
5205
+ rows - 6 - (hasCiStatus ? 2 : 0) - (hasStrip ? 2 : 0) - (hasFocusSlot ? 2 : 0)
5206
+ ),
5032
5207
  children: state.phase === "loading" ? /* @__PURE__ */ jsx(LoadingScreen, { label: `Fetching ${title}\u2026` }) : /* @__PURE__ */ jsx(
5033
5208
  NoRowsScreen,
5034
5209
  {
@@ -5136,6 +5311,9 @@ var App = ({
5136
5311
  ciJob,
5137
5312
  stripState: hasStrip ? stripState : void 0,
5138
5313
  stripLabel,
5314
+ focus,
5315
+ focusLabel,
5316
+ focusEmpty,
5139
5317
  tabHelp,
5140
5318
  onOpenPr: (item) => {
5141
5319
  setChromeSpec({});
@@ -5184,4 +5362,4 @@ var matchesFilter = (repo, filter) => {
5184
5362
  };
5185
5363
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
5186
5364
 
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 };
5365
+ export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, DEFAULT_FOCUS_EMPTY, DEFAULT_FOCUS_LABEL, FOCUS_JUMP_KEY, 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.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",
@@ -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",