@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.cjs CHANGED
@@ -1018,6 +1018,30 @@ var init_ui_elements = __esm({
1018
1018
  }
1019
1019
  });
1020
1020
 
1021
+ // src/screen/follow-scroll.js
1022
+ function clampScroll(scrollTop, maxScroll) {
1023
+ const max = Math.max(0, Number(maxScroll) || 0);
1024
+ const top = Number(scrollTop) || 0;
1025
+ return Math.min(Math.max(0, top), max);
1026
+ }
1027
+ function isScrolledToBottom(scrollTop, maxScroll) {
1028
+ return clampScroll(scrollTop, maxScroll) >= Math.max(0, Number(maxScroll) || 0);
1029
+ }
1030
+ function nextScrollAfterUserMove(scrollTop, maxScroll, delta) {
1031
+ const next = clampScroll((Number(scrollTop) || 0) + (Number(delta) || 0), maxScroll);
1032
+ return { scrollTop: next, following: isScrolledToBottom(next, maxScroll) };
1033
+ }
1034
+ function nextScrollAfterContentChange({ following, scrollTop, maxScroll }) {
1035
+ const max = Math.max(0, Number(maxScroll) || 0);
1036
+ if (following) return { scrollTop: max, following: true };
1037
+ const next = clampScroll(scrollTop, max);
1038
+ return { scrollTop: next, following: isScrolledToBottom(next, max) };
1039
+ }
1040
+ var init_follow_scroll = __esm({
1041
+ "src/screen/follow-scroll.js"() {
1042
+ }
1043
+ });
1044
+
1021
1045
  // src/screen/scrollable-text.js
1022
1046
  function wrapTextLines(text, cols) {
1023
1047
  const w = Math.max(1, Math.floor(Number(cols) || 1));
@@ -1050,9 +1074,11 @@ function ScrollableText({
1050
1074
  showScrollbar = true,
1051
1075
  showStatus = true,
1052
1076
  bindKeys = true,
1053
- header = null
1077
+ header = null,
1078
+ followBottom = false
1054
1079
  }) {
1055
1080
  const [scrollTop, setScrollTop] = (0, import_react5.useState)(0);
1081
+ const [following, setFollowing] = (0, import_react5.useState)(() => !!followBottom);
1056
1082
  const [, bump] = (0, import_react5.useState)(0);
1057
1083
  const termRows = process.stdout.rows || 24;
1058
1084
  const viewportRows = Math.max(
@@ -1071,39 +1097,40 @@ function ScrollableText({
1071
1097
  const clamped = Math.min(Math.max(0, scrollTop), maxScroll);
1072
1098
  const visible = allLines.slice(clamped, clamped + viewportRows);
1073
1099
  const bar = needsBar ? scrollbarGlyphs(viewportRows, allLines.length, clamped) : null;
1074
- (0, import_react5.useEffect)(() => {
1075
- setScrollTop((s) => Math.min(s, maxScroll));
1076
- }, [maxScroll]);
1077
1100
  const maxScrollRef = (0, import_react5.useRef)(maxScroll);
1078
1101
  const pageSizeRef = (0, import_react5.useRef)(viewportRows);
1102
+ const scrollTopRef = (0, import_react5.useRef)(scrollTop);
1103
+ const followingRef = (0, import_react5.useRef)(following);
1079
1104
  maxScrollRef.current = maxScroll;
1080
1105
  pageSizeRef.current = viewportRows;
1106
+ scrollTopRef.current = scrollTop;
1107
+ followingRef.current = following;
1108
+ (0, import_react5.useEffect)(() => {
1109
+ const next = nextScrollAfterContentChange({
1110
+ following: followBottom && followingRef.current,
1111
+ scrollTop: scrollTopRef.current,
1112
+ maxScroll
1113
+ });
1114
+ if (next.scrollTop !== scrollTopRef.current) setScrollTop(next.scrollTop);
1115
+ if (followBottom && next.following !== followingRef.current) setFollowing(next.following);
1116
+ }, [maxScroll, followBottom]);
1117
+ const applyUserScroll = (delta) => {
1118
+ const next = nextScrollAfterUserMove(scrollTopRef.current, maxScrollRef.current, delta);
1119
+ setScrollTop(next.scrollTop);
1120
+ if (followBottom) setFollowing(next.following);
1121
+ bump((n) => n + 1);
1122
+ ctx?.update?.();
1123
+ };
1081
1124
  (0, import_react5.useEffect)(() => {
1082
1125
  if (!ctx || !bindKeys) return void 0;
1083
1126
  ctx.setKeyBinding(SCROLL_KEYS);
1084
- ctx.setAction("scrollUp", () => {
1085
- setScrollTop((s) => Math.max(0, s - 1));
1086
- bump((n) => n + 1);
1087
- ctx.update?.();
1088
- });
1089
- ctx.setAction("scrollDown", () => {
1090
- setScrollTop((s) => Math.min(maxScrollRef.current, s + 1));
1091
- bump((n) => n + 1);
1092
- ctx.update?.();
1093
- });
1094
- ctx.setAction("pageUp", () => {
1095
- setScrollTop((s) => Math.max(0, s - pageSizeRef.current));
1096
- bump((n) => n + 1);
1097
- ctx.update?.();
1098
- });
1099
- ctx.setAction("pageDown", () => {
1100
- setScrollTop((s) => Math.min(maxScrollRef.current, s + pageSizeRef.current));
1101
- bump((n) => n + 1);
1102
- ctx.update?.();
1103
- });
1127
+ ctx.setAction("scrollUp", () => applyUserScroll(-1));
1128
+ ctx.setAction("scrollDown", () => applyUserScroll(1));
1129
+ ctx.setAction("pageUp", () => applyUserScroll(-pageSizeRef.current));
1130
+ ctx.setAction("pageDown", () => applyUserScroll(pageSizeRef.current));
1104
1131
  return void 0;
1105
- }, [ctx, bindKeys]);
1106
- 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" : "");
1132
+ }, [ctx, bindKeys, followBottom]);
1133
+ 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" : "");
1107
1134
  const rowNodes = visible.map((line, i) => {
1108
1135
  const body = padEndVisible(line, textWidth);
1109
1136
  const glyph = bar ? bar[i] ?? BAR_TRACK : showScrollbar ? " " : "";
@@ -1143,6 +1170,7 @@ var init_scrollable_text = __esm({
1143
1170
  import_ink5 = require("ink");
1144
1171
  init_components();
1145
1172
  init_scrollbar();
1173
+ init_follow_scroll();
1146
1174
  init_scrollbar();
1147
1175
  h5 = import_react5.createElement;
1148
1176
  SCROLL_KEYS = [
@@ -1311,10 +1339,14 @@ __export(screen_exports, {
1311
1339
  buildBreadcrumb: () => buildBreadcrumb,
1312
1340
  buildDetailBreadcrumb: () => buildDetailBreadcrumb,
1313
1341
  buildFooter: () => buildFooter,
1342
+ clampScroll: () => clampScroll,
1314
1343
  formatBindingKey: () => formatBindingKey,
1315
1344
  h: () => import_react6.createElement,
1345
+ isScrolledToBottom: () => isScrolledToBottom,
1316
1346
  load: () => load,
1317
1347
  memo: () => import_react6.memo,
1348
+ nextScrollAfterContentChange: () => nextScrollAfterContentChange,
1349
+ nextScrollAfterUserMove: () => nextScrollAfterUserMove,
1318
1350
  organizeFooterMessages: () => organizeFooterMessages,
1319
1351
  scrollbarGlyphs: () => scrollbarGlyphs,
1320
1352
  showListScreen: () => showListScreen,
@@ -1351,6 +1383,7 @@ var init_screen = __esm({
1351
1383
  init_components();
1352
1384
  init_ui_elements();
1353
1385
  init_scrollable_text();
1386
+ init_follow_scroll();
1354
1387
  init_scrollbar();
1355
1388
  init_key_bindings();
1356
1389
  init_utils();
@@ -2668,13 +2701,14 @@ var Logger = class _Logger {
2668
2701
  */
2669
2702
  progress(message, opts) {
2670
2703
  const { prefix, count, total } = opts;
2671
- const paddedTotal = String(total).length;
2704
+ const displayTotal = Math.max(Number(total) || 0, Number(count) || 0);
2705
+ const paddedTotal = String(displayTotal).length;
2672
2706
  const paddedCount = String(count).padStart(paddedTotal, " ");
2673
2707
  const payload = {
2674
2708
  level: "progress",
2675
2709
  message,
2676
2710
  count: paddedCount,
2677
- total,
2711
+ total: displayTotal,
2678
2712
  prefix
2679
2713
  };
2680
2714
  const key = prefix ?? "";
@@ -2691,7 +2725,7 @@ var Logger = class _Logger {
2691
2725
  if (wantTimes) {
2692
2726
  let remaining = -1;
2693
2727
  if (itemsPerSec > 0) {
2694
- remaining = (total - count) / itemsPerSec;
2728
+ remaining = Math.max(0, (displayTotal - count) / itemsPerSec);
2695
2729
  }
2696
2730
  payload.elapsed = this.round(elapsedSeconds, 2);
2697
2731
  payload.remaining = remaining >= 0 ? this.round(remaining, 2) : remaining;
@@ -2700,12 +2734,12 @@ var Logger = class _Logger {
2700
2734
  payload.rate = itemsPerSec >= 0 ? this.round(itemsPerSec, 2) : itemsPerSec;
2701
2735
  }
2702
2736
  }
2703
- if (count >= total) {
2737
+ if (count === total) {
2704
2738
  delete this.startTimes[key];
2705
2739
  delete this.startCounts[key];
2706
2740
  delete this.lastProgressTimes[key];
2707
2741
  }
2708
- if (this.shouldOutputProgress(prefix ?? "", count, total)) {
2742
+ if (this.shouldOutputProgress(prefix ?? "", count, displayTotal)) {
2709
2743
  this.out(payload);
2710
2744
  if (this.options.progressThrottle && prefix) {
2711
2745
  this.lastProgressTimes[prefix] = Date.now();
@@ -2864,6 +2898,8 @@ function setup(opts = {}) {
2864
2898
  // a quick "show me the figured params and quit" that skips the flow's
2865
2899
  // actual work. Like --stopAfter=init, this is a hard exit(0) (registered
2866
2900
  // cleanups are skipped); call it once components/params are resolved.
2901
+ _requestExitCode: null,
2902
+ requestExit: null,
2867
2903
  showUsedParamsIfNeeded: () => {
2868
2904
  const mode = params.getShowUsedParamsMode?.();
2869
2905
  if (mode !== "top" && mode !== "stop") return;
@@ -2874,6 +2910,9 @@ function setup(opts = {}) {
2874
2910
  }
2875
2911
  }
2876
2912
  };
2913
+ context.requestExit = (code = 0) => {
2914
+ context._requestExitCode = code;
2915
+ };
2877
2916
  logger.debug("[setup] completed successfully");
2878
2917
  return context;
2879
2918
  }
@@ -2909,9 +2948,25 @@ async function init(flow, opts = {}) {
2909
2948
  if (cleanupRan) return;
2910
2949
  cleanupRan = true;
2911
2950
  const fns = [...ctx.cleanupFunctions].reverse();
2951
+ const budgetMs = 5e3;
2952
+ const started = Date.now();
2912
2953
  for (const fn of fns) {
2954
+ const left = budgetMs - (Date.now() - started);
2955
+ if (left <= 0) {
2956
+ ctx.logger.warn("[cleanup] budget exhausted \u2014 skipping remaining cleanup");
2957
+ break;
2958
+ }
2913
2959
  try {
2914
- await fn(ctx);
2960
+ await Promise.race([
2961
+ Promise.resolve(fn(ctx)),
2962
+ new Promise((_, reject) => {
2963
+ const t = setTimeout(
2964
+ () => reject(new Error(`cleanup timed out after ${left}ms`)),
2965
+ left
2966
+ );
2967
+ t.unref?.();
2968
+ })
2969
+ ]);
2915
2970
  } catch (error) {
2916
2971
  ctx.logger.warn("[cleanup] error in cleanup function:", error);
2917
2972
  }
@@ -3007,6 +3062,8 @@ async function init(flow, opts = {}) {
3007
3062
  process.exit(process.exitCode);
3008
3063
  } else if (stop || kill) {
3009
3064
  process.exit(0);
3065
+ } else if (context._requestExitCode != null) {
3066
+ process.exit(context._requestExitCode);
3010
3067
  }
3011
3068
  }
3012
3069
  }