@oh-my-pi/pi-tui 17.3.4 → 17.3.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.5] - 2026-08-16
6
+
7
+ ### Fixed
8
+
9
+ - Fixed long CPU-bound event-loop stalls being misclassified as system sleep and omitted from loop-blocked diagnostics.
10
+ - Fixed focused components with markers falling back to full-screen redraws instead of direct row updates, preserving cursor position and native scrollback across marker changes.
11
+
5
12
  ## [17.3.4] - 2026-08-14
6
13
 
7
14
  ### Fixed
@@ -3,10 +3,12 @@ export interface LoopWatchdogOptions {
3
3
  intervalMs?: number;
4
4
  /** A tick later than this past its deadline counts as a block. Default 250. */
5
5
  thresholdMs?: number;
6
- /** Overshoot beyond this likely includes system sleep, so it is suppressed. Default 60_000. */
6
+ /** Overshoot beyond this is suppressed only when the process burned negligible CPU. Default 60_000. */
7
7
  sleepMs?: number;
8
8
  /** Monotonic clock source; injectable for tests. Default `performance.now`. */
9
9
  now?: () => number;
10
+ /** Process CPU time in ms; injectable for tests. Default `process.cpuUsage`. */
11
+ cpuNow?: () => number;
10
12
  /** Timer source; injectable for tests. Default `setTimeout`. */
11
13
  schedule?: (cb: () => void, ms: number) => LoopWatchdogTimer;
12
14
  }
@@ -30,10 +32,13 @@ interface LoopWatchdogTimer {
30
32
  * The handle is `unref`'d so the probe never keeps the process alive, and stop()
31
33
  * cancels the armed timer when the handle exposes `cancel` (the default
32
34
  * `setTimeout` handle does, via `clearTimeout`). The `#generation` guard remains
33
- * as a fallback for injected handles that cannot cancel. An overshoot beyond
34
- * `sleepMs` is treated as system sleep rather than a synchronous stall: the
35
- * process could not have run JS during the missed interval, and one resume
36
- * should not produce a multi-minute `ui.loop-blocked` record.
35
+ * as a fallback for injected handles that cannot cancel.
36
+ *
37
+ * A long overshoot is classified by CPU time rather than by duration. System
38
+ * sleep and a CPU-bound wedge both produce an arbitrarily large gap, so duration
39
+ * alone cannot tell them apart, and suppressing on duration discards exactly the
40
+ * worst stalls. Only a gap the process spent negligible CPU on is treated as
41
+ * sleep. CPU accounting is process-wide, so worker activity errs toward logging.
37
42
  */
38
43
  export declare class LoopWatchdog {
39
44
  #private;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.3.4",
4
+ "version": "17.3.7",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.3.4",
41
- "@oh-my-pi/pi-utils": "17.3.4"
40
+ "@oh-my-pi/pi-natives": "17.3.7",
41
+ "@oh-my-pi/pi-utils": "17.3.7"
42
42
  },
43
43
  "devDependencies": {
44
44
  "ghostty-web": "^0.4.0"
@@ -6,10 +6,12 @@ export interface LoopWatchdogOptions {
6
6
  intervalMs?: number;
7
7
  /** A tick later than this past its deadline counts as a block. Default 250. */
8
8
  thresholdMs?: number;
9
- /** Overshoot beyond this likely includes system sleep, so it is suppressed. Default 60_000. */
9
+ /** Overshoot beyond this is suppressed only when the process burned negligible CPU. Default 60_000. */
10
10
  sleepMs?: number;
11
11
  /** Monotonic clock source; injectable for tests. Default `performance.now`. */
12
12
  now?: () => number;
13
+ /** Process CPU time in ms; injectable for tests. Default `process.cpuUsage`. */
14
+ cpuNow?: () => number;
13
15
  /** Timer source; injectable for tests. Default `setTimeout`. */
14
16
  schedule?: (cb: () => void, ms: number) => LoopWatchdogTimer;
15
17
  }
@@ -23,6 +25,15 @@ interface LoopWatchdogTimer {
23
25
  cancel?(): void;
24
26
  }
25
27
 
28
+ /**
29
+ * Fraction of a missed interval that may be process CPU time while the gap is
30
+ * still treated as system sleep. Keep this near zero: cgroup throttling and
31
+ * scheduler contention can make a CPU-bound loop consume far less CPU than wall
32
+ * time. One percent allows a little measurement/background jitter while erring
33
+ * toward reporting a severe stall instead of hiding it.
34
+ */
35
+ const CPU_BUSY_RATIO = 0.01;
36
+
26
37
  /**
27
38
  * Always-on event-loop lag probe. Each tick is scheduled `intervalMs` ahead of
28
39
  * a recorded deadline; a tick that fires `thresholdMs` past its deadline means
@@ -35,18 +46,23 @@ interface LoopWatchdogTimer {
35
46
  * The handle is `unref`'d so the probe never keeps the process alive, and stop()
36
47
  * cancels the armed timer when the handle exposes `cancel` (the default
37
48
  * `setTimeout` handle does, via `clearTimeout`). The `#generation` guard remains
38
- * as a fallback for injected handles that cannot cancel. An overshoot beyond
39
- * `sleepMs` is treated as system sleep rather than a synchronous stall: the
40
- * process could not have run JS during the missed interval, and one resume
41
- * should not produce a multi-minute `ui.loop-blocked` record.
49
+ * as a fallback for injected handles that cannot cancel.
50
+ *
51
+ * A long overshoot is classified by CPU time rather than by duration. System
52
+ * sleep and a CPU-bound wedge both produce an arbitrarily large gap, so duration
53
+ * alone cannot tell them apart, and suppressing on duration discards exactly the
54
+ * worst stalls. Only a gap the process spent negligible CPU on is treated as
55
+ * sleep. CPU accounting is process-wide, so worker activity errs toward logging.
42
56
  */
43
57
  export class LoopWatchdog {
44
58
  #intervalMs: number;
45
59
  #thresholdMs: number;
46
60
  #sleepMs: number;
47
61
  #now: () => number;
62
+ #cpuNow: () => number;
48
63
  #schedule: (cb: () => void, ms: number) => LoopWatchdogTimer;
49
64
  #expected = 0;
65
+ #expectedCpu = 0;
50
66
  #wasBlocked = false;
51
67
  #running = false;
52
68
  // Bumped by stop(); each scheduled tick captures the generation it was armed
@@ -60,6 +76,12 @@ export class LoopWatchdog {
60
76
  this.#thresholdMs = options.thresholdMs ?? 250;
61
77
  this.#sleepMs = options.sleepMs ?? 60_000;
62
78
  this.#now = options.now ?? (() => performance.now());
79
+ this.#cpuNow =
80
+ options.cpuNow ??
81
+ (() => {
82
+ const usage = process.cpuUsage();
83
+ return (usage.user + usage.system) / 1000;
84
+ });
63
85
  this.#schedule =
64
86
  options.schedule ??
65
87
  ((cb, ms) => {
@@ -86,6 +108,7 @@ export class LoopWatchdog {
86
108
  #armTick(): void {
87
109
  const generation = this.#generation;
88
110
  this.#expected = this.#now() + this.#intervalMs;
111
+ this.#expectedCpu = this.#cpuNow();
89
112
  this.#handle = this.#schedule(() => this.#tick(generation), this.#intervalMs);
90
113
  this.#handle.unref?.();
91
114
  }
@@ -93,17 +116,20 @@ export class LoopWatchdog {
93
116
  #tick(generation: number): void {
94
117
  if (!this.#running || generation !== this.#generation) return;
95
118
  const blockedMs = this.#now() - this.#expected;
119
+ const cpuMs = this.#cpuNow() - this.#expectedCpu;
96
120
  // Consume the recent phase every tick (block or not) so attribution is
97
121
  // scoped to the just-elapsed interval and never carries a stale phase
98
122
  // forward to a later, phase-less block.
99
123
  const phase = takeRecentLoopPhase();
100
124
  if (blockedMs > this.#thresholdMs) {
101
- if (blockedMs > this.#sleepMs) {
125
+ if (blockedMs > this.#sleepMs && cpuMs < blockedMs * CPU_BUSY_RATIO) {
126
+ // A long gap the process did not spend CPU on: it was suspended.
102
127
  this.#wasBlocked = false;
103
128
  } else if (!this.#wasBlocked) {
104
129
  this.#wasBlocked = true;
105
130
  logger.warn("ui.loop-blocked", {
106
131
  blockedMs: Math.round(blockedMs),
132
+ cpuMs: Math.round(cpuMs),
107
133
  phase: phase ?? "unknown",
108
134
  });
109
135
  }
package/src/tui.ts CHANGED
@@ -1772,13 +1772,76 @@ export class TUI extends Container {
1772
1772
  markers.length = keep;
1773
1773
  }
1774
1774
 
1775
+ /**
1776
+ * Replace the cursor markers contributed by one fixed-size root segment.
1777
+ * The ledger stays sorted by absolute frame row; entries belonging to later
1778
+ * siblings move as one tail and otherwise retain their identity.
1779
+ */
1780
+ #replaceFrameCursorMarkers(startRow: number, lines: readonly string[]): void {
1781
+ const markers = this.#frameCursorMarkers;
1782
+ const endRow = startRow + lines.length;
1783
+ let intervalStart = 0;
1784
+ while (intervalStart < markers.length && markers[intervalStart]!.row < startRow) intervalStart++;
1785
+ let intervalEnd = intervalStart;
1786
+ while (intervalEnd < markers.length && markers[intervalEnd]!.row < endRow) intervalEnd++;
1787
+
1788
+ let nextCount = 0;
1789
+ for (let row = 0; row < lines.length; row++) {
1790
+ if (lines[row]!.includes(CURSOR_MARKER)) nextCount++;
1791
+ }
1792
+
1793
+ const previousCount = intervalEnd - intervalStart;
1794
+ const delta = nextCount - previousCount;
1795
+ const previousLength = markers.length;
1796
+ if (delta > 0) {
1797
+ markers.length = previousLength + delta;
1798
+ for (let index = previousLength - 1; index >= intervalEnd; index--) {
1799
+ markers[index + delta] = markers[index]!;
1800
+ }
1801
+ } else if (delta < 0) {
1802
+ for (let index = intervalEnd; index < previousLength; index++) {
1803
+ markers[index + delta] = markers[index]!;
1804
+ }
1805
+ markers.length = previousLength + delta;
1806
+ }
1807
+
1808
+ const reusableCount = Math.min(previousCount, nextCount);
1809
+ let markerSlot = intervalStart;
1810
+ for (let row = 0; row < lines.length; row++) {
1811
+ const line = lines[row]!;
1812
+ const markerIndex = line.indexOf(CURSOR_MARKER);
1813
+ if (markerIndex === -1) continue;
1814
+ const absoluteRow = startRow + row;
1815
+ const col = visibleWidth(line.slice(0, markerIndex));
1816
+ if (markerSlot < intervalStart + reusableCount) {
1817
+ const marker = markers[markerSlot]!;
1818
+ marker.row = absoluteRow;
1819
+ marker.col = col;
1820
+ } else {
1821
+ markers[markerSlot] = { row: absoluteRow, col };
1822
+ }
1823
+ markerSlot++;
1824
+ }
1825
+ }
1826
+
1827
+ /** Strip every internal cursor sentinel from one rendered row. */
1828
+ #stripCursorMarkers(line: string, markerIndex = line.indexOf(CURSOR_MARKER)): string {
1829
+ if (markerIndex === -1) return line;
1830
+ let stripped = line;
1831
+ while (markerIndex !== -1) {
1832
+ stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1833
+ markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1834
+ }
1835
+ return stripped;
1836
+ }
1837
+
1775
1838
  /**
1776
1839
  * Append one row to the composed frame, stripping CURSOR_MARKER occurrences
1777
1840
  * (internal sentinels that must never reach the terminal, the committed
1778
1841
  * prefix, or the resync audit) and recording the first marker's position.
1779
1842
  */
1780
1843
  #ingestFrameRow(line: string): void {
1781
- let markerIndex = line.indexOf(CURSOR_MARKER);
1844
+ const markerIndex = line.indexOf(CURSOR_MARKER);
1782
1845
  if (markerIndex === -1) {
1783
1846
  this.#composedFrame.push(line);
1784
1847
  return;
@@ -1787,12 +1850,7 @@ export class TUI extends Container {
1787
1850
  row: this.#composedFrame.length,
1788
1851
  col: visibleWidth(line.slice(0, markerIndex)),
1789
1852
  });
1790
- let stripped = line;
1791
- while (markerIndex !== -1) {
1792
- stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1793
- markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1794
- }
1795
- this.#composedFrame.push(stripped);
1853
+ this.#composedFrame.push(this.#stripCursorMarkers(line, markerIndex));
1796
1854
  }
1797
1855
 
1798
1856
  #syncTerminalCursorMode(component: Component | null): void {
@@ -2558,12 +2616,6 @@ export class TUI extends Container {
2558
2616
  this.requestComponentRender(component);
2559
2617
  return;
2560
2618
  }
2561
- for (const line of nextLines) {
2562
- if (line.includes(CURSOR_MARKER)) {
2563
- this.requestComponentRender(component);
2564
- return;
2565
- }
2566
- }
2567
2619
 
2568
2620
  let firstChanged = -1;
2569
2621
  let lastChanged = -1;
@@ -2571,8 +2623,9 @@ export class TUI extends Container {
2571
2623
  for (let i = 0; i < nextLines.length; i++) {
2572
2624
  const frameRow = segment.start + i;
2573
2625
  const raw = nextLines[i]!;
2574
- const prepared = this.#prepareLine(raw, width);
2575
- this.#composedFrame[frameRow] = raw;
2626
+ const composed = this.#stripCursorMarkers(raw);
2627
+ const prepared = this.#prepareLine(composed, width);
2628
+ this.#composedFrame[frameRow] = composed;
2576
2629
  this.#preparedMeta[frameRow] = prepared;
2577
2630
  this.#preparedFrame[frameRow] = prepared.line;
2578
2631
  if (previousWindow[screenStart + i] === prepared.line) continue;
@@ -2580,7 +2633,8 @@ export class TUI extends Container {
2580
2633
  if (firstChanged === -1) firstChanged = i;
2581
2634
  lastChanged = i;
2582
2635
  }
2583
- segments[segmentIndex] = { ...segment, lines: nextLines };
2636
+ this.#replaceFrameCursorMarkers(segment.start, nextLines);
2637
+ segment.lines = nextLines;
2584
2638
  this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
2585
2639
  this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
2586
2640