@kud/gh-ink 0.73.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,8 +633,30 @@ 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
 
639
+ /**
640
+ * What an action did to a row, said before the server has agreed to it.
641
+ *
642
+ * Three shapes cover every action the inbox takes on a row: it leaves the list
643
+ * (close, done), it changes tab (a Jira move), or it changes in place (labels,
644
+ * assignee, read). A patch names the END STATE rather than the step that gets
645
+ * there, which is what lets it be reapplied on top of every fetch: "SHOP-1234
646
+ * belongs in review, wearing In Review" is as true on the third fetch as on the
647
+ * first, where "move it one tab right" would walk it off the board.
648
+ */
649
+ type Patch = {
650
+ kind: "remove";
651
+ } | {
652
+ kind: "move";
653
+ toSection: string;
654
+ fields?: Record<string, unknown>;
655
+ } | {
656
+ kind: "mutate";
657
+ fields: Record<string, unknown>;
658
+ };
659
+
529
660
  type DetailContext = {
530
661
  item: GHItem;
531
662
  kind: "pr" | "issue";
@@ -629,6 +760,25 @@ type TabForStatus = (to: {
629
760
  name: string;
630
761
  category?: string;
631
762
  }) => string | null;
763
+ /**
764
+ * What a move hands back when the screen took it optimistically: the request's
765
+ * two possible answers, reported so the pending patch can stop showing `◌` or
766
+ * be marked failed. Absent when the move was not taken optimistically, and the
767
+ * caller falls back to its own flashes.
768
+ */
769
+ type OptimisticHandle = {
770
+ settled: () => void;
771
+ failed: (err: unknown) => void;
772
+ };
773
+ /**
774
+ * A row action said ahead of the server: the end state it leaves the row in,
775
+ * and the verb the failure line names it by — `close` reads back as
776
+ * "Couldn't close #412".
777
+ */
778
+ type OptimisticChange = {
779
+ patch: Patch;
780
+ action: string;
781
+ };
632
782
  /**
633
783
  * Resolve the live transitions for a ticket, called lazily when its move
634
784
  * submenu opens — never at board fetch, where one call per visible ticket
@@ -766,6 +916,15 @@ declare const buildActions: (item: AnyItem, login: string, showFlash: (msg: stri
766
916
  * the settlement signal.
767
917
  */
768
918
  onSettled?: (item: GHItem, result: SettledResult) => void;
919
+ /**
920
+ * Take the row's action ahead of the server — see `OptimisticChange`. A
921
+ * handle back means the row is already where the action puts it, so the
922
+ * request's answer goes to the handle and a failure goes ONLY there: the
923
+ * failed patch has its own sticky line with `w` beside it, and a flash
924
+ * naming the same failure would be a second voice saying it less well.
925
+ * Absent, every action keeps the pessimistic shape it had.
926
+ */
927
+ optimistic?: (item: AnyItem, change: OptimisticChange) => OptimisticHandle | undefined;
769
928
  /**
770
929
  * Open the ticket's move submenu through the live-transitions hook. Passed
771
930
  * by the browse screen (which owns the menu the rows open into) only when
@@ -831,7 +990,7 @@ declare const ActionMenu: ({ item, actions, cursor, cols, }: {
831
990
  cols?: number;
832
991
  }) => React.JSX.Element;
833
992
  declare const signatureOf: (sections: Section[]) => string;
834
- 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, }: {
835
994
  fetcher: () => Promise<{
836
995
  sections: Section[];
837
996
  login: string;
@@ -852,6 +1011,15 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
852
1011
  * data that goes stale the same way the rows do.
853
1012
  */
854
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;
855
1023
  }>;
856
1024
  cacheKey?: string;
857
1025
  /**
@@ -894,6 +1062,16 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
894
1062
  /** What to call the row before its first answer arrives with a title. */
895
1063
  stripLabel?: string;
896
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;
897
1075
  /**
898
1076
  * A file something else touches when it has changed GitHub on your behalf —
899
1077
  * a Claude session closing an issue, a script merging a PR. Touching it makes
@@ -946,4 +1124,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
946
1124
  */
947
1125
  declare const parsePatterns: (value: string) => string[];
948
1126
 
949
- 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 }),
@@ -1054,9 +1058,9 @@ var PrRow = ({
1054
1058
  justifyContent: "flex-end",
1055
1059
  flexShrink: 0,
1056
1060
  children: sizeParts ? /* @__PURE__ */ jsxs(Text3, { children: [
1057
- /* @__PURE__ */ jsx(Text3, { color: colors.success, children: sizeParts.added }),
1061
+ /* @__PURE__ */ jsx(Text3, { color: colors.added, children: sizeParts.added }),
1058
1062
  " ",
1059
- /* @__PURE__ */ jsx(Text3, { color: colors.error, children: sizeParts.removed })
1063
+ /* @__PURE__ */ jsx(Text3, { color: colors.removed, children: sizeParts.removed })
1060
1064
  ] }) : null
1061
1065
  }
1062
1066
  ) : null,
@@ -1335,20 +1339,12 @@ var lookupRows = (claim, lookup, query, start, openUrl) => {
1335
1339
  if (lookup?.phase === "failed")
1336
1340
  return {
1337
1341
  exclusive: true,
1338
- rows: [
1339
- {
1340
- id: "launcher:retry",
1341
- title: `Couldn't look up ${claim.label}: ${lookup.message}`,
1342
- label: /* @__PURE__ */ jsxs(Text3, { color: colors.error, children: [
1343
- "\u2717 couldn't look up ",
1344
- claim.label,
1345
- " \xB7 ",
1346
- lookup.message
1347
- ] }),
1348
- run: () => start(query, claim),
1349
- keepOpen: true
1350
- }
1351
- ]
1342
+ rows: [],
1343
+ message: {
1344
+ text: `couldn't look up ${claim.label} \xB7 ${lookup.message} \xB7 \u21B5 retry`,
1345
+ tone: "error",
1346
+ onSubmit: () => start(query, claim)
1347
+ }
1352
1348
  };
1353
1349
  const rows = [
1354
1350
  {
@@ -1380,7 +1376,7 @@ var lookupRows = (claim, lookup, query, start, openUrl) => {
1380
1376
 
1381
1377
  // src/inbox/cache.ts
1382
1378
  var cacheTtlMs = () => inboxConfig().cacheTtlMs;
1383
- var CACHE_VERSION = 5;
1379
+ var CACHE_VERSION = 6;
1384
1380
  var cacheDir = () => join(
1385
1381
  process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
1386
1382
  inboxConfig().cacheNamespace
@@ -1391,11 +1387,14 @@ var readCache = (key) => {
1391
1387
  const raw = JSON.parse(readFileSync(cacheFile(key), "utf8"));
1392
1388
  if (raw?.version !== CACHE_VERSION) return null;
1393
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;
1394
1392
  return {
1395
1393
  sections: raw.sections,
1396
1394
  login: raw.login ?? "",
1397
1395
  at: raw.at ?? 0,
1398
1396
  budget: raw.budget,
1397
+ ...raw.focus !== void 0 ? { focus: raw.focus } : {},
1399
1398
  ...railsOf(raw.sidebar).every((s) => Array.isArray(s?.rows)) && raw.sidebar !== void 0 ? { sidebar: raw.sidebar } : {}
1400
1399
  };
1401
1400
  } catch {
@@ -1694,8 +1693,59 @@ var failureNotice = (failed) => {
1694
1693
  const more = failed.length > 1 ? ` +${failed.length - 1} more` : "";
1695
1694
  return `\u2717 Couldn't ${newest.action} ${newest.label}: ${newest.failure?.reason ?? "no reason reported"}${more} w restore`;
1696
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
+ };
1697
1746
  var quietly = $({ quiet: true });
1698
1747
  var isMenuGap = (row) => "gap" in row;
1748
+ var rowLabelOf = (item) => item.kind === "task" ? item.ticket ?? item.key : item.kind === "pr" || item.kind === "issue" ? `#${item.number}` : "";
1699
1749
  var jiraMovesCache = /* @__PURE__ */ new Map();
1700
1750
  var cachedJiraMoves = (ticket, status) => {
1701
1751
  const entry = jiraMovesCache.get(ticket);
@@ -2297,6 +2347,19 @@ var workCount = (s) => {
2297
2347
  }
2298
2348
  return n;
2299
2349
  };
2350
+ var countedSections = (sections, departing) => {
2351
+ if (!departing?.size) return sections;
2352
+ const sent = new Set(sections.map((s) => s.id));
2353
+ return sections.map((s) => {
2354
+ const items = s.items.filter((item) => {
2355
+ const key = keyOf(item);
2356
+ if (!key || !departing.has(key)) return true;
2357
+ const to = departing.get(key);
2358
+ return to != null && (to === s.id || !sent.has(to));
2359
+ });
2360
+ return items.length === s.items.length ? s : { ...s, items };
2361
+ });
2362
+ };
2300
2363
  var drillCmd = (item) => {
2301
2364
  if (item.kind === "task")
2302
2365
  return item.ticket ? `jira issue view ${item.ticket}` : null;
@@ -2360,6 +2423,7 @@ var openInJira = (jiraBase, key, showFlash) => {
2360
2423
  });
2361
2424
  showFlash(`\u2197 Opened ${key} in Jira`);
2362
2425
  };
2426
+ var REMOVE_ME_AS_REVIEWER = "Remove me as reviewer";
2363
2427
  var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions, onRefresh, onRemove, onOpenView, ext) => {
2364
2428
  if (item.kind === "repo-header" || item.kind === "subgroup-header" || item.kind === "show-more" || item.kind === "show-less")
2365
2429
  return [];
@@ -2464,6 +2528,32 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2464
2528
  }
2465
2529
  const actions = drillAction ? [drillAction, open, copyUrl] : [open, copyUrl];
2466
2530
  const closing = [];
2531
+ const removeAhead = (opts) => {
2532
+ const row = item;
2533
+ const held = ext?.optimistic?.(row, {
2534
+ patch: { kind: "remove" },
2535
+ action: opts.action
2536
+ });
2537
+ onRemove?.(row);
2538
+ showFlash(opts.taking);
2539
+ void opts.request().then(
2540
+ () => {
2541
+ held?.settled();
2542
+ if (opts.done) showFlash(opts.done);
2543
+ if (held || opts.refreshes) ext?.onActed?.();
2544
+ if (opts.settles) ext?.onSettled?.(row, { ok: true });
2545
+ },
2546
+ (e) => {
2547
+ if (held) held.failed(e);
2548
+ else {
2549
+ showFlash(opts.refused(e));
2550
+ onRefresh?.();
2551
+ }
2552
+ if (opts.settles)
2553
+ ext?.onSettled?.(row, { ok: false, reason: firstLine(e) });
2554
+ }
2555
+ );
2556
+ };
2467
2557
  actions.push({
2468
2558
  label: "Copy repo name",
2469
2559
  hint: "r",
@@ -2486,7 +2576,7 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2486
2576
  });
2487
2577
  if (item.kind === "pr" && item.standing === "queued" && login) {
2488
2578
  actions.push({
2489
- label: "Remove me as reviewer",
2579
+ label: REMOVE_ME_AS_REVIEWER,
2490
2580
  hint: "x",
2491
2581
  // Quiet, beside Unsubscribe: both are "less of this PR for me" — no more
2492
2582
  // review requests, no more notifications. Guarded by the caller's own
@@ -2494,25 +2584,17 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2494
2584
  // review request there is no such row, and none is invented here.
2495
2585
  group: "quiet",
2496
2586
  icon: "\uF468",
2497
- run: () => {
2498
- onRemove?.(item);
2499
- showFlash(`\u22EF Removing you from #${item.number}\u2026`);
2500
- void quietly`gh pr edit ${item.number} --repo ${item.repo} --remove-reviewer ${login}`.then(
2501
- () => {
2502
- showFlash(`\u2713 Removed you as reviewer on #${item.number}`);
2503
- ext?.onActed?.();
2504
- ext?.onSettled?.(item, { ok: true });
2505
- },
2506
- (e) => {
2507
- showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`);
2508
- onRefresh?.();
2509
- ext?.onSettled?.(item, {
2510
- ok: false,
2511
- reason: firstLine(e)
2512
- });
2513
- }
2514
- );
2515
- }
2587
+ // Optimistic, the same shape Close uses. A row that lingers for a round
2588
+ // trip reads as a broken keypress.
2589
+ run: () => removeAhead({
2590
+ action: "remove you from",
2591
+ request: () => quietly`gh pr edit ${item.number} --repo ${item.repo} --remove-reviewer ${login}`,
2592
+ taking: `\u22EF Removing you from #${item.number}\u2026`,
2593
+ done: `\u2713 Removed you as reviewer on #${item.number}`,
2594
+ refused: (e) => `\u2717 #${item.number}: ${explainGhAction(e)}`,
2595
+ settles: true,
2596
+ refreshes: true
2597
+ })
2516
2598
  });
2517
2599
  }
2518
2600
  if (item.kind === "pr" && item.branch) {
@@ -2584,23 +2666,13 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2584
2666
  label: `Close #${item.number}`,
2585
2667
  hint: "",
2586
2668
  group: "confirm",
2587
- run: () => {
2588
- onRemove?.(item);
2589
- showFlash(`\u2713 Closed #${item.number}`);
2590
- void quietly`gh issue close ${item.number} --repo ${item.repo}`.then(
2591
- () => {
2592
- ext?.onSettled?.(item, { ok: true });
2593
- },
2594
- (e) => {
2595
- showFlash(`\u2717 Close failed \u2014 restoring #${item.number}`);
2596
- onRefresh?.();
2597
- ext?.onSettled?.(item, {
2598
- ok: false,
2599
- reason: firstLine(e)
2600
- });
2601
- }
2602
- );
2603
- }
2669
+ run: () => removeAhead({
2670
+ action: "close",
2671
+ request: () => quietly`gh issue close ${item.number} --repo ${item.repo}`,
2672
+ taking: `\u2713 Closed #${item.number}`,
2673
+ refused: () => `\u2717 Close failed \u2014 restoring #${item.number}`,
2674
+ settles: true
2675
+ })
2604
2676
  },
2605
2677
  { label: "Cancel", hint: "", group: "confirm", run: () => {
2606
2678
  } }
@@ -2651,23 +2723,13 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2651
2723
  label: `Close #${item.number}`,
2652
2724
  hint: "",
2653
2725
  group: "confirm",
2654
- run: () => {
2655
- onRemove?.(item);
2656
- showFlash(`\u2713 Closed #${item.number}`);
2657
- void quietly`gh pr close ${item.number} --repo ${item.repo}`.then(
2658
- () => {
2659
- ext?.onSettled?.(item, { ok: true });
2660
- },
2661
- (e) => {
2662
- showFlash(`\u2717 Close failed \u2014 restoring #${item.number}`);
2663
- onRefresh?.();
2664
- ext?.onSettled?.(item, {
2665
- ok: false,
2666
- reason: firstLine(e)
2667
- });
2668
- }
2669
- );
2670
- }
2726
+ run: () => removeAhead({
2727
+ action: "close",
2728
+ request: () => quietly`gh pr close ${item.number} --repo ${item.repo}`,
2729
+ taking: `\u2713 Closed #${item.number}`,
2730
+ refused: () => `\u2717 Close failed \u2014 restoring #${item.number}`,
2731
+ settles: true
2732
+ })
2671
2733
  },
2672
2734
  { label: "Cancel", hint: "", group: "confirm", run: () => {
2673
2735
  } }
@@ -2688,16 +2750,23 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2688
2750
  label: `Close #${item.number} and delete ${item.branch}`,
2689
2751
  hint: "",
2690
2752
  group: "confirm",
2691
- run: () => {
2692
- onRemove?.(item);
2693
- showFlash(`\u2713 Closed #${item.number} and deleted ${item.branch}`);
2694
- void quietly`gh pr close ${item.number} --repo ${item.repo}`.then(
2695
- () => quietly`gh api -X DELETE ${`repos/${item.repo}/git/refs/heads/${item.branch}`}`
2696
- ).catch(() => {
2697
- showFlash(`\u2717 Close + delete failed \u2014 restoring`);
2698
- onRefresh?.();
2699
- });
2700
- }
2753
+ // The patch covers the close and nothing else. A branch that will
2754
+ // not delete leaves a PR that IS closed, so reporting "Couldn't
2755
+ // close #412" and offering `w` would restore a row the server no
2756
+ // longer has; the branch gets its own flash instead.
2757
+ run: () => removeAhead({
2758
+ action: "close",
2759
+ request: async () => {
2760
+ await quietly`gh pr close ${item.number} --repo ${item.repo}`;
2761
+ void quietly`gh api -X DELETE ${`repos/${item.repo}/git/refs/heads/${item.branch}`}`.catch(
2762
+ () => showFlash(
2763
+ `\u2717 Closed #${item.number}, but couldn't delete ${item.branch}`
2764
+ )
2765
+ );
2766
+ },
2767
+ taking: `\u2713 Closed #${item.number} and deleted ${item.branch}`,
2768
+ refused: () => `\u2717 Close + delete failed \u2014 restoring`
2769
+ })
2701
2770
  },
2702
2771
  { label: "Cancel", hint: "", group: "confirm", run: () => {
2703
2772
  } }
@@ -2899,7 +2968,8 @@ var ItemRow = ({
2899
2968
  uniformLabels = [],
2900
2969
  columns,
2901
2970
  shed,
2902
- numberCols
2971
+ numberCols,
2972
+ focus
2903
2973
  }) => {
2904
2974
  const backdropped = useBackdropped();
2905
2975
  const selBg = selectionBackground(active, backdropped);
@@ -2943,12 +3013,16 @@ var ItemRow = ({
2943
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";
2944
3014
  const titleMax = Math.max(
2945
3015
  1,
2946
- 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
2947
3019
  );
3020
+ const focusCell = focusCellFor(focus, item.url);
2948
3021
  return /* @__PURE__ */ jsxs(Box, { marginTop: gap ? 1 : 0, width: cols, backgroundColor: selBg, children: [
2949
3022
  /* @__PURE__ */ jsx(Text3, { color: colors.info, children: active ? "\u276F " : " " }),
2950
3023
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: prefix }),
2951
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,
2952
3026
  pending ? /* @__PURE__ */ jsx(Text3, { dimColor: true, children: transitIcon + " " }) : /* @__PURE__ */ jsx(Text3, { bold: true, color: transient ? TRANSIT_COLOUR[transient] : void 0, children: transitIcon + " " }),
2953
3027
  markerCols > 0 ? /* @__PURE__ */ jsx(Text3, { color: item.markerColor, children: (item.marker ?? "").padEnd(markerCols) + " " }) : null,
2954
3028
  /* @__PURE__ */ jsx(Text3, { ...taskKeyStyle(), children: item.key + " " }),
@@ -2983,7 +3057,7 @@ var ItemRow = ({
2983
3057
  const icon = merged ? {
2984
3058
  glyph: MERGED_FRAMES[sparkFrame % MERGED_FRAMES.length],
2985
3059
  color: MERGED_COLOUR
2986
- } : transient ? {
3060
+ } : pending ? { glyph: PENDING_GLYPH, color: colors.muted } : transient ? {
2987
3061
  glyph: isDeparture(transient) ? TRANSIT_OUT_FRAMES[rampAt(TRANSIT_OUT_FRAMES.length)] : isArrival(transient) ? TRANSIT_IN_FRAMES[rampAt(TRANSIT_IN_FRAMES.length)] : displayFor(item.health).glyph,
2988
3062
  color: TRANSIT_COLOUR[transient]
2989
3063
  } : void 0;
@@ -3007,7 +3081,11 @@ var ItemRow = ({
3007
3081
  numberCols,
3008
3082
  icon,
3009
3083
  motion,
3010
- announcements
3084
+ announcements,
3085
+ focusCell: focusCellFor(
3086
+ focus,
3087
+ "url" in item ? item.url : void 0
3088
+ )
3011
3089
  }
3012
3090
  );
3013
3091
  };
@@ -3043,17 +3121,18 @@ var useActionMenu = () => {
3043
3121
  };
3044
3122
  const handleKey = (key) => {
3045
3123
  if (!actions) return false;
3124
+ const live = (n) => n >= 0 && n < actions.length && !isMenuGap(actions[n]);
3046
3125
  if (key.upArrow)
3047
3126
  setCursor((c) => {
3048
3127
  let n = c - 1;
3049
3128
  while (n > 0 && isMenuGap(actions[n])) n -= 1;
3050
- return isMenuGap(actions[n]) ? c : n;
3129
+ return live(n) ? n : c;
3051
3130
  });
3052
3131
  if (key.downArrow)
3053
3132
  setCursor((c) => {
3054
3133
  let n = c + 1;
3055
3134
  while (n < actions.length - 1 && isMenuGap(actions[n])) n += 1;
3056
- return isMenuGap(actions[n]) ? c : n;
3135
+ return live(n) ? n : c;
3057
3136
  });
3058
3137
  if (key.return) {
3059
3138
  const row = actions[cursor];
@@ -3210,6 +3289,7 @@ var LEGEND_FOOTNOTE = "Each item lands in the FIRST tab that claims it, so count
3210
3289
  var HelpModal = ({
3211
3290
  extensions,
3212
3291
  hasJira,
3292
+ hasFocus,
3213
3293
  tabHelp,
3214
3294
  maxRows,
3215
3295
  scroll = 0,
@@ -3244,6 +3324,7 @@ var HelpModal = ({
3244
3324
  ["f", "filter by repo"],
3245
3325
  ["r", "refresh"],
3246
3326
  ["w", "restore a change that failed"],
3327
+ ...hasFocus ? [["g", "jump to focus"]] : [],
3247
3328
  ...extensionLegend(extensions),
3248
3329
  ["?", "this help"],
3249
3330
  ["q", "quit"]
@@ -3439,6 +3520,9 @@ var BrowseScreen = ({
3439
3520
  ciJob,
3440
3521
  stripState,
3441
3522
  stripLabel,
3523
+ focus,
3524
+ focusLabel,
3525
+ focusEmpty,
3442
3526
  tabHelp,
3443
3527
  origin,
3444
3528
  budget,
@@ -3453,6 +3537,8 @@ var BrowseScreen = ({
3453
3537
  sidebar,
3454
3538
  liveLabel,
3455
3539
  optimisticMove,
3540
+ optimistic,
3541
+ departing,
3456
3542
  pendingKeys,
3457
3543
  failure,
3458
3544
  onRestore
@@ -3488,12 +3574,13 @@ var BrowseScreen = ({
3488
3574
  const filterActive = search != null || repoFilter.size > 0;
3489
3575
  const reserveCiRow = ciStatusState != null;
3490
3576
  const reserveStripRow = stripState != null;
3577
+ const reserveFocusRow = focusLabel !== void 0 || focusEmpty !== void 0 || focus !== void 0;
3491
3578
  const ciStatus = ciStatusState?.kind === "ready" ? ciStatusState.status : null;
3492
3579
  const listHeight = Math.max(
3493
3580
  5,
3494
3581
  // -10 rather than -8: the extra 2 are the frame's top and bottom border
3495
3582
  // rows, so the tree never grows taller than the terminal inside the frame.
3496
- 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)
3497
3584
  );
3498
3585
  useEffect(() => {
3499
3586
  setLocalSections(applyWork(sections));
@@ -3578,6 +3665,14 @@ var BrowseScreen = ({
3578
3665
  const timer = setTimeout(() => setDismissedSeq(seq), FAILURE_NOTICE_MS);
3579
3666
  return () => clearTimeout(timer);
3580
3667
  }, [failure?.seq]);
3668
+ const counted = useMemo(
3669
+ () => countedSections(localSections, departing),
3670
+ [localSections, departing]
3671
+ );
3672
+ const countOf = (id) => {
3673
+ const s = counted.find((c) => c.id === id);
3674
+ return s ? workCount(s) : 0;
3675
+ };
3581
3676
  const markedTabs = useMemo(() => {
3582
3677
  const out = /* @__PURE__ */ new Set();
3583
3678
  if (!transients?.size) return out;
@@ -3631,7 +3726,10 @@ var BrowseScreen = ({
3631
3726
  cols: rowCols,
3632
3727
  uniformLabels,
3633
3728
  columns: trailingColumns,
3634
- 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
3635
3733
  }
3636
3734
  );
3637
3735
  const [sparkFrame, setSparkFrame] = useState(0);
@@ -3713,31 +3811,34 @@ var BrowseScreen = ({
3713
3811
  optimistic: optimisticMove ? (to) => optimisticMove(task, to) : void 0
3714
3812
  });
3715
3813
  };
3814
+ const actionsFor = (item, flash2) => buildActions(
3815
+ item,
3816
+ login,
3817
+ flash2,
3818
+ jiraBase,
3819
+ jiraKeyRe,
3820
+ jiraTransitions,
3821
+ onRefresh,
3822
+ dismissItem,
3823
+ openDrillView,
3824
+ {
3825
+ extensions,
3826
+ onOpenExt,
3827
+ onActed,
3828
+ onSettled,
3829
+ optimistic,
3830
+ onOpenMove: jiraTransitionsFor ? openMoveFor : void 0
3831
+ }
3832
+ );
3716
3833
  const openMenu = () => {
3717
3834
  if (!activeItem || activeItem.kind === "repo-header" || activeItem.kind === "subgroup-header")
3718
3835
  return;
3719
- const actions = buildActions(
3720
- activeItem,
3721
- login,
3722
- (msg) => {
3836
+ menu.open(
3837
+ actionsFor(activeItem, (msg) => {
3723
3838
  menu.close();
3724
3839
  showFlash(msg);
3725
- },
3726
- jiraBase,
3727
- jiraKeyRe,
3728
- jiraTransitions,
3729
- onRefresh,
3730
- dismissItem,
3731
- openDrillView,
3732
- {
3733
- extensions,
3734
- onOpenExt,
3735
- onActed,
3736
- onSettled,
3737
- onOpenMove: jiraTransitionsFor ? openMoveFor : void 0
3738
- }
3840
+ })
3739
3841
  );
3740
- menu.open(actions);
3741
3842
  };
3742
3843
  useInput((input, key) => {
3743
3844
  const idleFor = Date.now() - lastKeyAt.current;
@@ -3868,6 +3969,56 @@ var BrowseScreen = ({
3868
3969
  onRefresh?.();
3869
3970
  return;
3870
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
+ }
3871
4022
  const ext = extensionFor(input, extensions);
3872
4023
  if (ext) {
3873
4024
  onOpenExt?.(ext.id, extensionTargetFor(activeItem));
@@ -4029,20 +4180,7 @@ var BrowseScreen = ({
4029
4180
  return;
4030
4181
  }
4031
4182
  if (input === "x" && activeItem.kind === "pr" && activeItem.standing === "queued" && login) {
4032
- dismissItem(activeItem);
4033
- showFlash(`\u22EF Removing you from #${activeItem.number}\u2026`);
4034
- void quietly`gh pr edit ${activeItem.number} --repo ${activeItem.repo} --remove-reviewer ${login}`.then(
4035
- () => {
4036
- showFlash(`\u2713 Removed you as reviewer on #${activeItem.number}`);
4037
- onActed?.();
4038
- onSettled?.(activeItem, { ok: true });
4039
- },
4040
- (e) => {
4041
- showFlash(`\u2717 #${activeItem.number}: ${explainGhAction(e)}`);
4042
- onRefresh?.();
4043
- onSettled?.(activeItem, { ok: false, reason: firstLine(e) });
4044
- }
4045
- );
4183
+ actionsFor(activeItem, showFlash).find((a) => a.label === REMOVE_ME_AS_REVIEWER)?.run();
4046
4184
  return;
4047
4185
  }
4048
4186
  if (input === "b" && activeItem.kind === "pr" && activeItem.branch) {
@@ -4296,6 +4434,7 @@ var BrowseScreen = ({
4296
4434
  {
4297
4435
  extensions,
4298
4436
  hasJira: !!jiraBase,
4437
+ hasFocus: focus != null,
4299
4438
  tabHelp,
4300
4439
  maxRows: listHeight,
4301
4440
  scroll: helpScroll,
@@ -4331,7 +4470,7 @@ var BrowseScreen = ({
4331
4470
  InboxHeader,
4332
4471
  {
4333
4472
  brand,
4334
- sections: localSections,
4473
+ sections: counted,
4335
4474
  login,
4336
4475
  scopeLabel: overlayOpen ? chrome?.scope ?? origin?.label : origin?.label,
4337
4476
  budgetLabel: budgetNotice(budget)?.label,
@@ -4352,6 +4491,15 @@ var BrowseScreen = ({
4352
4491
  width: COLS
4353
4492
  }
4354
4493
  ) : null,
4494
+ reserveFocusRow ? /* @__PURE__ */ jsx(
4495
+ FocusSlotLine,
4496
+ {
4497
+ focus,
4498
+ label: focusLabel,
4499
+ empty: focusEmpty,
4500
+ width: COLS
4501
+ }
4502
+ ) : null,
4355
4503
  skippedForBudget ? /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
4356
4504
  /* @__PURE__ */ jsx(Text3, { bold: true, color: colors.accent, children: " \u26A1 auto-refresh paused to save API budget" }),
4357
4505
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: " r refreshes anyway" })
@@ -4360,10 +4508,12 @@ var BrowseScreen = ({
4360
4508
  Tabs,
4361
4509
  {
4362
4510
  active: section.id,
4511
+ width: COLS,
4363
4512
  items: localSections.map((s) => ({
4364
4513
  value: s.id,
4365
4514
  label: s.label,
4366
- count: workCount(s),
4515
+ icon: s.icon,
4516
+ count: countOf(s.id),
4367
4517
  /*
4368
4518
  * The denominator, and only where it can still be true.
4369
4519
  *
@@ -4448,6 +4598,7 @@ var BrowseScreen = ({
4448
4598
  columns: trailingColumns,
4449
4599
  shed: trailingShed,
4450
4600
  numberCols,
4601
+ focus,
4451
4602
  gap: i > 0 && gapsAbove(section.items, viewStart + i)
4452
4603
  },
4453
4604
  `${viewStart + i}:${rowKey(item)}`
@@ -4543,6 +4694,8 @@ var App = ({
4543
4694
  stripFetcher,
4544
4695
  stripLabel,
4545
4696
  stripPollMs = 3e5,
4697
+ focusLabel,
4698
+ focusEmpty,
4546
4699
  watchPath,
4547
4700
  watchDebounceMs = 400,
4548
4701
  // Zero by default, deliberately. A package that adds randomness to its own
@@ -4725,6 +4878,7 @@ var App = ({
4725
4878
  )
4726
4879
  };
4727
4880
  if (fresh.budget) setBudget(fresh.budget);
4881
+ if (fresh.focus !== void 0) setFocus(fresh.focus);
4728
4882
  if (fresh.sidebar !== void 0) setSidebar(fresh.sidebar);
4729
4883
  if (cacheKey) writeCache(cacheKey, fresh);
4730
4884
  setRefreshing(false);
@@ -4835,6 +4989,26 @@ var App = ({
4835
4989
  phase: "inflight"
4836
4990
  });
4837
4991
  } : void 0;
4992
+ const optimistic = (item, change) => {
4993
+ const key = keyOf(item);
4994
+ if (!key) return void 0;
4995
+ return hold(key, {
4996
+ patch: change.patch,
4997
+ action: change.action,
4998
+ label: rowLabelOf(item),
4999
+ since: Date.now(),
5000
+ phase: "inflight"
5001
+ });
5002
+ };
5003
+ const departing = useMemo(
5004
+ () => new Map(
5005
+ [...overrides.current].flatMap(
5006
+ ([key, { patch }]) => patch.kind === "remove" ? [[key, null]] : patch.kind === "move" ? [[key, patch.toSection]] : []
5007
+ )
5008
+ ),
5009
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5010
+ [overrideRev]
5011
+ );
4838
5012
  const restore = () => {
4839
5013
  const newest = failedOverrides(overrides.current)[0];
4840
5014
  if (!newest) return;
@@ -4857,6 +5031,7 @@ var App = ({
4857
5031
  displayedSections.current = cached.sections;
4858
5032
  setFetchedAt(cached.at);
4859
5033
  if (cached.sidebar) setSidebar(cached.sidebar);
5034
+ if (cached.focus !== void 0) setFocus(cached.focus);
4860
5035
  setState({
4861
5036
  phase: "browse",
4862
5037
  sections: cached.sections,
@@ -4919,6 +5094,7 @@ var App = ({
4919
5094
  if (shared && shared.at > firedAt) {
4920
5095
  receive({ sections: shared.sections, login: shared.login });
4921
5096
  if (shared.sidebar) setSidebar(shared.sidebar);
5097
+ if (shared.focus !== void 0) setFocus(shared.focus);
4922
5098
  setFetchedAt(shared.at);
4923
5099
  return;
4924
5100
  }
@@ -4939,6 +5115,7 @@ var App = ({
4939
5115
  );
4940
5116
  const [appliedSummary, setAppliedSummary] = useState("");
4941
5117
  const [sidebar, setSidebar] = useState(void 0);
5118
+ const [focus, setFocus] = useState(void 0);
4942
5119
  const [skippedForBudget, setSkippedForBudget] = useState(false);
4943
5120
  const [mergedUrls, setMergedUrls] = useState([]);
4944
5121
  const [leavingUrls, setLeavingUrls] = useState([]);
@@ -4991,6 +5168,15 @@ var App = ({
4991
5168
  width: COLS
4992
5169
  }
4993
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,
4994
5180
  /* @__PURE__ */ jsx(
4995
5181
  Box,
4996
5182
  {
@@ -5070,6 +5256,8 @@ var App = ({
5070
5256
  jiraTransitions,
5071
5257
  jiraTransitionsFor,
5072
5258
  optimisticMove,
5259
+ optimistic,
5260
+ departing,
5073
5261
  pendingKeys,
5074
5262
  failure,
5075
5263
  onRestore: restore,
@@ -5101,6 +5289,9 @@ var App = ({
5101
5289
  ciJob,
5102
5290
  stripState: hasStrip ? stripState : void 0,
5103
5291
  stripLabel,
5292
+ focus,
5293
+ focusLabel,
5294
+ focusEmpty,
5104
5295
  tabHelp,
5105
5296
  onOpenPr: (item) => {
5106
5297
  setChromeSpec({});
@@ -5149,4 +5340,4 @@ var matchesFilter = (repo, filter) => {
5149
5340
  };
5150
5341
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
5151
5342
 
5152
- 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.73.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.36.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.36.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",