@neosh/api 0.4.0 → 0.4.2

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/src/ui.ts CHANGED
@@ -513,7 +513,11 @@ export async function picker<T>(
513
513
  items: PickerItem<T>[],
514
514
  opts: PickerOptions<T> = {},
515
515
  ): Promise<T | null> {
516
- const height = Math.max(1, opts.height ?? 12);
516
+ // What the list would like. What it *gets* is settled by `measure` below, once the frontend has
517
+ // said how much of the screen there was — a picker asking for twelve rows on a sixteen-row
518
+ // terminal is not a twelve-row picker, and every number this widget computes from `rows` was
519
+ // wrong by the difference until it asked.
520
+ let rows = Math.max(1, opts.height ?? 12);
517
521
  const width = Math.max(20, opts.width ?? 64);
518
522
  const filtering = opts.filter !== false;
519
523
 
@@ -530,13 +534,13 @@ export async function picker<T>(
530
534
  anchor: opts.anchor ?? { kind: "screen" },
531
535
  offset: opts.offset,
532
536
  width: { kind: "fixed", n: width },
533
- // Exactly the rows that get drawn: the list, plus a filter line when there is one, a title
534
- // when there is one, and the key strip unless it was waived.
535
- height: {
536
- kind: "fixed",
537
- n: height + (filtering ? 1 : 0) + (opts.title ? 1 : 0) + (hints === "" ? 0 : 1),
538
- },
537
+ // Exactly the rows that get drawn: the list, plus a filter line when there is one and a title
538
+ // when there is one. The key strip is not among them any more — it is on the bottom border,
539
+ // where it costs no row and cannot be the row that does not fit. It was a row, and on a
540
+ // sixteen-row terminal it was reliably the one clipped: the strip that says how to get out.
541
+ height: { kind: "fixed", n: rows + (filtering ? 1 : 0) + (opts.title ? 1 : 0) },
539
542
  border: "rounded",
543
+ footer: hints === "" ? undefined : ` ${hints} `,
540
544
  focusable: true,
541
545
  closeOnBlur: true,
542
546
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -625,7 +629,7 @@ export async function picker<T>(
625
629
  if (closed) return;
626
630
  // Keep the cursor on screen without recentring on every keystroke.
627
631
  if (cursor < top) top = cursor;
628
- if (cursor >= top + height) top = cursor - height + 1;
632
+ if (cursor >= top + rows) top = cursor - rows + 1;
629
633
 
630
634
  const lines: string[] = [];
631
635
  if (opts.title) lines.push(opts.title);
@@ -636,7 +640,7 @@ export async function picker<T>(
636
640
  // dock. Measuring against anything else puts the last visible character on both lines — the
637
641
  // continuation says `/ finds` under a row that already ended in `/`.
638
642
  const inner = Math.max(8, width);
639
- let window = visible.slice(top, top + height);
643
+ let window = visible.slice(top, top + rows);
640
644
  // One gutter for the whole list, or none at all. Giving it only to the rows that asked for an
641
645
  // icon would step every other label one column left, which reads as a list that cannot decide
642
646
  // where its left margin is.
@@ -672,7 +676,7 @@ export async function picker<T>(
672
676
  // float's height: the window was sized for `height` lines and growing past it would push the
673
677
  // key strip off the bottom, which is the row that says how to get out.
674
678
  if (rest.length > 0) {
675
- const room = Math.max(1, height - rest.length);
679
+ const room = Math.max(1, rows - rest.length);
676
680
  if (cursor < top) top = cursor;
677
681
  if (cursor >= top + room) top = cursor - room + 1;
678
682
  window = visible.slice(top, top + room);
@@ -691,23 +695,6 @@ export async function picker<T>(
691
695
  if (!isCursor) continue;
692
696
  for (const line of rest) lines.push(` ${line}`);
693
697
  }
694
- // Pushed onto the last row rather than floated: the float is sized for it, and a strip that
695
- // moved up as the list shortened would be a strip you have to look for.
696
- //
697
- // The placeholder takes a row of the list's own space, so an empty list has used one of the
698
- // `height` rows and not none. Counting it as none put the strip one row past the bottom of a
699
- // float sized for exactly `height`, where it was silently clipped — leaving the empty state,
700
- // the one state where you most want to be told what the keys do, as the only one with no keys
701
- // on it.
702
- // Lines, not rows: the row under the cursor is more than one of them when it has unfolded, and
703
- // counting rows here would leave the strip that many lines low — off the bottom of a float
704
- // sized for exactly `height`.
705
- const listRows = Math.max(1, lines.length - firstListLine);
706
- const hintLine = hints === "" ? -1 : lines.length + Math.max(0, height - listRows);
707
- if (hintLine >= 0) {
708
- while (lines.length < hintLine) lines.push("");
709
- lines.push(` ${hints}`);
710
- }
711
698
  // Marks are collected against their row and handed over with the text, in one call. Set one at
712
699
  // a time they were a sequence the frontend could draw the middle of: the moment the clear had
713
700
  // landed and the marks had not, every row drew unmarked — in `Normal`, which is near-white.
@@ -716,9 +703,6 @@ export async function picker<T>(
716
703
  drawn[line]?.marks!.push({ col, opts: o });
717
704
  };
718
705
 
719
- if (hintLine >= 0) {
720
- mark(hintLine, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[hintLine] ?? "") });
721
- }
722
706
  const listTop = (opts.title ? 1 : 0) + (filtering ? 1 : 0);
723
707
  for (let i = 0; i < window.length; i++) {
724
708
  const row = window[i]!;
@@ -869,10 +853,10 @@ export async function picker<T>(
869
853
  cursor = Math.max(0, cursor - 1);
870
854
  break;
871
855
  case "page_down":
872
- cursor = Math.min(last(), cursor + height);
856
+ cursor = Math.min(last(), cursor + rows);
873
857
  break;
874
858
  case "page_up":
875
- cursor = Math.max(0, cursor - height);
859
+ cursor = Math.max(0, cursor - rows);
876
860
  break;
877
861
  case "first":
878
862
  cursor = 0;
@@ -923,6 +907,33 @@ export async function picker<T>(
923
907
  }, { desc: "picker key" }),
924
908
  );
925
909
 
910
+ /**
911
+ * How many list rows there turned out to be room for.
912
+ *
913
+ * A float is *asked* for a height and given whatever the screen has, so `opts.height` is a wish
914
+ * and this is the answer. Everything above counts in `rows` — where the window starts, what a
915
+ * page step is, how far the cursor may go before the list scrolls — and while that number was the
916
+ * wish, a picker on a short terminal scrolled to keep the cursor on a row that was not drawn.
917
+ *
918
+ * Read once when the panel opens and again whenever the frontend says this window changed size,
919
+ * rather than on every keystroke: a round trip per character typed is latency on the one path in
920
+ * this widget that has to feel immediate.
921
+ */
922
+ async function measure(): Promise<void> {
923
+ if (closed) return;
924
+ const v = await neosh.win.viewport(win).catch(() => null);
925
+ if (!v) return;
926
+ const next = Math.max(1, v.height - (filtering ? 1 : 0) - (opts.title ? 1 : 0));
927
+ if (next === rows) return;
928
+ rows = next;
929
+ await render();
930
+ }
931
+ disposers.push(
932
+ neosh.event.on("neosh.viewport", (e) => {
933
+ if ((e as { win?: WindowId }).win === win) void measure();
934
+ }),
935
+ );
936
+
926
937
  await neosh.focus.push(win);
927
938
  disposers.push(await neosh.keymap.capture(win, command));
928
939
  // `<C-c>` is bound globally to `interrupt`, which would arm "press again to quit" while a picker
@@ -935,6 +946,7 @@ export async function picker<T>(
935
946
  if (typeof opts.query === "function") query = opts.query();
936
947
  await refetch();
937
948
  await render();
949
+ await measure();
938
950
  if (opts.onHighlight) {
939
951
  const row = visible[cursor];
940
952
  if (row) opts.onHighlight(row.item, row.index);
@@ -957,6 +969,145 @@ function dropSegment(text: string): string {
957
969
  return at < 0 ? "" : trimmed.slice(0, at + 1);
958
970
  }
959
971
 
972
+ // ---------------------------------------------------------------------------
973
+ // Reading something too long for the screen
974
+ // ---------------------------------------------------------------------------
975
+
976
+ export interface PagerOptions {
977
+ /** On the top border. */
978
+ title?: string;
979
+ /**
980
+ * On the bottom border, replacing the default legend.
981
+ *
982
+ * The default already names the scroll keys and the way out, read out of nothing — they are fixed
983
+ * defaults on `neosh.scroll` and `^Z` is where a rebinding shows up. Pass your own only to add a
984
+ * verb of your own to it.
985
+ */
986
+ footer?: string;
987
+ /** Columns of content. The border is drawn outside it. */
988
+ width?: number;
989
+ /**
990
+ * The most rows of content to ask for. Fewer if the content is shorter, fewer still if the screen
991
+ * is — which is the case this whole widget is about, and is why it is a ceiling and not a size.
992
+ */
993
+ height?: number;
994
+ /**
995
+ * The buffer kind, so a third party can bind keys in *this* panel and find it with `win.ofKind`.
996
+ *
997
+ * Defaults to {@link KIND_PAGER}, which every pager in the workspace shares. Pass your own when
998
+ * the panel is a thing in its own right — a diff, a status — and somebody might reasonably want
999
+ * keys on that and not on every pager there is.
1000
+ */
1001
+ kind?: string;
1002
+ /** Marks to lay over the rows, in the same shape {@link Neosh.buf.render} takes. */
1003
+ marks?: DrawnRow["marks"][];
1004
+ z?: number;
1005
+ /**
1006
+ * Handed this panel's own close, as soon as it is on screen.
1007
+ *
1008
+ * For the caller that has to be able to put it away itself — a key that toggles, a panel that
1009
+ * closes when the thing it is about goes. Without it the only way out is a key, and a `^Z` that
1010
+ * opens a sheet and then opens a second one behind it is the bug this exists to prevent.
1011
+ */
1012
+ onOpen?: (close: () => Promise<void>) => void;
1013
+ }
1014
+
1015
+ /** The kind a pager's buffer gets when the caller does not name one. */
1016
+ export const KIND_PAGER = "neosh.pager";
1017
+
1018
+ /**
1019
+ * Show rows you can read but not edit, in a float that scrolls when it does not fit.
1020
+ *
1021
+ * The shape almost every read-only panel in the workspace turned out to want, and the thing each of
1022
+ * them was missing: a float is given whatever height the screen has, so a status of sixty changed
1023
+ * files or a diff of four hundred lines was thirty rows on screen and the rest drawn nowhere, with
1024
+ * no key pointed at it and nothing to say it was there. Every one of them had independently written
1025
+ * "open a float, put lines in it" and independently stopped before the part where the terminal is
1026
+ * short.
1027
+ *
1028
+ * What you get: the reader's motions (`j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, arrows, paging keys)
1029
+ * and the wheel, because the float sets `scroll` and those are ordinary bindings on
1030
+ * `neosh.scroll` — so `^Z` lists them and `init.ts` moves them. A bar down the right border while
1031
+ * anything is hidden. A legend on the bottom border, which cannot scroll away and is not the first
1032
+ * row clipped. `<Esc>`, `q`, `<CR>` and `^C` close it.
1033
+ *
1034
+ * Resolves when it closes.
1035
+ */
1036
+ export async function pager(
1037
+ neosh: Neosh,
1038
+ lines: string[],
1039
+ opts: PagerOptions = {},
1040
+ ): Promise<void> {
1041
+ const buf = await neosh.buf.create({
1042
+ name: `[${opts.title?.trim() || "pager"}]`,
1043
+ scratch: true,
1044
+ kind: opts.kind ?? KIND_PAGER,
1045
+ });
1046
+ const ns = await neosh.ns.create("neosh.ui.pager");
1047
+ await neosh.buf.render(
1048
+ buf,
1049
+ ns,
1050
+ 0,
1051
+ -1,
1052
+ lines.map((text, i) => ({ text, marks: opts.marks?.[i] ?? [] })),
1053
+ );
1054
+
1055
+ const win = await neosh.float.open(buf, {
1056
+ anchor: { kind: "screen" },
1057
+ width: opts.width === undefined ? { kind: "auto" } : { kind: "fixed", n: opts.width },
1058
+ // A ceiling, never a size. `fixed` here would ask for rows the screen does not have and get
1059
+ // clipped to them anyway — the difference being that `max` also shrinks to fit content that is
1060
+ // shorter, so a two-line status is a two-line panel rather than a mostly empty box.
1061
+ height: { kind: "max", n: opts.height ?? 30 },
1062
+ border: "rounded",
1063
+ title: opts.title,
1064
+ footer: opts.footer ?? " j k move ^D ^U half gg G ends esc close ",
1065
+ focusable: true,
1066
+ closeOnBlur: true,
1067
+ scroll: true,
1068
+ z: opts.z ?? 200,
1069
+ });
1070
+ await neosh.focus.push(win);
1071
+
1072
+ let settle: () => void = () => {};
1073
+ const done = new Promise<void>((resolve) => {
1074
+ settle = resolve;
1075
+ });
1076
+
1077
+ const command = `neosh.ui.pager.key.${++pickerSeq}`;
1078
+ const disposers: Disposable[] = [];
1079
+ let closed = false;
1080
+ const close = async () => {
1081
+ if (closed) return;
1082
+ closed = true;
1083
+ for (const d of disposers) d.dispose();
1084
+ await neosh.focus.pop().catch(() => {});
1085
+ await neosh.win.close(win).catch(() => {});
1086
+ settle();
1087
+ };
1088
+
1089
+ disposers.push(
1090
+ await neosh.cmd.register(command, (_args, key) => {
1091
+ // Only what nothing else claimed reaches here, and the scroll keys are claimed — they resolve
1092
+ // at `neosh.scroll`, which is nearer than this capture is late. So what is left is dismissal.
1093
+ // Which is *named*: a panel you scroll is a panel you spend time in, and "any key closes it"
1094
+ // turns a mistyped letter into losing your place.
1095
+ if (!key || closesPager(key)) void close();
1096
+ }, { desc: "close this panel" }),
1097
+ );
1098
+ disposers.push(await neosh.keymap.capture(win, command));
1099
+ opts.onOpen?.(close);
1100
+ return done;
1101
+ }
1102
+
1103
+ /** `<Esc>`, `q`, `<CR>` and `^C` — the four keys every panel in this workspace closes on. */
1104
+ function closesPager(key: KeyContext): boolean {
1105
+ const { code, mods } = key.key;
1106
+ if (code.kind === "esc" || code.kind === "enter") return true;
1107
+ if (code.kind !== "char") return false;
1108
+ return mods.ctrl ? code.c.toLowerCase() === "c" : code.c.toLowerCase() === "q";
1109
+ }
1110
+
960
1111
  // ---------------------------------------------------------------------------
961
1112
  // Derived widgets
962
1113
  // ---------------------------------------------------------------------------
@@ -1013,6 +1164,18 @@ export async function confirm(
1013
1164
  `y ${yes.toLowerCase()} n ${no.toLowerCase()} ↵ choose esc cancel`,
1014
1165
  width - 2,
1015
1166
  );
1167
+ /**
1168
+ * How many of the detail lines there is room for.
1169
+ *
1170
+ * Settled once the frontend has said how tall this dialog turned out to be — a dialog is asked
1171
+ * for a height and given what the screen has, and the rows it loses are the ones at the bottom.
1172
+ * Which for a question with the answers under the detail meant the answers, so a long `detail` on
1173
+ * a short terminal was a dialog you could not see either option in.
1174
+ *
1175
+ * The detail is what gives way, because the detail is the part you can do without: the question
1176
+ * and the two answers are the dialog. What went is said rather than silently dropped.
1177
+ */
1178
+ let room = detail.length;
1016
1179
 
1017
1180
  // On the answer that changes nothing, when the other one cannot be taken back.
1018
1181
  let cursor = opts.dangerous ? 1 : 0;
@@ -1025,14 +1188,17 @@ export async function confirm(
1025
1188
 
1026
1189
  /** Where each part of the dialog starts, so the marks do not have to count rows twice. */
1027
1190
  const detailAt = asked.length + 1;
1028
- const answersAt = detailAt + (detail.length === 0 ? 0 : detail.length + 1);
1029
- const stripAt = answersAt + answers.length + 1;
1191
+ /** Every row but the detail: the question, the blanks around it, and the two answers. */
1192
+ const fixedRows = asked.length + 1 + answers.length;
1030
1193
 
1031
1194
  const win = await neosh.float.open(buf, {
1032
1195
  anchor: { kind: "screen" },
1033
1196
  width: { kind: "fixed", n: width },
1034
- height: { kind: "fixed", n: stripAt + 1 },
1197
+ height: { kind: "fixed", n: fixedRows + (detail.length === 0 ? 0 : detail.length + 1) },
1035
1198
  border: "rounded",
1199
+ // The key strip, on the edge. It was the last row of the buffer, which made it both the row a
1200
+ // long `detail` pushed off the bottom and a row of height every dialog had to pay for.
1201
+ footer: ` ${strip} `,
1036
1202
  focusable: true,
1037
1203
  closeOnBlur: true,
1038
1204
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -1048,15 +1214,20 @@ export async function confirm(
1048
1214
  });
1049
1215
 
1050
1216
  const render = async () => {
1217
+ const shownDetail = room >= detail.length
1218
+ ? detail
1219
+ : // One row of the budget goes to saying what is not being shown, so a dialog that has had to
1220
+ // cut something never looks like a dialog that had nothing more to say.
1221
+ [...detail.slice(0, Math.max(0, room - 1)), `… and ${detail.length - Math.max(0, room - 1)} more lines`];
1222
+ const detailRows = shownDetail.length;
1223
+ const answersAt = detailAt + (detailRows === 0 ? 0 : detailRows + 1);
1051
1224
  const lines: string[] = asked.map((l) => ` ${l}`);
1052
1225
  lines.push("");
1053
- if (detail.length > 0) {
1054
- lines.push(...detail.map((l) => ` ${l}`));
1226
+ if (detailRows > 0) {
1227
+ lines.push(...shownDetail.map((l) => ` ${l}`));
1055
1228
  lines.push("");
1056
1229
  }
1057
1230
  answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`));
1058
- lines.push("");
1059
- lines.push(` ${strip}`);
1060
1231
  // Text and marks together, in one call: this is redrawn on every keystroke, and a repaint the
1061
1232
  // frontend can draw the middle of is one that flashes — unmarked rows draw in `Normal`. It also
1062
1233
  // takes care of the older half of the same bug, that a mark whose line was replaced under it
@@ -1069,7 +1240,7 @@ export async function confirm(
1069
1240
  for (let i = 0; i < asked.length; i++) {
1070
1241
  mark(i, 0, { hlGroup: "Title", endCol: byteLength(lines[i] ?? "") });
1071
1242
  }
1072
- for (let i = 0; i < detail.length; i++) {
1243
+ for (let i = 0; i < detailRows; i++) {
1073
1244
  mark(detailAt + i, 0, {
1074
1245
  hlGroup: "Comment",
1075
1246
  endCol: byteLength(lines[detailAt + i] ?? ""),
@@ -1090,7 +1261,6 @@ export async function confirm(
1090
1261
  });
1091
1262
  }
1092
1263
  }
1093
- mark(stripAt, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[stripAt] ?? "") });
1094
1264
  await neosh.buf.render(buf, ns, 0, -1, drawn);
1095
1265
  // The caret marks the answer, since there is nothing here to type into.
1096
1266
  await neosh.win.setCursor(win, answersAt + cursor, 0);
@@ -1161,6 +1331,23 @@ export async function confirm(
1161
1331
  disposers.push(await neosh.keymap.capture(win, command));
1162
1332
  await bindWidgetKeys(neosh, win, command, keys);
1163
1333
  await render();
1334
+ // And then how much of it there was room for. Asked after the first draw, because a window the
1335
+ // frontend has never laid out has no answer to give.
1336
+ const measure = async () => {
1337
+ if (closed) return;
1338
+ const v = await neosh.win.viewport(win).catch(() => null);
1339
+ if (!v) return;
1340
+ const next = Math.max(0, v.height - fixedRows - 1);
1341
+ if (next === room) return;
1342
+ room = next;
1343
+ await render();
1344
+ };
1345
+ disposers.push(
1346
+ neosh.event.on("neosh.viewport", (e) => {
1347
+ if ((e as { win?: WindowId }).win === win) void measure();
1348
+ }),
1349
+ );
1350
+ await measure();
1164
1351
  return done;
1165
1352
  }
1166
1353
 
@@ -1401,6 +1588,9 @@ export async function prompt(
1401
1588
  width: { kind: "fixed", n: width },
1402
1589
  height: { kind: "fixed", n: 2 },
1403
1590
  border: "rounded",
1591
+ // A field with no legend on it is a field you guess at. On the border, so it costs neither of
1592
+ // the two rows this has.
1593
+ footer: " ↵ accept ^W a word ^U all esc cancel ",
1404
1594
  focusable: true,
1405
1595
  closeOnBlur: true,
1406
1596
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -2224,9 +2414,11 @@ export async function railPicker<G, T>(
2224
2414
  const win = await neosh.float.open(buf, {
2225
2415
  anchor: { kind: "screen" },
2226
2416
  width: { kind: "fixed", n: total },
2227
- // title, filter, rule, body, rule, hints
2228
- height: { kind: "fixed", n: height + 5 },
2417
+ // title, filter, rule, body, rule. The key strip is not among them: it is on the bottom border,
2418
+ // where it costs no row of body and cannot be the row a short terminal clips.
2419
+ height: { kind: "fixed", n: height + 4 },
2229
2420
  border: "rounded",
2421
+ footer: ` ${opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close"} `,
2230
2422
  focusable: true,
2231
2423
  closeOnBlur: true,
2232
2424
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -2336,8 +2528,21 @@ export async function railPicker<G, T>(
2336
2528
  all = got;
2337
2529
  rebuild();
2338
2530
  cursor = Math.max(0, Math.min(opts.itemAt?.(all) ?? 0, Math.max(0, rows.length - 1)));
2339
- // Land on something selectable rather than on a section header.
2340
- if (rows[cursor]?.kind !== "item") cursor = rows.findIndex((r) => r.kind === "item");
2531
+ // Land on something you can actually press `↵` on: not a section header, and not a row the
2532
+ // caller disabled. `movePane` has always stepped over disabled rows, so the only way to be
2533
+ // standing on one was to open here — and the first row of a list is where a picker opens by
2534
+ // default. A pane whose top row is "this needs a newer CLI" then greeted every `↵` with
2535
+ // nothing happening, which reads as the picker being broken rather than the row being refused.
2536
+ const landable = (at: number) => {
2537
+ const row = rows[at];
2538
+ return row?.kind === "item" && !row.item.disabled;
2539
+ };
2540
+ if (!landable(cursor)) {
2541
+ const next = rows.findIndex((_, at) => landable(at));
2542
+ // Every row refused is still a list worth opening — you came to read why. Falling back to
2543
+ // the first item rather than to nothing keeps the detail line of *something* on screen.
2544
+ cursor = next >= 0 ? next : rows.findIndex((r) => r.kind === "item");
2545
+ }
2341
2546
  if (cursor < 0) cursor = 0;
2342
2547
  paneTop = 0;
2343
2548
  };
@@ -2415,7 +2620,11 @@ export async function railPicker<G, T>(
2415
2620
  ? takeWords(detail, Math.max(0, room))
2416
2621
  : [clipToWidth(detail, Math.max(0, room)), ""];
2417
2622
  const label = padToWidth(labelHead, labelWidth);
2418
- const badgeCell = badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth), badgeWidth);
2623
+ // One column narrower than the cell it sits in, so there is always a space before the detail.
2624
+ // Clipped to the full width, a badge that happens to fill it runs straight into the sentence
2625
+ // after it and the two read as one word.
2626
+ const badgeCell =
2627
+ badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth - 1), badgeWidth);
2419
2628
  const text = `${head}${label}${badgeCell}${detailHead}`;
2420
2629
 
2421
2630
  const labelAt = byteLength(head);
@@ -2543,12 +2752,6 @@ export async function railPicker<G, T>(
2543
2752
  line: lines.length - 1,
2544
2753
  mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Separator" },
2545
2754
  });
2546
- const hints = opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close";
2547
- lines.push(` ${hints}`);
2548
- marks.push({
2549
- line: lines.length - 1,
2550
- mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Sidebar.Dim" },
2551
- });
2552
2755
 
2553
2756
  // The marks were already collected against their rows; they now travel with them. One call
2554
2757
  // rather than one per mark, so there is no frame in which the text has arrived and the colour
@@ -2766,7 +2969,16 @@ export interface ActionItem {
2766
2969
  /** For the hint strip and `^Z`. */
2767
2970
  label: string;
2768
2971
  command: string;
2769
- /** Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`. */
2972
+ /**
2973
+ * Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`.
2974
+ *
2975
+ * `custom:<section id>` narrows it to the rows of **one** section: `custom:git` is a verb about
2976
+ * the git block and nothing else. Bare `custom` means every contributed row in the panel, which
2977
+ * is right for a verb about contributions in general and wrong for the usual case — two plugins
2978
+ * each with a block in the column and each wanting `<Tab>` on their own rows were, without this,
2979
+ * one key that one of them won and the other silently lost. A panel binds the key once and sends
2980
+ * it to whichever action matches the row the cursor is actually on.
2981
+ */
2770
2982
  on?: string;
2771
2983
  }
2772
2984
 
@@ -3012,7 +3224,16 @@ export function placeSections<S extends string>(
3012
3224
  */
3013
3225
  export function sectionRows<T>(
3014
3226
  c: Contribution & { item: SectionItem },
3015
- opts: { width: number; custom: (command: string, args: string[]) => T },
3227
+ opts: {
3228
+ width: number;
3229
+ /**
3230
+ * What a landable contributed row *is*, in the panel's own vocabulary.
3231
+ *
3232
+ * The section's id comes third so a panel can tell one contributor's rows from another's —
3233
+ * which is what {@link ActionItem.on}'s `custom:<id>` form is matched against.
3234
+ */
3235
+ custom: (command: string, args: string[], section: string) => T;
3236
+ },
3016
3237
  ): ListRow<T>[] {
3017
3238
  const rows: ListRow<T>[] = [];
3018
3239
  const contributed = Array.isArray(c.item?.rows) ? c.item.rows : [];
@@ -3052,7 +3273,7 @@ export function sectionRows<T>(
3052
3273
  spans: spans.length > 0 ? spans : undefined,
3053
3274
  right: typeof r.right?.text === "string" ? { text: `${r.right.text} `, hl: r.right.hl } : undefined,
3054
3275
  inert: typeof r.command !== "string",
3055
- value: typeof r.command === "string" ? opts.custom(r.command, args) : undefined,
3276
+ value: typeof r.command === "string" ? opts.custom(r.command, args, c.id) : undefined,
3056
3277
  });
3057
3278
  }
3058
3279
  return rows;