pi-better-background-tasks 0.4.0 → 0.6.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.
@@ -93,8 +93,19 @@ type NavigatorState = {
93
93
  mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
94
94
  mainListDeadlineScheduler?: RenderScheduler;
95
95
  editorComponent?: Component;
96
+ /** The editor Pi currently has mounted (the wrapper the factory returned last). */
97
+ editorFocusTarget?: unknown;
98
+ /** How many times that wrapper has rendered, so an overlay can tell whether it is still on screen. */
99
+ editorRenders?: number;
100
+ /**
101
+ * Whether that wrapper rendered between the overlay's last two frames: true while it is on screen,
102
+ * including inside another extension's editor that wraps it. Undefined until two frames compare.
103
+ */
104
+ editorRenderedLastFrame?: boolean;
96
105
  detailOverlayRows?: number;
97
106
  dispose?: () => void;
107
+ /** The mounted detail overlay, reused instead of stacking a second one. */
108
+ overlay?: { showDetail(navigatorId: string): void; focus(): void };
98
109
  };
99
110
 
100
111
  const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
@@ -199,6 +210,7 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
199
210
  const s = state();
200
211
  try { s.dispose?.(); } catch { /* ignore */ }
201
212
  s.dispose = undefined;
213
+ s.overlay = undefined;
202
214
  stopMainListWidget();
203
215
  const activeCtx = ctx ?? s.uiCtx;
204
216
  s.uiCtx = undefined;
@@ -627,7 +639,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
627
639
 
628
640
  function wrapEditor(inner: any, deps: HostDeps): unknown {
629
641
  if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
630
- return new Proxy(inner, {
642
+ const proxy: any = new Proxy(inner, {
631
643
  get(target, prop) {
632
644
  if (prop === "handleInput") {
633
645
  return (data: string) => {
@@ -637,6 +649,13 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
637
649
  target.handleInput(data);
638
650
  };
639
651
  }
652
+ if (prop === "render" && typeof target.render === "function") {
653
+ return (...args: unknown[]) => {
654
+ const s = state();
655
+ if (s.editorFocusTarget === proxy) s.editorRenders = (s.editorRenders ?? 0) + 1;
656
+ return target.render(...args);
657
+ };
658
+ }
640
659
  const value = Reflect.get(target, prop);
641
660
  return typeof value === "function" ? value.bind(target) : value;
642
661
  },
@@ -644,12 +663,24 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
644
663
  return Reflect.set(target, prop, value);
645
664
  },
646
665
  });
666
+ // Pi mounts and focuses whatever the factory returns; an extension that
667
+ // re-installs the editor replaces it, so this always names the live one.
668
+ state().editorFocusTarget = proxy;
669
+ return proxy;
647
670
  }
648
671
 
649
672
  function handleMainListInput(data: string, deps: HostDeps): boolean {
650
673
  const s = state();
651
674
  const rows = listRows();
652
- if (rows.length === 0) return false;
675
+ if (rows.length === 0) {
676
+ // Every row is gone, but an overlay that lost focus may still be mounted: Esc still closes it.
677
+ if (s.overlay && deps.matchKey(data, "escape")) {
678
+ unfocusMainList();
679
+ dismissOverlay();
680
+ return true;
681
+ }
682
+ return false;
683
+ }
653
684
  if (!s.mainListFocused) {
654
685
  if (!deps.isOpenTrigger(data)) return false;
655
686
  focusMainList();
@@ -673,7 +704,10 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
673
704
  }
674
705
  if (deps.matchKey(data, "enter")) {
675
706
  const selected = selectedMainListRow();
676
- if (selected?.parentRow) unfocusMainList();
707
+ if (selected?.parentRow) {
708
+ unfocusMainList();
709
+ dismissOverlay();
710
+ }
677
711
  else openNavigator();
678
712
  return true;
679
713
  }
@@ -683,11 +717,32 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
683
717
  }
684
718
  if (deps.matchKey(data, "escape") || deps.isOpenTrigger(data)) {
685
719
  unfocusMainList();
720
+ dismissOverlay();
686
721
  return true;
687
722
  }
688
723
  return false;
689
724
  }
690
725
 
726
+ /**
727
+ * Whether pi-tui has the editor our wrapper returned mounted (#332). Pi mounts whatever the factory
728
+ * returns, so another extension's `setEditorComponent` (a foreign factory, or `undefined` for Pi's
729
+ * default editor) unmounts it, and no key reaches our input handler after that. `undefined` means
730
+ * the host cannot say (no `isComponentMounted`), and callers keep their earlier behaviour.
731
+ */
732
+ function liveEditorMounted(tui: unknown): boolean | undefined {
733
+ const live = state().editorFocusTarget;
734
+ const check = (tui as { isComponentMounted?: (component: unknown) => boolean } | undefined)?.isComponentMounted;
735
+ if (!live || typeof check !== "function") return undefined;
736
+ try { return check.call(tui, live) === true; } catch { return undefined; }
737
+ }
738
+
739
+ /** Close a detail overlay that is still mounted while the editor has focus. */
740
+ function dismissOverlay(): void {
741
+ const s = state();
742
+ if (!s.overlay) return;
743
+ try { s.dispose?.(); } catch { /* ignore */ }
744
+ }
745
+
691
746
  function handleMainListCloseKey(): void {
692
747
  const row = selectedMainListRow();
693
748
  if (!row || row.parentRow) return;
@@ -721,20 +776,59 @@ function openNavigator(): void {
721
776
  const rows = listRows();
722
777
  if (rows.length === 0) return;
723
778
  const selectedId = selectedMainListRow()?.navigatorId ?? rows[0]!.navigatorId;
779
+ // An overlay that lost focus (another UI took it and handed it to the editor)
780
+ // is still mounted: switch its detail and focus it rather than stacking a
781
+ // second overlay over it, whose Esc would only reveal the stale one below.
782
+ const mounted = s.overlay;
783
+ if (mounted) {
784
+ // showDetail may close the overlay (and clear s.overlay); focus() is then a no-op.
785
+ mounted.showDetail(selectedId);
786
+ mounted.focus();
787
+ return;
788
+ }
724
789
  try {
725
790
  try { s.dispose?.(); } catch { /* ignore */ }
726
791
  s.dispose = undefined;
792
+ s.editorRenderedLastFrame = undefined;
727
793
  let disposeToken: (() => void) | undefined;
794
+ let overlayRef: NavigatorState["overlay"];
795
+ let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
796
+ const release = () => {
797
+ if (overlayRef && s.overlay === overlayRef) s.overlay = undefined;
798
+ };
728
799
  const opened = (ctx.ui as any).custom((tui: any, theme: any, _keybindings: any, done: (v: null) => void) => {
729
- const component = createOverlayComponent(rows, deps, tui, theme, done, () => {
800
+ const component = createOverlayComponent(rows, deps, tui, theme, (value) => {
801
+ release();
802
+ // Pi restores focus to the editor that was focused when the overlay opened. If an
803
+ // extension re-installed the editor since, that instance is unmounted and every key
804
+ // would be lost; hand focus to the live editor before closing.
805
+ // Only an editor on screen can take focus: after another extension replaced ours, Pi already
806
+ // focused its replacement, and handing focus to our unmounted wrapper would swallow every key.
807
+ // An extension that wraps ours mounts its own component, so pi-tui does not find ours, but ours
808
+ // still renders and handles every key through it: that is still the live editor (#332).
809
+ const liveEditor = s.editorFocusTarget;
810
+ if (liveEditor && (liveEditorMounted(tui) !== false || s.editorRenderedLastFrame !== false)) {
811
+ try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
812
+ }
813
+ done(value);
814
+ }, () => {
730
815
  s.lastHint = undefined;
731
816
  refreshBackgroundWorkNavigator(ctx);
732
817
  }, selectedId);
733
- disposeToken = () => component.dispose();
818
+ overlayRef = {
819
+ showDetail: (navigatorId) => component.showDetail(navigatorId),
820
+ focus: () => {
821
+ if (s.overlay !== overlayRef) return;
822
+ try { handle?.focus?.(); } catch { /* ignore */ }
823
+ },
824
+ };
825
+ s.overlay = overlayRef;
826
+ disposeToken = () => component.dismiss();
734
827
  s.dispose = disposeToken;
735
828
  return component;
736
- }, { overlay: true, overlayOptions: detailOverlayOptions });
829
+ }, { overlay: true, overlayOptions: detailOverlayOptions, onHandle: (h: typeof handle) => { handle = h; } });
737
830
  const clear = () => {
831
+ release();
738
832
  if (s.dispose === disposeToken) s.dispose = undefined;
739
833
  };
740
834
  void Promise.resolve(opened).then(clear, clear);
@@ -750,16 +844,16 @@ function createOverlayComponent(
750
844
  theme: { fg?(color: string, value: string): string } | undefined,
751
845
  done: (v: null) => void,
752
846
  onClosed: () => void,
753
- initialDetailId?: string,
847
+ initialDetailId: string,
754
848
  ) {
849
+ // The overlay only ever shows one row's detail; the rail below it is the list.
755
850
  const overlayState: OverlayState = { rows: initialRows, selected: 0 };
756
- let mode: "list" | "detail" = initialDetailId ? "detail" : "list";
757
851
  let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
758
852
  const expandedSections = new Set<string>();
759
- let detailId: string | null = initialDetailId ?? null;
760
- let detail: BackgroundWorkDetail | null = detailId ? (detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null) : null;
853
+ let detailId: string = initialDetailId;
854
+ let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
761
855
  applyDefaultExpandedSections(detail, expandedSections);
762
- if (detailId) {
856
+ {
763
857
  const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
764
858
  if (idx >= 0) overlayState.selected = idx;
765
859
  }
@@ -776,7 +870,7 @@ function createOverlayComponent(
776
870
  }
777
871
 
778
872
  const detailScheduler = createRenderScheduler(() => {
779
- if (!detailId || mode !== "detail" || closed) return;
873
+ if (closed) return;
780
874
  detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
781
875
  requestRender();
782
876
  startDetailTimer();
@@ -837,29 +931,11 @@ function createOverlayComponent(
837
931
  expandedSections.clear();
838
932
  detail = detailFor(row.navigatorId, Date.now(), { logTailLines: logTailRows }) ?? fallbackDetail(row);
839
933
  applyDefaultExpandedSections(detail, expandedSections);
840
- mode = "detail";
841
934
  startDetailTimer();
842
935
  requestRender();
843
936
  }
844
937
 
845
- if (detailId) startDetailTimer();
846
-
847
- function leaveDetail(): void {
848
- if (mode !== "detail") return;
849
- const viewedId = detailId;
850
- clearCloseArm();
851
- stopDetailTimer();
852
- mode = "list";
853
- detail = null;
854
- detailId = null;
855
- expandedSections.clear();
856
- refreshRows();
857
- if (viewedId) {
858
- const idx = overlayState.rows.findIndex((row) => row.navigatorId === viewedId);
859
- if (idx >= 0) overlayState.selected = idx;
860
- }
861
- requestRender();
862
- }
938
+ startDetailTimer();
863
939
 
864
940
  function close(): void {
865
941
  if (closed) return;
@@ -871,20 +947,28 @@ function createOverlayComponent(
871
947
 
872
948
  function handleCloseKey(): void {
873
949
  const row = selectedRow();
874
- if (!row) return;
950
+ if (!row || row.parentRow) return;
875
951
  const now = Date.now();
876
952
  if (closeArm?.id === row.navigatorId && now >= closeArm.armedAt && now < closeArm.armedAt + CLOSE_ARM_MS) {
877
953
  clearCloseArm();
954
+ const before = overlayState.rows;
955
+ const closedIdx = overlayState.selected;
878
956
  closeFor(row);
879
- if (mode === "detail") {
880
- stopDetailTimer();
881
- mode = "list";
882
- detail = null;
883
- detailId = null;
884
- }
885
- refreshRows();
957
+ stopDetailTimer();
958
+ const after = listRows();
959
+ const nextId = neighbourCloseTarget(before, closedIdx, row.navigatorId, after);
886
960
  onClosed();
887
- requestRender();
961
+ if (!nextId) {
962
+ // Nothing left to show: hand the keyboard back to the editor.
963
+ unfocusMainList();
964
+ close();
965
+ return;
966
+ }
967
+ state().mainListSelectedId = nextId;
968
+ overlayState.rows = after;
969
+ overlayState.selected = Math.max(0, after.findIndex((candidate) => candidate.navigatorId === nextId));
970
+ refreshMainListWidget();
971
+ openDetail();
888
972
  return;
889
973
  }
890
974
  clearCloseArm();
@@ -900,21 +984,42 @@ function createOverlayComponent(
900
984
  requestRender();
901
985
  }
902
986
 
987
+ let editorRendersSeen: number | undefined;
988
+ /**
989
+ * Our editor wrapper was uninstalled: close, since no key of ours can reach this overlay again and
990
+ * it would cover the editor that replaced ours (#332). An extension that composes the editor inside
991
+ * its own non-container component still renders ours, so the wrapper also counts as live while it
992
+ * rendered since the previous overlay frame (Pi renders the editor before it composites overlays).
993
+ */
994
+ function closeIfEditorReplaced(): void {
995
+ const rendersBefore = editorRendersSeen;
996
+ const renders = state().editorRenders ?? 0;
997
+ editorRendersSeen = renders;
998
+ state().editorRenderedLastFrame = rendersBefore === undefined ? undefined : renders !== rendersBefore;
999
+ if (closed || liveEditorMounted(tui) !== false || rendersBefore === undefined || renders !== rendersBefore) return;
1000
+ // Not from inside pi-tui's render pass: hiding an overlay there would change the stack it is drawing.
1001
+ queueMicrotask(() => {
1002
+ if (closed) return;
1003
+ unfocusMainList();
1004
+ close();
1005
+ requestRender();
1006
+ });
1007
+ }
1008
+
903
1009
  return {
904
1010
  render(width: number) {
1011
+ closeIfEditorReplaced();
905
1012
  refreshRows();
906
- const railLines = mode === "detail"
907
- ? buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
908
- selectedId: selectedRow()?.navigatorId,
909
- focused: true,
910
- })
911
- : [];
912
- const editorLines = mode === "detail" ? renderEditorLines(width) : [];
1013
+ const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
1014
+ selectedId: selectedRow()?.navigatorId,
1015
+ focused: true,
1016
+ });
1017
+ const editorLines = renderEditorLines(width);
913
1018
  const bottomLines = [...railLines, ...editorLines];
914
1019
  const overlayRows = state().detailOverlayRows;
915
1020
  const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
916
1021
  let contentLines: string[];
917
- if (mode === "detail" && detail?.transcript && deps.createTranscriptComponent) {
1022
+ if (detail?.transcript && deps.createTranscriptComponent) {
918
1023
  if (transcriptDetail !== detail || !transcriptComponent) {
919
1024
  transcriptDetail = detail;
920
1025
  transcriptComponent = deps.createTranscriptComponent(detail, theme);
@@ -930,15 +1035,13 @@ function createOverlayComponent(
930
1035
  } else {
931
1036
  transcriptDetail = null;
932
1037
  transcriptComponent = null;
933
- contentLines = mode === "detail"
934
- ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false })
935
- : buildListLines(overlayState, width, deps.truncate, fg);
1038
+ contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false });
936
1039
  }
937
- if (mode !== "detail") return contentLines;
938
- if (detailRows === undefined) return [...contentLines, ...bottomLines];
1040
+ if (overlayRows === undefined || detailRows === undefined) return [...contentLines, ...bottomLines];
939
1041
  const fittedContent = contentLines.slice(0, detailRows);
940
1042
  while (fittedContent.length < detailRows) fittedContent.push("");
941
- return [...fittedContent, ...bottomLines];
1043
+ // A terminal shorter than rail + editor keeps the bottom rows (the input frame) and drops the top.
1044
+ return [...fittedContent, ...bottomLines].slice(-Math.max(1, overlayRows));
942
1045
  },
943
1046
  handleInput(data: string) {
944
1047
  if (closed) return;
@@ -946,56 +1049,53 @@ function createOverlayComponent(
946
1049
  handleCloseKey();
947
1050
  return;
948
1051
  }
949
- if (mode === "detail") {
950
- if (deps.matchKey(data, "up")) {
951
- selectOverlayRow(overlayState.selected - 1);
952
- activateSelectedRow();
953
- }
954
- else if (deps.matchKey(data, "down")) {
955
- selectOverlayRow(overlayState.selected + 1);
956
- activateSelectedRow();
957
- }
958
- else if (deps.matchKey(data, "left")) {
959
- const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
960
- if (mainIdx >= 0) selectOverlayRow(mainIdx);
961
- close();
962
- }
963
- else if (deps.matchKey(data, "enter")) {
964
- const row = selectedRow();
965
- if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
966
- else {
967
- const sectionId = firstToggleableSectionId(detail);
968
- if (!sectionId) return;
969
- if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
970
- else expandedSections.add(sectionId);
971
- requestRender();
972
- }
973
- }
974
- else if (data === "l" || data === "L") {
975
- logTailRows = cycleLogTailRows(logTailRows);
976
- if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
1052
+ if (deps.matchKey(data, "up")) {
1053
+ selectOverlayRow(overlayState.selected - 1);
1054
+ activateSelectedRow();
1055
+ }
1056
+ else if (deps.matchKey(data, "down")) {
1057
+ selectOverlayRow(overlayState.selected + 1);
1058
+ activateSelectedRow();
1059
+ }
1060
+ else if (deps.matchKey(data, "left")) {
1061
+ const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
1062
+ if (mainIdx >= 0) selectOverlayRow(mainIdx);
1063
+ close();
1064
+ }
1065
+ else if (deps.matchKey(data, "enter")) {
1066
+ const row = selectedRow();
1067
+ if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
1068
+ else {
1069
+ const sectionId = firstToggleableSectionId(detail);
1070
+ if (!sectionId) return;
1071
+ if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
1072
+ else expandedSections.add(sectionId);
977
1073
  requestRender();
978
1074
  }
979
- else if (deps.matchKey(data, "escape")) {
980
- unfocusMainList();
981
- close();
982
- }
983
- return;
984
1075
  }
985
- if (deps.matchKey(data, "up")) {
986
- overlayState.selected = Math.max(0, overlayState.selected - 1);
987
- clearCloseArm();
988
- requestRender();
989
- } else if (deps.matchKey(data, "down")) {
990
- overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), overlayState.selected + 1);
991
- clearCloseArm();
1076
+ else if (data === "l" || data === "L") {
1077
+ logTailRows = cycleLogTailRows(logTailRows);
1078
+ if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
992
1079
  requestRender();
993
- } else if (deps.matchKey(data, "enter")) {
994
- openDetail();
995
- } else if (deps.matchKey(data, "escape")) {
1080
+ }
1081
+ else if (deps.matchKey(data, "escape")) {
1082
+ unfocusMainList();
996
1083
  close();
997
1084
  }
998
1085
  },
1086
+ /** Show another row's detail in this overlay (the main list moved while it was unfocused). */
1087
+ showDetail(navigatorId: string) {
1088
+ if (closed) return;
1089
+ refreshRows();
1090
+ const idx = overlayState.rows.findIndex((row) => row.navigatorId === navigatorId);
1091
+ if (idx < 0) return;
1092
+ overlayState.selected = idx;
1093
+ state().mainListSelectedId = navigatorId;
1094
+ if (overlayState.rows[idx]!.parentRow) close();
1095
+ else openDetail();
1096
+ },
1097
+ /** Close the overlay (done), e.g. when the navigator is disposed. */
1098
+ dismiss() { close(); },
999
1099
  invalidate() { transcriptComponent?.invalidate(); },
1000
1100
  dispose() {
1001
1101
  clearCloseArm();
@@ -1004,6 +1104,30 @@ function createOverlayComponent(
1004
1104
  };
1005
1105
  }
1006
1106
 
1107
+ /**
1108
+ * After a confirmed close, the row whose detail the overlay shows next: the
1109
+ * nearest closable row below the closed one in the pre-close order, else the
1110
+ * nearest above it. `undefined` means nothing closable remains.
1111
+ */
1112
+ function neighbourCloseTarget(
1113
+ before: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
1114
+ closedIdx: number,
1115
+ closedId: string,
1116
+ after: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
1117
+ ): string | undefined {
1118
+ const remaining = new Set(after.filter((row) => !row.parentRow && row.navigatorId !== closedId).map((row) => row.navigatorId));
1119
+ for (let i = closedIdx + 1; i < before.length; i += 1) {
1120
+ const id = before[i]?.navigatorId;
1121
+ if (id && remaining.has(id)) return id;
1122
+ }
1123
+ for (let i = Math.min(closedIdx, before.length) - 1; i >= 0; i -= 1) {
1124
+ const id = before[i]?.navigatorId;
1125
+ if (id && remaining.has(id)) return id;
1126
+ }
1127
+ // Rows that appeared since the last render.
1128
+ return after.find((row) => remaining.has(row.navigatorId))?.navigatorId;
1129
+ }
1130
+
1007
1131
  function buildTranscriptDetailLines(
1008
1132
  detail: BackgroundWorkDetail,
1009
1133
  transcriptLines: string[],
@@ -1088,33 +1212,6 @@ function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
1088
1212
  };
1089
1213
  }
1090
1214
 
1091
- function buildListLines(nav: OverlayState, width: number, truncate: (s: string, width: number) => string, fg: (color: string, value: string) => string): string[] {
1092
- const lines: string[] = [];
1093
- lines.push(rule(`Work · ${nav.rows.length}`, width));
1094
- const selected = nav.rows[nav.selected];
1095
- const closeAction = selected ? "x close" : null;
1096
- lines.push(dim(` ${["↑↓ select", "Enter view", closeAction, "Esc close"].filter(Boolean).join(" · ")}`, fg));
1097
- lines.push("");
1098
- if (nav.rows.length === 0) lines.push(" (no work)");
1099
- let lastProvider = "";
1100
- for (let i = 0; i < nav.rows.length; i += 1) {
1101
- const row = nav.rows[i]!;
1102
- if (row.providerLabel !== lastProvider) {
1103
- lines.push(dim(section(row.providerLabel, width), fg));
1104
- lastProvider = row.providerLabel;
1105
- }
1106
- const prefix = i === nav.selected ? fg("accent", "› ") : " ";
1107
- const status = fg(toneColor(row.statusTone, row.status), row.status);
1108
- const facts = (row.facts ?? []).filter(Boolean).slice(0, 2);
1109
- const suffix = facts.length ? ` · ${facts.join(" · ")}` : "";
1110
- lines.push(`${prefix}${row.name || row.id} · ${row.kind} · ${row.elapsed} · ${row.primary} · ${status}${suffix}`);
1111
- if (row.secondary) lines.push(` ${row.secondary}`);
1112
- }
1113
- lines.push("");
1114
- lines.push(dim(rule("", width), fg));
1115
- return lines.map((line) => safeTruncate(line, width, truncate));
1116
- }
1117
-
1118
1215
  function buildDetailLines(
1119
1216
  detail: BackgroundWorkDetail | null,
1120
1217
  width: number,
@@ -1173,7 +1270,9 @@ function buildDetailLines(
1173
1270
  lines.push(` ${preview} ${folded}`);
1174
1271
  } else {
1175
1272
  const wrapped = wrapEvidenceText(body, width - 6);
1176
- const shown = wrapped.slice(0, tailRows);
1273
+ let shown = wrapped.slice(0, tailRows);
1274
+ // Same budget as the tail branch: the header row and the trailing blank come out of maxRows too.
1275
+ if (options.maxRows !== undefined) shown = shown.slice(0, Math.max(1, options.maxRows - lines.length - 2));
1177
1276
  lines.push(dim(section(`${detail.evidence.label} · showing ${shown.length}/${wrapped.length} rows`, width), fg));
1178
1277
  for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
1179
1278
  }