@gajae-code/tui 0.3.0 → 0.3.2
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 +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/metrics.d.ts +78 -0
- package/dist/types/tui.d.ts +1 -1
- package/package.json +4 -3
- package/src/components/loader.ts +22 -9
- package/src/index.ts +2 -0
- package/src/metrics.ts +355 -0
- package/src/terminal.ts +4 -0
- package/src/tui.ts +23 -8
- package/src/utils.ts +25 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.1] - 2026-06-05
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Added replay-gate helper timing for text-layout utilities so Stage 5 native offload decisions are benchmark-gated by measured hot paths rather than speculative rewrites.
|
|
10
|
+
|
|
5
11
|
## [0.2.2] - 2026-05-31
|
|
6
12
|
|
|
7
13
|
### Changed
|
package/dist/types/index.d.ts
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Number of consecutive unexpected full redraws that constitute a "storm". */
|
|
2
|
+
export declare const REPAINT_STORM_THRESHOLD = 3;
|
|
3
|
+
/** Hard cap on retained metric label keys; overflow is aggregated under `other`. */
|
|
4
|
+
export declare const MAX_LABEL_MAP_ENTRIES = 128;
|
|
5
|
+
export interface DurationStats {
|
|
6
|
+
count: number;
|
|
7
|
+
meanMs: number;
|
|
8
|
+
p50Ms: number;
|
|
9
|
+
p95Ms: number;
|
|
10
|
+
p99Ms: number;
|
|
11
|
+
maxMs: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RssStats {
|
|
14
|
+
samples: number;
|
|
15
|
+
baselineBytes: number | null;
|
|
16
|
+
lastBytes: number | null;
|
|
17
|
+
peakBytes: number;
|
|
18
|
+
growthBytes: number;
|
|
19
|
+
/** RSS sampled after the run + a forced GC (informational). */
|
|
20
|
+
returnBytes: number | null;
|
|
21
|
+
/** Heap used at baseline and after the run + forced GC (reclaimable signal). */
|
|
22
|
+
heapBaselineBytes: number | null;
|
|
23
|
+
heapReturnBytes: number | null;
|
|
24
|
+
/** (heapReturn - heapBaseline) / heapBaseline; <= tolerance means heap returned. */
|
|
25
|
+
returnWithinBaselineFraction: number | null;
|
|
26
|
+
}
|
|
27
|
+
export interface HelperStat {
|
|
28
|
+
count: number;
|
|
29
|
+
totalMs: number;
|
|
30
|
+
meanMs: number;
|
|
31
|
+
}
|
|
32
|
+
export interface RenderMetricsSnapshot {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
renderCount: number;
|
|
35
|
+
renderDurations: DurationStats;
|
|
36
|
+
durationsTruncated: boolean;
|
|
37
|
+
requestSources: Record<string, number>;
|
|
38
|
+
fullRedrawCount: number;
|
|
39
|
+
fullRedrawCauses: Record<string, number>;
|
|
40
|
+
repaintStorms: number;
|
|
41
|
+
maxConsecutiveFullRedraws: number;
|
|
42
|
+
rss: RssStats;
|
|
43
|
+
ownerGauges: Record<string, number>;
|
|
44
|
+
timerGauges: Record<string, number>;
|
|
45
|
+
helperStats: Record<string, HelperStat>;
|
|
46
|
+
}
|
|
47
|
+
export declare class RenderMetrics {
|
|
48
|
+
#private;
|
|
49
|
+
constructor(enabled?: boolean);
|
|
50
|
+
get enabled(): boolean;
|
|
51
|
+
enable(): void;
|
|
52
|
+
disable(): void;
|
|
53
|
+
/** Reset all collected data (keeps the enabled state). */
|
|
54
|
+
reset(): void;
|
|
55
|
+
/** High-resolution clock for timing render passes. Returns 0 when disabled. */
|
|
56
|
+
now(): number;
|
|
57
|
+
/** Record that a render was requested, attributed to a caller source. */
|
|
58
|
+
recordRequest(source?: string): void;
|
|
59
|
+
/** Record one completed `#doRender` pass duration (ms). */
|
|
60
|
+
recordRender(durationMs: number): void;
|
|
61
|
+
/** Record a full-redraw event and classify its cause for storm detection. */
|
|
62
|
+
recordFullRedraw(cause: string): void;
|
|
63
|
+
/** Sample current RSS. Records baseline on first sample, tracks peak/last. */
|
|
64
|
+
sampleRss(): number;
|
|
65
|
+
setOwnerGauge(name: string, value: number): void;
|
|
66
|
+
setTimerGauge(name: string, value: number): void;
|
|
67
|
+
/** Accumulate timing/count for a named render helper (e.g. "renderTree"). */
|
|
68
|
+
recordHelper(name: string, durationMs: number): void;
|
|
69
|
+
/**
|
|
70
|
+
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
71
|
+
* "return" value used by the memory-leak gate. Callers should drop large
|
|
72
|
+
* references before calling so reclaimable memory is actually freed.
|
|
73
|
+
*/
|
|
74
|
+
sampleReturn(): number;
|
|
75
|
+
snapshot(): RenderMetricsSnapshot;
|
|
76
|
+
}
|
|
77
|
+
/** Shared metrics instance used by the TUI render loop. */
|
|
78
|
+
export declare const renderMetrics: RenderMetrics;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -158,5 +158,5 @@ export declare class TUI extends Container {
|
|
|
158
158
|
addInputListener(listener: InputListener): () => void;
|
|
159
159
|
removeInputListener(listener: InputListener): void;
|
|
160
160
|
stop(): void;
|
|
161
|
-
requestRender(force?: boolean): void;
|
|
161
|
+
requestRender(force?: boolean, source?: string): void;
|
|
162
162
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -33,12 +33,13 @@
|
|
|
33
33
|
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
34
34
|
"lint": "biome lint .",
|
|
35
35
|
"test": "bun test test/*.test.ts",
|
|
36
|
+
"test:perf": "PI_TUI_PERF_GATES=1 bun test test/perf-gates.test.ts",
|
|
36
37
|
"fix": "biome check --write --unsafe .",
|
|
37
38
|
"fmt": "biome format --write ."
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
|
-
"@gajae-code/natives": "0.3.
|
|
41
|
-
"@gajae-code/utils": "0.3.
|
|
41
|
+
"@gajae-code/natives": "0.3.2",
|
|
42
|
+
"@gajae-code/utils": "0.3.2",
|
|
42
43
|
"lru-cache": "11.3.6",
|
|
43
44
|
"marked": "^18.0.3"
|
|
44
45
|
},
|
package/src/components/loader.ts
CHANGED
|
@@ -7,13 +7,17 @@ import { Text } from "./text";
|
|
|
7
7
|
* message colorizer is time-dependent (e.g. shimmer/KITT) animate smoothly.
|
|
8
8
|
*
|
|
9
9
|
* Two cadences are interleaved on a single timer:
|
|
10
|
-
* - **
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* - **Recompute tick** (every `RENDER_INTERVAL_MS`) → recomposes the spinner +
|
|
11
|
+
* colorized message every 16ms. A redraw is requested only when that composed
|
|
12
|
+
* text actually changed since the last tick (`#lastDisplayed`), so animated
|
|
13
|
+
* colorizers (shimmer/KITT) and spinner-frame advances still repaint, while
|
|
14
|
+
* static loaders skip the redundant no-op render requests between advances.
|
|
14
15
|
* - **Spinner advance** (every `SPINNER_ADVANCE_MS`) → bumps the spinner
|
|
15
|
-
* frame index. Decoupled from the
|
|
16
|
+
* frame index. Decoupled from the recompute cadence so the spinner keeps
|
|
16
17
|
* its classic ~12.5fps step pace regardless of shimmer state.
|
|
18
|
+
*
|
|
19
|
+
* The animation timer is `unref`'d so an active loader never keeps the event
|
|
20
|
+
* loop alive on its own.
|
|
17
21
|
*/
|
|
18
22
|
const RENDER_INTERVAL_MS = 16;
|
|
19
23
|
const SPINNER_ADVANCE_MS = 80;
|
|
@@ -24,6 +28,7 @@ export class Loader extends Text {
|
|
|
24
28
|
#intervalId?: NodeJS.Timeout;
|
|
25
29
|
#ui: TUI | null = null;
|
|
26
30
|
#lastSpinnerTick = 0;
|
|
31
|
+
#lastDisplayed?: string;
|
|
27
32
|
|
|
28
33
|
constructor(
|
|
29
34
|
ui: TUI,
|
|
@@ -62,6 +67,8 @@ export class Loader extends Text {
|
|
|
62
67
|
}
|
|
63
68
|
this.#updateDisplay();
|
|
64
69
|
}, RENDER_INTERVAL_MS);
|
|
70
|
+
// Don't let the animation timer keep the event loop alive on its own.
|
|
71
|
+
this.#intervalId?.unref?.();
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
stop() {
|
|
@@ -78,9 +85,15 @@ export class Loader extends Text {
|
|
|
78
85
|
|
|
79
86
|
#updateDisplay() {
|
|
80
87
|
const frame = this.#frames[this.#currentFrame];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
const next = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
|
|
89
|
+
// Only touch the component and ask the TUI to repaint when the rendered
|
|
90
|
+
// text actually changed. Time-dependent colorizers (shimmer/KITT) produce
|
|
91
|
+
// new text every tick and still animate; static loaders skip the ~16ms
|
|
92
|
+
// no-op render requests between 80ms spinner advances. Output is unchanged
|
|
93
|
+
// because a suppressed frame would have produced a no-op write anyway.
|
|
94
|
+
if (next === this.#lastDisplayed) return;
|
|
95
|
+
this.#lastDisplayed = next;
|
|
96
|
+
this.setText(next);
|
|
97
|
+
this.#ui?.requestRender(false, "loader");
|
|
85
98
|
}
|
|
86
99
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ export * from "./fuzzy";
|
|
|
24
24
|
export * from "./keybindings";
|
|
25
25
|
// Kitty keyboard protocol helpers
|
|
26
26
|
export * from "./keys";
|
|
27
|
+
// Renderer/runtime observability metrics (opt-in)
|
|
28
|
+
export * from "./metrics";
|
|
27
29
|
// Mermaid diagram support
|
|
28
30
|
// Input buffering for batch splitting
|
|
29
31
|
export * from "./stdin-buffer";
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in renderer/runtime observability counters for the TUI.
|
|
3
|
+
*
|
|
4
|
+
* This module is the Stage 1 "observability foundation" surface. It is OFF by
|
|
5
|
+
* default and only collects data when explicitly enabled, either via the
|
|
6
|
+
* `PI_TUI_METRICS` environment flag or programmatically (used by the replay
|
|
7
|
+
* harness and tests). When disabled, every record call is a single boolean
|
|
8
|
+
* check at the call site, so default runtime overhead is negligible and no
|
|
9
|
+
* existing render behavior changes.
|
|
10
|
+
*
|
|
11
|
+
* It tracks:
|
|
12
|
+
* - `#doRender` durations (p50/p95/p99/max/mean) — frame-time histogram.
|
|
13
|
+
* - `requestRender` source attribution — which callers ask for renders.
|
|
14
|
+
* - Full-redraw cause classification — why a frame fell back to full repaint.
|
|
15
|
+
* - Repaint-storm detection — runs of consecutive unexpected full redraws.
|
|
16
|
+
* - RSS samples — baseline/peak/last for memory-growth gates.
|
|
17
|
+
* - Owner/timer gauges — long-lived resource counts for leak gates.
|
|
18
|
+
*/
|
|
19
|
+
import { performance } from "node:perf_hooks";
|
|
20
|
+
import { $flag } from "@gajae-code/utils";
|
|
21
|
+
|
|
22
|
+
/** Number of consecutive unexpected full redraws that constitute a "storm". */
|
|
23
|
+
export const REPAINT_STORM_THRESHOLD = 3;
|
|
24
|
+
|
|
25
|
+
/** Hard cap on retained render-duration samples to keep memory bounded. */
|
|
26
|
+
const MAX_DURATION_SAMPLES = 200_000;
|
|
27
|
+
|
|
28
|
+
/** Hard cap on retained metric label keys; overflow is aggregated under `other`. */
|
|
29
|
+
export const MAX_LABEL_MAP_ENTRIES = 128;
|
|
30
|
+
|
|
31
|
+
const LABEL_OVERFLOW_KEY = "other";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize full-redraw causes before retaining them as metric labels. Some
|
|
35
|
+
* render paths include dimensions in debug-facing reason strings; metrics keep
|
|
36
|
+
* the stable cause class so resize/delete storms cannot create unbounded label
|
|
37
|
+
* cardinality.
|
|
38
|
+
*/
|
|
39
|
+
function normalizeFullRedrawCause(cause: string): string {
|
|
40
|
+
const c = cause.toLowerCase();
|
|
41
|
+
if (c.startsWith("first render")) return "first render";
|
|
42
|
+
if (c.startsWith("terminal width changed")) return "terminal width changed";
|
|
43
|
+
if (c.startsWith("terminal height changed")) return "terminal height changed";
|
|
44
|
+
if (c.startsWith("clearonshrink")) return "clearOnShrink";
|
|
45
|
+
if (c.startsWith("extralines > height")) return "extraLines > height";
|
|
46
|
+
if (c.startsWith("firstchanged < viewporttop")) return "firstChanged < viewportTop";
|
|
47
|
+
return cause;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Full-redraw causes that are expected and do not count toward repaint storms.
|
|
52
|
+
* These are legitimate, unavoidable full repaints (first frame, resize, shrink
|
|
53
|
+
* clearing). Steady-stream storms come from any other repeated full redraw.
|
|
54
|
+
*/
|
|
55
|
+
function isExpectedFullRedraw(cause: string): boolean {
|
|
56
|
+
const c = cause.toLowerCase();
|
|
57
|
+
return (
|
|
58
|
+
c.startsWith("first render") ||
|
|
59
|
+
c.includes("width changed") ||
|
|
60
|
+
c.includes("height changed") ||
|
|
61
|
+
c.startsWith("clearonshrink") ||
|
|
62
|
+
c.includes("forced") ||
|
|
63
|
+
c.includes("force")
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function retainedLabel<T>(map: Map<string, T>, label: string): string {
|
|
68
|
+
if (map.has(label) || label === LABEL_OVERFLOW_KEY) return label;
|
|
69
|
+
return map.size < MAX_LABEL_MAP_ENTRIES - 1 ? label : LABEL_OVERFLOW_KEY;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function incrementCount(map: Map<string, number>, label: string): void {
|
|
73
|
+
const retained = retainedLabel(map, label);
|
|
74
|
+
map.set(retained, (map.get(retained) ?? 0) + 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DurationStats {
|
|
78
|
+
count: number;
|
|
79
|
+
meanMs: number;
|
|
80
|
+
p50Ms: number;
|
|
81
|
+
p95Ms: number;
|
|
82
|
+
p99Ms: number;
|
|
83
|
+
maxMs: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RssStats {
|
|
87
|
+
samples: number;
|
|
88
|
+
baselineBytes: number | null;
|
|
89
|
+
lastBytes: number | null;
|
|
90
|
+
peakBytes: number;
|
|
91
|
+
growthBytes: number;
|
|
92
|
+
/** RSS sampled after the run + a forced GC (informational). */
|
|
93
|
+
returnBytes: number | null;
|
|
94
|
+
/** Heap used at baseline and after the run + forced GC (reclaimable signal). */
|
|
95
|
+
heapBaselineBytes: number | null;
|
|
96
|
+
heapReturnBytes: number | null;
|
|
97
|
+
/** (heapReturn - heapBaseline) / heapBaseline; <= tolerance means heap returned. */
|
|
98
|
+
returnWithinBaselineFraction: number | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface HelperStat {
|
|
102
|
+
count: number;
|
|
103
|
+
totalMs: number;
|
|
104
|
+
meanMs: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RenderMetricsSnapshot {
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
renderCount: number;
|
|
110
|
+
renderDurations: DurationStats;
|
|
111
|
+
durationsTruncated: boolean;
|
|
112
|
+
requestSources: Record<string, number>;
|
|
113
|
+
fullRedrawCount: number;
|
|
114
|
+
fullRedrawCauses: Record<string, number>;
|
|
115
|
+
repaintStorms: number;
|
|
116
|
+
maxConsecutiveFullRedraws: number;
|
|
117
|
+
rss: RssStats;
|
|
118
|
+
ownerGauges: Record<string, number>;
|
|
119
|
+
timerGauges: Record<string, number>;
|
|
120
|
+
helperStats: Record<string, HelperStat>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function emptyDurationStats(): DurationStats {
|
|
124
|
+
return { count: 0, meanMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function percentile(sorted: number[], p: number): number {
|
|
128
|
+
if (sorted.length === 0) return 0;
|
|
129
|
+
const rank = (p / 100) * (sorted.length - 1);
|
|
130
|
+
const lo = Math.floor(rank);
|
|
131
|
+
const hi = Math.ceil(rank);
|
|
132
|
+
if (lo === hi) return sorted[lo];
|
|
133
|
+
const frac = rank - lo;
|
|
134
|
+
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class RenderMetrics {
|
|
138
|
+
#enabled: boolean;
|
|
139
|
+
#renderCount = 0;
|
|
140
|
+
#durations: number[] = [];
|
|
141
|
+
#durationsTruncated = false;
|
|
142
|
+
#requestSources = new Map<string, number>();
|
|
143
|
+
#fullRedrawCount = 0;
|
|
144
|
+
#fullRedrawCauses = new Map<string, number>();
|
|
145
|
+
#pendingUnexpectedFullRedraw = false;
|
|
146
|
+
#consecutiveFullRedraws = 0;
|
|
147
|
+
#maxConsecutiveFullRedraws = 0;
|
|
148
|
+
#repaintStorms = 0;
|
|
149
|
+
#rssSamples = 0;
|
|
150
|
+
#rssBaseline: number | null = null;
|
|
151
|
+
#rssLast: number | null = null;
|
|
152
|
+
#rssPeak = 0;
|
|
153
|
+
#ownerGauges = new Map<string, number>();
|
|
154
|
+
#timerGauges = new Map<string, number>();
|
|
155
|
+
#helpers = new Map<string, { count: number; totalMs: number }>();
|
|
156
|
+
#rssReturn: number | null = null;
|
|
157
|
+
#heapBaseline: number | null = null;
|
|
158
|
+
#heapReturn: number | null = null;
|
|
159
|
+
|
|
160
|
+
constructor(enabled = $flag("PI_TUI_METRICS")) {
|
|
161
|
+
this.#enabled = enabled;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get enabled(): boolean {
|
|
165
|
+
return this.#enabled;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
enable(): void {
|
|
169
|
+
this.#enabled = true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
disable(): void {
|
|
173
|
+
this.#enabled = false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Reset all collected data (keeps the enabled state). */
|
|
177
|
+
reset(): void {
|
|
178
|
+
this.#renderCount = 0;
|
|
179
|
+
this.#durations = [];
|
|
180
|
+
this.#durationsTruncated = false;
|
|
181
|
+
this.#requestSources.clear();
|
|
182
|
+
this.#fullRedrawCount = 0;
|
|
183
|
+
this.#fullRedrawCauses.clear();
|
|
184
|
+
this.#pendingUnexpectedFullRedraw = false;
|
|
185
|
+
this.#consecutiveFullRedraws = 0;
|
|
186
|
+
this.#maxConsecutiveFullRedraws = 0;
|
|
187
|
+
this.#repaintStorms = 0;
|
|
188
|
+
this.#rssSamples = 0;
|
|
189
|
+
this.#rssBaseline = null;
|
|
190
|
+
this.#rssLast = null;
|
|
191
|
+
this.#rssPeak = 0;
|
|
192
|
+
this.#ownerGauges.clear();
|
|
193
|
+
this.#timerGauges.clear();
|
|
194
|
+
this.#helpers.clear();
|
|
195
|
+
this.#rssReturn = null;
|
|
196
|
+
this.#heapBaseline = null;
|
|
197
|
+
this.#heapReturn = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** High-resolution clock for timing render passes. Returns 0 when disabled. */
|
|
201
|
+
now(): number {
|
|
202
|
+
return this.#enabled ? performance.now() : 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Record that a render was requested, attributed to a caller source. */
|
|
206
|
+
recordRequest(source = "unknown"): void {
|
|
207
|
+
if (!this.#enabled) return;
|
|
208
|
+
incrementCount(this.#requestSources, source);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Record one completed `#doRender` pass duration (ms). */
|
|
212
|
+
recordRender(durationMs: number): void {
|
|
213
|
+
if (!this.#enabled) return;
|
|
214
|
+
this.#renderCount += 1;
|
|
215
|
+
if (this.#durations.length < MAX_DURATION_SAMPLES) {
|
|
216
|
+
this.#durations.push(durationMs);
|
|
217
|
+
} else {
|
|
218
|
+
this.#durationsTruncated = true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Storm bookkeeping: a render that performed an unexpected full redraw
|
|
222
|
+
// extends the current run; any other render breaks it.
|
|
223
|
+
if (this.#pendingUnexpectedFullRedraw) {
|
|
224
|
+
this.#consecutiveFullRedraws += 1;
|
|
225
|
+
if (this.#consecutiveFullRedraws > this.#maxConsecutiveFullRedraws) {
|
|
226
|
+
this.#maxConsecutiveFullRedraws = this.#consecutiveFullRedraws;
|
|
227
|
+
}
|
|
228
|
+
if (this.#consecutiveFullRedraws === REPAINT_STORM_THRESHOLD) {
|
|
229
|
+
this.#repaintStorms += 1;
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
this.#consecutiveFullRedraws = 0;
|
|
233
|
+
}
|
|
234
|
+
this.#pendingUnexpectedFullRedraw = false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Record a full-redraw event and classify its cause for storm detection. */
|
|
238
|
+
recordFullRedraw(cause: string): void {
|
|
239
|
+
if (!this.#enabled) return;
|
|
240
|
+
this.#fullRedrawCount += 1;
|
|
241
|
+
const normalizedCause = normalizeFullRedrawCause(cause);
|
|
242
|
+
incrementCount(this.#fullRedrawCauses, normalizedCause);
|
|
243
|
+
if (!isExpectedFullRedraw(normalizedCause)) {
|
|
244
|
+
this.#pendingUnexpectedFullRedraw = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Sample current RSS. Records baseline on first sample, tracks peak/last. */
|
|
249
|
+
sampleRss(): number {
|
|
250
|
+
if (!this.#enabled) return 0;
|
|
251
|
+
const mem = process.memoryUsage();
|
|
252
|
+
const rss = mem.rss;
|
|
253
|
+
this.#rssSamples += 1;
|
|
254
|
+
if (this.#rssBaseline === null) this.#rssBaseline = rss;
|
|
255
|
+
if (this.#heapBaseline === null) this.#heapBaseline = mem.heapUsed;
|
|
256
|
+
this.#rssLast = rss;
|
|
257
|
+
if (rss > this.#rssPeak) this.#rssPeak = rss;
|
|
258
|
+
return rss;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
setOwnerGauge(name: string, value: number): void {
|
|
262
|
+
if (!this.#enabled) return;
|
|
263
|
+
this.#ownerGauges.set(name, value);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
setTimerGauge(name: string, value: number): void {
|
|
267
|
+
if (!this.#enabled) return;
|
|
268
|
+
this.#timerGauges.set(name, value);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Accumulate timing/count for a named render helper (e.g. "renderTree"). */
|
|
272
|
+
recordHelper(name: string, durationMs: number): void {
|
|
273
|
+
if (!this.#enabled) return;
|
|
274
|
+
const retained = retainedLabel(this.#helpers, name);
|
|
275
|
+
const cur = this.#helpers.get(retained) ?? { count: 0, totalMs: 0 };
|
|
276
|
+
cur.count += 1;
|
|
277
|
+
cur.totalMs += durationMs;
|
|
278
|
+
this.#helpers.set(retained, cur);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
283
|
+
* "return" value used by the memory-leak gate. Callers should drop large
|
|
284
|
+
* references before calling so reclaimable memory is actually freed.
|
|
285
|
+
*/
|
|
286
|
+
sampleReturn(): number {
|
|
287
|
+
if (!this.#enabled) return 0;
|
|
288
|
+
const bunGc = (globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc;
|
|
289
|
+
const nodeGc = (globalThis as { gc?: () => void }).gc;
|
|
290
|
+
if (typeof bunGc === "function") bunGc(true);
|
|
291
|
+
else if (typeof nodeGc === "function") nodeGc();
|
|
292
|
+
const mem = process.memoryUsage();
|
|
293
|
+
this.#rssReturn = mem.rss;
|
|
294
|
+
this.#heapReturn = mem.heapUsed;
|
|
295
|
+
if (this.#rssBaseline === null) this.#rssBaseline = mem.rss;
|
|
296
|
+
if (this.#heapBaseline === null) this.#heapBaseline = mem.heapUsed;
|
|
297
|
+
return mem.rss;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#durationStats(): DurationStats {
|
|
301
|
+
if (this.#durations.length === 0) return emptyDurationStats();
|
|
302
|
+
const sorted = [...this.#durations].sort((a, b) => a - b);
|
|
303
|
+
const sum = sorted.reduce((acc, v) => acc + v, 0);
|
|
304
|
+
return {
|
|
305
|
+
count: sorted.length,
|
|
306
|
+
meanMs: sum / sorted.length,
|
|
307
|
+
p50Ms: percentile(sorted, 50),
|
|
308
|
+
p95Ms: percentile(sorted, 95),
|
|
309
|
+
p99Ms: percentile(sorted, 99),
|
|
310
|
+
maxMs: sorted[sorted.length - 1],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#helperStats(): Record<string, HelperStat> {
|
|
315
|
+
const out: Record<string, HelperStat> = {};
|
|
316
|
+
for (const [name, v] of this.#helpers) {
|
|
317
|
+
out[name] = { count: v.count, totalMs: v.totalMs, meanMs: v.count ? v.totalMs / v.count : 0 };
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
snapshot(): RenderMetricsSnapshot {
|
|
323
|
+
return {
|
|
324
|
+
enabled: this.#enabled,
|
|
325
|
+
renderCount: this.#renderCount,
|
|
326
|
+
renderDurations: this.#durationStats(),
|
|
327
|
+
durationsTruncated: this.#durationsTruncated,
|
|
328
|
+
requestSources: Object.fromEntries(this.#requestSources),
|
|
329
|
+
fullRedrawCount: this.#fullRedrawCount,
|
|
330
|
+
fullRedrawCauses: Object.fromEntries(this.#fullRedrawCauses),
|
|
331
|
+
repaintStorms: this.#repaintStorms,
|
|
332
|
+
maxConsecutiveFullRedraws: this.#maxConsecutiveFullRedraws,
|
|
333
|
+
rss: {
|
|
334
|
+
samples: this.#rssSamples,
|
|
335
|
+
baselineBytes: this.#rssBaseline,
|
|
336
|
+
lastBytes: this.#rssLast,
|
|
337
|
+
peakBytes: this.#rssPeak,
|
|
338
|
+
growthBytes: this.#rssBaseline === null ? 0 : this.#rssPeak - this.#rssBaseline,
|
|
339
|
+
returnBytes: this.#rssReturn,
|
|
340
|
+
heapBaselineBytes: this.#heapBaseline,
|
|
341
|
+
heapReturnBytes: this.#heapReturn,
|
|
342
|
+
returnWithinBaselineFraction:
|
|
343
|
+
this.#heapBaseline && this.#heapReturn !== null
|
|
344
|
+
? (this.#heapReturn - this.#heapBaseline) / this.#heapBaseline
|
|
345
|
+
: null,
|
|
346
|
+
},
|
|
347
|
+
ownerGauges: Object.fromEntries(this.#ownerGauges),
|
|
348
|
+
timerGauges: Object.fromEntries(this.#timerGauges),
|
|
349
|
+
helperStats: this.#helperStats(),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Shared metrics instance used by the TUI render loop. */
|
|
355
|
+
export const renderMetrics = new RenderMetrics();
|
package/src/terminal.ts
CHANGED
|
@@ -34,6 +34,8 @@ export function emergencyTerminalRestore(): void {
|
|
|
34
34
|
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
35
35
|
process.stdout.write(
|
|
36
36
|
"\x1b[?2004l" + // Disable bracketed paste
|
|
37
|
+
"\x1b[?1000l" + // Disable normal mouse reporting
|
|
38
|
+
"\x1b[?1006l" + // Disable SGR extended mouse reporting
|
|
37
39
|
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
38
40
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
39
41
|
"\x1b[>4;0m" + // Disable modifyOtherKeys fallback
|
|
@@ -572,6 +574,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
572
574
|
|
|
573
575
|
// Disable bracketed paste mode
|
|
574
576
|
this.#safeWrite("\x1b[?2004l");
|
|
577
|
+
this.#safeWrite("\x1b[?1000l");
|
|
578
|
+
this.#safeWrite("\x1b[?1006l");
|
|
575
579
|
|
|
576
580
|
// Disable Mode 2031 appearance change notifications
|
|
577
581
|
this.#safeWrite("\x1b[?2031l");
|
package/src/tui.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as path from "node:path";
|
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
7
|
import { $flag, getDebugLogPath } from "@gajae-code/utils";
|
|
8
8
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
9
|
+
import { renderMetrics } from "./metrics";
|
|
9
10
|
import type { Terminal } from "./terminal";
|
|
10
11
|
import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
|
|
11
12
|
import {
|
|
@@ -433,6 +434,7 @@ export class TUI extends Container {
|
|
|
433
434
|
if (this.#renderTimer) {
|
|
434
435
|
clearTimeout(this.#renderTimer);
|
|
435
436
|
this.#renderTimer = undefined;
|
|
437
|
+
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
436
438
|
}
|
|
437
439
|
this.#clearSixelProbeState();
|
|
438
440
|
}
|
|
@@ -619,6 +621,7 @@ export class TUI extends Container {
|
|
|
619
621
|
if (this.#renderTimer) {
|
|
620
622
|
clearTimeout(this.#renderTimer);
|
|
621
623
|
this.#renderTimer = undefined;
|
|
624
|
+
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
622
625
|
}
|
|
623
626
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
624
627
|
if (this.#previousLines.length > 0) {
|
|
@@ -640,11 +643,12 @@ export class TUI extends Container {
|
|
|
640
643
|
}
|
|
641
644
|
}
|
|
642
645
|
|
|
643
|
-
requestRender(force = false): void {
|
|
646
|
+
requestRender(force = false, source = "unknown"): void {
|
|
644
647
|
if (!this.terminalAvailable) {
|
|
645
648
|
this.#markTerminalUnavailable();
|
|
646
649
|
return;
|
|
647
650
|
}
|
|
651
|
+
if (renderMetrics.enabled) renderMetrics.recordRequest(source);
|
|
648
652
|
if (force) {
|
|
649
653
|
this.#previousLines = [];
|
|
650
654
|
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
@@ -656,6 +660,7 @@ export class TUI extends Container {
|
|
|
656
660
|
if (this.#renderTimer) {
|
|
657
661
|
clearTimeout(this.#renderTimer);
|
|
658
662
|
this.#renderTimer = undefined;
|
|
663
|
+
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
659
664
|
}
|
|
660
665
|
this.#renderRequested = true;
|
|
661
666
|
process.nextTick(() => {
|
|
@@ -664,7 +669,9 @@ export class TUI extends Container {
|
|
|
664
669
|
}
|
|
665
670
|
this.#renderRequested = false;
|
|
666
671
|
this.#lastRenderAt = performance.now();
|
|
672
|
+
const t0 = renderMetrics.now();
|
|
667
673
|
this.#doRender();
|
|
674
|
+
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
668
675
|
});
|
|
669
676
|
return;
|
|
670
677
|
}
|
|
@@ -681,16 +688,20 @@ export class TUI extends Container {
|
|
|
681
688
|
const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
|
682
689
|
this.#renderTimer = setTimeout(() => {
|
|
683
690
|
this.#renderTimer = undefined;
|
|
691
|
+
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
684
692
|
if (this.#stopped || !this.#renderRequested) {
|
|
685
693
|
return;
|
|
686
694
|
}
|
|
687
695
|
this.#renderRequested = false;
|
|
688
696
|
this.#lastRenderAt = performance.now();
|
|
697
|
+
const t0 = renderMetrics.now();
|
|
689
698
|
this.#doRender();
|
|
699
|
+
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
690
700
|
if (this.#renderRequested) {
|
|
691
701
|
this.#scheduleRender();
|
|
692
702
|
}
|
|
693
703
|
}, delay);
|
|
704
|
+
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 1);
|
|
694
705
|
}
|
|
695
706
|
|
|
696
707
|
#handleInput(data: string): void {
|
|
@@ -1108,7 +1119,9 @@ export class TUI extends Container {
|
|
|
1108
1119
|
};
|
|
1109
1120
|
|
|
1110
1121
|
// Render all components to get new lines
|
|
1122
|
+
const renderTreeStart = renderMetrics.now();
|
|
1111
1123
|
let newLines = this.render(width);
|
|
1124
|
+
if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
|
|
1112
1125
|
|
|
1113
1126
|
// Composite overlays into the rendered lines (before differential compare)
|
|
1114
1127
|
if (this.overlayStack.length > 0) {
|
|
@@ -1130,8 +1143,9 @@ export class TUI extends Container {
|
|
|
1130
1143
|
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
1131
1144
|
|
|
1132
1145
|
// Helper to clear scrollback and viewport and render all new lines
|
|
1133
|
-
const fullRender = (clear: boolean): void => {
|
|
1146
|
+
const fullRender = (clear: boolean, reason = "full render"): void => {
|
|
1134
1147
|
this.#fullRedrawCount += 1;
|
|
1148
|
+
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
1135
1149
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1136
1150
|
// Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
|
|
1137
1151
|
if (clear) buffer += isMultiplexerSession() ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
|
|
@@ -1161,6 +1175,7 @@ export class TUI extends Container {
|
|
|
1161
1175
|
|
|
1162
1176
|
const multiplexerViewportRepaint = (reason: string): void => {
|
|
1163
1177
|
this.#fullRedrawCount += 1;
|
|
1178
|
+
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
1164
1179
|
const nextViewportTop = Math.max(0, newLines.length - height);
|
|
1165
1180
|
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
1166
1181
|
let buffer = "\x1b[?2026h";
|
|
@@ -1225,14 +1240,14 @@ export class TUI extends Container {
|
|
|
1225
1240
|
// First render - just output everything without clearing (assumes clean screen)
|
|
1226
1241
|
if (this.#previousLines.length === 0 && !widthChanged && !heightChanged) {
|
|
1227
1242
|
logRedraw("first render");
|
|
1228
|
-
fullRender(false);
|
|
1243
|
+
fullRender(false, "first render");
|
|
1229
1244
|
return;
|
|
1230
1245
|
}
|
|
1231
1246
|
|
|
1232
1247
|
// Width changes always need a full re-render because wrapping changes.
|
|
1233
1248
|
if (widthChanged) {
|
|
1234
1249
|
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1235
|
-
fullRender(true);
|
|
1250
|
+
fullRender(true, "terminal width changed");
|
|
1236
1251
|
return;
|
|
1237
1252
|
}
|
|
1238
1253
|
|
|
@@ -1246,7 +1261,7 @@ export class TUI extends Container {
|
|
|
1246
1261
|
}
|
|
1247
1262
|
if (!isTermuxSession() && !isMultiplexerSession()) {
|
|
1248
1263
|
logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
|
|
1249
|
-
fullRender(true);
|
|
1264
|
+
fullRender(true, "terminal height changed");
|
|
1250
1265
|
return;
|
|
1251
1266
|
}
|
|
1252
1267
|
}
|
|
@@ -1256,7 +1271,7 @@ export class TUI extends Container {
|
|
|
1256
1271
|
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
|
1257
1272
|
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
1258
1273
|
logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
1259
|
-
fullRender(true);
|
|
1274
|
+
fullRender(true, "clearOnShrink");
|
|
1260
1275
|
return;
|
|
1261
1276
|
}
|
|
1262
1277
|
|
|
@@ -1308,7 +1323,7 @@ export class TUI extends Container {
|
|
|
1308
1323
|
if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
|
|
1309
1324
|
multiplexerViewportRepaint(`extraLines > height (${extraLines} > ${height})`);
|
|
1310
1325
|
} else {
|
|
1311
|
-
fullRender(true);
|
|
1326
|
+
fullRender(true, "extraLines > height");
|
|
1312
1327
|
}
|
|
1313
1328
|
return;
|
|
1314
1329
|
}
|
|
@@ -1347,7 +1362,7 @@ export class TUI extends Container {
|
|
|
1347
1362
|
if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
|
|
1348
1363
|
multiplexerViewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1349
1364
|
} else {
|
|
1350
|
-
fullRender(true);
|
|
1365
|
+
fullRender(true, "firstChanged < viewportTop");
|
|
1351
1366
|
}
|
|
1352
1367
|
return;
|
|
1353
1368
|
}
|
package/src/utils.ts
CHANGED
|
@@ -8,11 +8,22 @@ import {
|
|
|
8
8
|
type SliceResult,
|
|
9
9
|
} from "@gajae-code/natives";
|
|
10
10
|
import { getDefaultTabWidth, getIndentation } from "@gajae-code/utils";
|
|
11
|
+
import { renderMetrics } from "./metrics";
|
|
11
12
|
|
|
12
13
|
export { Ellipsis } from "@gajae-code/natives";
|
|
13
14
|
|
|
14
15
|
export { getDefaultTabWidth, getIndentation } from "@gajae-code/utils";
|
|
15
16
|
|
|
17
|
+
function recordTextHelper<T>(name: string, fn: () => T): T {
|
|
18
|
+
if (!renderMetrics.enabled) return fn();
|
|
19
|
+
const start = renderMetrics.now();
|
|
20
|
+
try {
|
|
21
|
+
return fn();
|
|
22
|
+
} finally {
|
|
23
|
+
renderMetrics.recordHelper(name, renderMetrics.now() - start);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
|
|
17
28
|
return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth());
|
|
18
29
|
}
|
|
@@ -98,31 +109,37 @@ export function visibleWidthRaw(str: string): number {
|
|
|
98
109
|
if (!str) {
|
|
99
110
|
return 0;
|
|
100
111
|
}
|
|
112
|
+
if (str.length === 1) {
|
|
113
|
+
const code = str.charCodeAt(0);
|
|
114
|
+
if (code >= 0x20 && code <= 0x7e) return 1;
|
|
115
|
+
if (code === 9) return getDefaultTabWidth();
|
|
116
|
+
}
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
let tabLength = 0;
|
|
104
|
-
const tabWidth = getDefaultTabWidth();
|
|
118
|
+
let tabCount = 0;
|
|
105
119
|
let isPureAscii = true;
|
|
106
120
|
for (let i = 0; i < str.length; i++) {
|
|
107
121
|
const code = str.charCodeAt(i);
|
|
108
122
|
if (code === 9) {
|
|
109
|
-
|
|
123
|
+
tabCount += 1;
|
|
110
124
|
} else if (code < 0x20 || code > 0x7e) {
|
|
111
125
|
isPureAscii = false;
|
|
112
126
|
}
|
|
113
127
|
}
|
|
114
128
|
if (isPureAscii) {
|
|
115
|
-
return str.length +
|
|
129
|
+
return str.length + tabCount * (getDefaultTabWidth() - 1);
|
|
116
130
|
}
|
|
117
|
-
|
|
131
|
+
|
|
132
|
+
const normalized = normalizeForWidth(str);
|
|
133
|
+
if (tabCount === 0) return Bun.stringWidth(normalized);
|
|
134
|
+
return Bun.stringWidth(normalized.replaceAll("\t", " ".repeat(getDefaultTabWidth())));
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
/**
|
|
121
138
|
* Calculate the visible width of a string in terminal columns.
|
|
122
139
|
*/
|
|
123
140
|
export function visibleWidth(str: string): number {
|
|
124
|
-
if (!
|
|
125
|
-
return visibleWidthRaw(str);
|
|
141
|
+
if (!renderMetrics.enabled) return visibleWidthRaw(str);
|
|
142
|
+
return recordTextHelper("text.visibleWidth", () => visibleWidthRaw(str));
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
|