@gajae-code/tui 0.3.0 → 0.3.1

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,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
@@ -16,6 +16,7 @@ export type * from "./editor-component";
16
16
  export * from "./fuzzy";
17
17
  export * from "./keybindings";
18
18
  export * from "./keys";
19
+ export * from "./metrics";
19
20
  export * from "./stdin-buffer";
20
21
  export type * from "./symbols";
21
22
  export * from "./terminal";
@@ -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;
@@ -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.0",
4
+ "version": "0.3.1",
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.0",
41
- "@gajae-code/utils": "0.3.0",
41
+ "@gajae-code/natives": "0.3.1",
42
+ "@gajae-code/utils": "0.3.1",
42
43
  "lru-cache": "11.3.6",
43
44
  "marked": "^18.0.3"
44
45
  },
@@ -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
- * - **Render tick** (every `RENDER_INTERVAL_MS`) → asks the TUI to redraw.
11
- * The TUI already throttles at 16ms (`MIN_RENDER_INTERVAL_MS`), so this
12
- * is the natural upper bound; static messageColorFns produce identical
13
- * output and the differ drops the no-op redraw at ~zero cost.
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 render cadence so the spinner keeps
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
- this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
82
- if (this.#ui) {
83
- this.#ui.requestRender();
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
- // Fast path: pure ASCII printable
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
- tabLength += tabWidth;
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 + tabLength;
129
+ return str.length + tabCount * (getDefaultTabWidth() - 1);
116
130
  }
117
- return Bun.stringWidth(normalizeForWidth(str)) + tabLength;
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 (!str) return 0;
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]/;