@nmakarov/cli-toolkit 0.79.0 → 0.81.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/dist/init.js CHANGED
@@ -1001,6 +1001,30 @@ var init_ui_elements = __esm({
1001
1001
  }
1002
1002
  });
1003
1003
 
1004
+ // src/screen/follow-scroll.js
1005
+ function clampScroll(scrollTop, maxScroll) {
1006
+ const max = Math.max(0, Number(maxScroll) || 0);
1007
+ const top = Number(scrollTop) || 0;
1008
+ return Math.min(Math.max(0, top), max);
1009
+ }
1010
+ function isScrolledToBottom(scrollTop, maxScroll) {
1011
+ return clampScroll(scrollTop, maxScroll) >= Math.max(0, Number(maxScroll) || 0);
1012
+ }
1013
+ function nextScrollAfterUserMove(scrollTop, maxScroll, delta) {
1014
+ const next = clampScroll((Number(scrollTop) || 0) + (Number(delta) || 0), maxScroll);
1015
+ return { scrollTop: next, following: isScrolledToBottom(next, maxScroll) };
1016
+ }
1017
+ function nextScrollAfterContentChange({ following, scrollTop, maxScroll }) {
1018
+ const max = Math.max(0, Number(maxScroll) || 0);
1019
+ if (following) return { scrollTop: max, following: true };
1020
+ const next = clampScroll(scrollTop, max);
1021
+ return { scrollTop: next, following: isScrolledToBottom(next, max) };
1022
+ }
1023
+ var init_follow_scroll = __esm({
1024
+ "src/screen/follow-scroll.js"() {
1025
+ }
1026
+ });
1027
+
1004
1028
  // src/screen/scrollable-text.js
1005
1029
  import { useState as useState3, useEffect as useEffect2, useMemo, useRef as useRef2, createElement as createElement2 } from "react";
1006
1030
  import { Box as Box4, Text as Text5 } from "ink";
@@ -1035,9 +1059,11 @@ function ScrollableText({
1035
1059
  showScrollbar = true,
1036
1060
  showStatus = true,
1037
1061
  bindKeys = true,
1038
- header = null
1062
+ header = null,
1063
+ followBottom = false
1039
1064
  }) {
1040
1065
  const [scrollTop, setScrollTop] = useState3(0);
1066
+ const [following, setFollowing] = useState3(() => !!followBottom);
1041
1067
  const [, bump] = useState3(0);
1042
1068
  const termRows = process.stdout.rows || 24;
1043
1069
  const viewportRows = Math.max(
@@ -1056,39 +1082,40 @@ function ScrollableText({
1056
1082
  const clamped = Math.min(Math.max(0, scrollTop), maxScroll);
1057
1083
  const visible = allLines.slice(clamped, clamped + viewportRows);
1058
1084
  const bar = needsBar ? scrollbarGlyphs(viewportRows, allLines.length, clamped) : null;
1059
- useEffect2(() => {
1060
- setScrollTop((s) => Math.min(s, maxScroll));
1061
- }, [maxScroll]);
1062
1085
  const maxScrollRef = useRef2(maxScroll);
1063
1086
  const pageSizeRef = useRef2(viewportRows);
1087
+ const scrollTopRef = useRef2(scrollTop);
1088
+ const followingRef = useRef2(following);
1064
1089
  maxScrollRef.current = maxScroll;
1065
1090
  pageSizeRef.current = viewportRows;
1091
+ scrollTopRef.current = scrollTop;
1092
+ followingRef.current = following;
1093
+ useEffect2(() => {
1094
+ const next = nextScrollAfterContentChange({
1095
+ following: followBottom && followingRef.current,
1096
+ scrollTop: scrollTopRef.current,
1097
+ maxScroll
1098
+ });
1099
+ if (next.scrollTop !== scrollTopRef.current) setScrollTop(next.scrollTop);
1100
+ if (followBottom && next.following !== followingRef.current) setFollowing(next.following);
1101
+ }, [maxScroll, followBottom]);
1102
+ const applyUserScroll = (delta) => {
1103
+ const next = nextScrollAfterUserMove(scrollTopRef.current, maxScrollRef.current, delta);
1104
+ setScrollTop(next.scrollTop);
1105
+ if (followBottom) setFollowing(next.following);
1106
+ bump((n) => n + 1);
1107
+ ctx?.update?.();
1108
+ };
1066
1109
  useEffect2(() => {
1067
1110
  if (!ctx || !bindKeys) return void 0;
1068
1111
  ctx.setKeyBinding(SCROLL_KEYS);
1069
- ctx.setAction("scrollUp", () => {
1070
- setScrollTop((s) => Math.max(0, s - 1));
1071
- bump((n) => n + 1);
1072
- ctx.update?.();
1073
- });
1074
- ctx.setAction("scrollDown", () => {
1075
- setScrollTop((s) => Math.min(maxScrollRef.current, s + 1));
1076
- bump((n) => n + 1);
1077
- ctx.update?.();
1078
- });
1079
- ctx.setAction("pageUp", () => {
1080
- setScrollTop((s) => Math.max(0, s - pageSizeRef.current));
1081
- bump((n) => n + 1);
1082
- ctx.update?.();
1083
- });
1084
- ctx.setAction("pageDown", () => {
1085
- setScrollTop((s) => Math.min(maxScrollRef.current, s + pageSizeRef.current));
1086
- bump((n) => n + 1);
1087
- ctx.update?.();
1088
- });
1112
+ ctx.setAction("scrollUp", () => applyUserScroll(-1));
1113
+ ctx.setAction("scrollDown", () => applyUserScroll(1));
1114
+ ctx.setAction("pageUp", () => applyUserScroll(-pageSizeRef.current));
1115
+ ctx.setAction("pageDown", () => applyUserScroll(pageSizeRef.current));
1089
1116
  return void 0;
1090
- }, [ctx, bindKeys]);
1091
- const status = allLines.length === 0 ? "empty" : `lines ${clamped + 1}-${Math.min(clamped + visible.length, allLines.length)} of ${allLines.length}` + (needsBar ? " \xB7 \u2325\u2191/\u2193 or PgUp/Dn page" : "");
1117
+ }, [ctx, bindKeys, followBottom]);
1118
+ const status = allLines.length === 0 ? "empty" : `lines ${clamped + 1}-${Math.min(clamped + visible.length, allLines.length)} of ${allLines.length}` + (needsBar ? " \xB7 \u2325\u2191/\u2193 or PgUp/Dn page" : "") + (followBottom && following ? " \xB7 follow" : followBottom ? " \xB7 follow off" : "");
1092
1119
  const rowNodes = visible.map((line, i) => {
1093
1120
  const body = padEndVisible(line, textWidth);
1094
1121
  const glyph = bar ? bar[i] ?? BAR_TRACK : showScrollbar ? " " : "";
@@ -1126,6 +1153,7 @@ var init_scrollable_text = __esm({
1126
1153
  "src/screen/scrollable-text.js"() {
1127
1154
  init_components();
1128
1155
  init_scrollbar();
1156
+ init_follow_scroll();
1129
1157
  init_scrollbar();
1130
1158
  h5 = createElement2;
1131
1159
  SCROLL_KEYS = [
@@ -1294,10 +1322,14 @@ __export(screen_exports, {
1294
1322
  buildBreadcrumb: () => buildBreadcrumb,
1295
1323
  buildDetailBreadcrumb: () => buildDetailBreadcrumb,
1296
1324
  buildFooter: () => buildFooter,
1325
+ clampScroll: () => clampScroll,
1297
1326
  formatBindingKey: () => formatBindingKey,
1298
1327
  h: () => createElement3,
1328
+ isScrolledToBottom: () => isScrolledToBottom,
1299
1329
  load: () => load,
1300
1330
  memo: () => memo,
1331
+ nextScrollAfterContentChange: () => nextScrollAfterContentChange,
1332
+ nextScrollAfterUserMove: () => nextScrollAfterUserMove,
1301
1333
  organizeFooterMessages: () => organizeFooterMessages,
1302
1334
  scrollbarGlyphs: () => scrollbarGlyphs,
1303
1335
  showListScreen: () => showListScreen,
@@ -1334,6 +1366,7 @@ var init_screen = __esm({
1334
1366
  init_components();
1335
1367
  init_ui_elements();
1336
1368
  init_scrollable_text();
1369
+ init_follow_scroll();
1337
1370
  init_scrollbar();
1338
1371
  init_key_bindings();
1339
1372
  init_utils();
@@ -2643,13 +2676,14 @@ var Logger = class _Logger {
2643
2676
  */
2644
2677
  progress(message, opts) {
2645
2678
  const { prefix, count, total } = opts;
2646
- const paddedTotal = String(total).length;
2679
+ const displayTotal = Math.max(Number(total) || 0, Number(count) || 0);
2680
+ const paddedTotal = String(displayTotal).length;
2647
2681
  const paddedCount = String(count).padStart(paddedTotal, " ");
2648
2682
  const payload = {
2649
2683
  level: "progress",
2650
2684
  message,
2651
2685
  count: paddedCount,
2652
- total,
2686
+ total: displayTotal,
2653
2687
  prefix
2654
2688
  };
2655
2689
  const key = prefix ?? "";
@@ -2666,7 +2700,7 @@ var Logger = class _Logger {
2666
2700
  if (wantTimes) {
2667
2701
  let remaining = -1;
2668
2702
  if (itemsPerSec > 0) {
2669
- remaining = (total - count) / itemsPerSec;
2703
+ remaining = Math.max(0, (displayTotal - count) / itemsPerSec);
2670
2704
  }
2671
2705
  payload.elapsed = this.round(elapsedSeconds, 2);
2672
2706
  payload.remaining = remaining >= 0 ? this.round(remaining, 2) : remaining;
@@ -2675,12 +2709,12 @@ var Logger = class _Logger {
2675
2709
  payload.rate = itemsPerSec >= 0 ? this.round(itemsPerSec, 2) : itemsPerSec;
2676
2710
  }
2677
2711
  }
2678
- if (count >= total) {
2712
+ if (count === total) {
2679
2713
  delete this.startTimes[key];
2680
2714
  delete this.startCounts[key];
2681
2715
  delete this.lastProgressTimes[key];
2682
2716
  }
2683
- if (this.shouldOutputProgress(prefix ?? "", count, total)) {
2717
+ if (this.shouldOutputProgress(prefix ?? "", count, displayTotal)) {
2684
2718
  this.out(payload);
2685
2719
  if (this.options.progressThrottle && prefix) {
2686
2720
  this.lastProgressTimes[prefix] = Date.now();
@@ -2839,6 +2873,8 @@ function setup(opts = {}) {
2839
2873
  // a quick "show me the figured params and quit" that skips the flow's
2840
2874
  // actual work. Like --stopAfter=init, this is a hard exit(0) (registered
2841
2875
  // cleanups are skipped); call it once components/params are resolved.
2876
+ _requestExitCode: null,
2877
+ requestExit: null,
2842
2878
  showUsedParamsIfNeeded: () => {
2843
2879
  const mode = params.getShowUsedParamsMode?.();
2844
2880
  if (mode !== "top" && mode !== "stop") return;
@@ -2849,6 +2885,9 @@ function setup(opts = {}) {
2849
2885
  }
2850
2886
  }
2851
2887
  };
2888
+ context.requestExit = (code = 0) => {
2889
+ context._requestExitCode = code;
2890
+ };
2852
2891
  logger.debug("[setup] completed successfully");
2853
2892
  return context;
2854
2893
  }
@@ -2884,9 +2923,25 @@ async function init(flow, opts = {}) {
2884
2923
  if (cleanupRan) return;
2885
2924
  cleanupRan = true;
2886
2925
  const fns = [...ctx.cleanupFunctions].reverse();
2926
+ const budgetMs = 5e3;
2927
+ const started = Date.now();
2887
2928
  for (const fn of fns) {
2929
+ const left = budgetMs - (Date.now() - started);
2930
+ if (left <= 0) {
2931
+ ctx.logger.warn("[cleanup] budget exhausted \u2014 skipping remaining cleanup");
2932
+ break;
2933
+ }
2888
2934
  try {
2889
- await fn(ctx);
2935
+ await Promise.race([
2936
+ Promise.resolve(fn(ctx)),
2937
+ new Promise((_, reject) => {
2938
+ const t = setTimeout(
2939
+ () => reject(new Error(`cleanup timed out after ${left}ms`)),
2940
+ left
2941
+ );
2942
+ t.unref?.();
2943
+ })
2944
+ ]);
2890
2945
  } catch (error) {
2891
2946
  ctx.logger.warn("[cleanup] error in cleanup function:", error);
2892
2947
  }
@@ -2982,6 +3037,8 @@ async function init(flow, opts = {}) {
2982
3037
  process.exit(process.exitCode);
2983
3038
  } else if (stop || kill) {
2984
3039
  process.exit(0);
3040
+ } else if (context._requestExitCode != null) {
3041
+ process.exit(context._requestExitCode);
2985
3042
  }
2986
3043
  }
2987
3044
  }