@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 +7 -0
- package/dist/types/loop-watchdog.d.ts +10 -5
- package/package.json +3 -3
- package/src/loop-watchdog.ts +32 -6
- package/src/tui.ts +70 -16
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
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
+
"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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.3.
|
|
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"
|
package/src/loop-watchdog.ts
CHANGED
|
@@ -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
|
|
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.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2575
|
-
this.#
|
|
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
|
-
|
|
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
|
|