@sayknow-cli/tui 0.2.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +903 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +82 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +20 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +111 -0
  8. package/dist/types/components/image.d.ts +16 -0
  9. package/dist/types/components/input.d.ts +16 -0
  10. package/dist/types/components/loader.d.ts +14 -0
  11. package/dist/types/components/markdown.d.ts +64 -0
  12. package/dist/types/components/select-list.d.ts +46 -0
  13. package/dist/types/components/settings-list.d.ts +39 -0
  14. package/dist/types/components/spacer.d.ts +11 -0
  15. package/dist/types/components/tab-bar.d.ts +56 -0
  16. package/dist/types/components/text.d.ts +13 -0
  17. package/dist/types/components/truncated-text.d.ts +10 -0
  18. package/dist/types/editor-component.d.ts +36 -0
  19. package/dist/types/fuzzy.d.ts +15 -0
  20. package/dist/types/index.d.ts +26 -0
  21. package/dist/types/keybindings.d.ts +189 -0
  22. package/dist/types/keys.d.ts +208 -0
  23. package/dist/types/kill-ring.d.ts +27 -0
  24. package/dist/types/metrics.d.ts +85 -0
  25. package/dist/types/stdin-buffer.d.ts +50 -0
  26. package/dist/types/symbols.d.ts +23 -0
  27. package/dist/types/terminal-capabilities.d.ts +75 -0
  28. package/dist/types/terminal.d.ts +76 -0
  29. package/dist/types/ttyid.d.ts +9 -0
  30. package/dist/types/tui.d.ts +181 -0
  31. package/dist/types/utils.d.ts +75 -0
  32. package/package.json +74 -0
  33. package/src/autocomplete.ts +896 -0
  34. package/src/bracketed-paste.ts +47 -0
  35. package/src/components/box.ts +173 -0
  36. package/src/components/cancellable-loader.ts +40 -0
  37. package/src/components/editor.ts +2820 -0
  38. package/src/components/image.ts +90 -0
  39. package/src/components/input.ts +465 -0
  40. package/src/components/loader.ts +103 -0
  41. package/src/components/markdown.ts +1061 -0
  42. package/src/components/select-list.ts +249 -0
  43. package/src/components/settings-list.ts +211 -0
  44. package/src/components/spacer.ts +28 -0
  45. package/src/components/tab-bar.ts +175 -0
  46. package/src/components/text.ts +110 -0
  47. package/src/components/truncated-text.ts +61 -0
  48. package/src/editor-component.ts +71 -0
  49. package/src/fuzzy.ts +143 -0
  50. package/src/index.ts +41 -0
  51. package/src/keybindings.ts +279 -0
  52. package/src/keys.ts +537 -0
  53. package/src/kill-ring.ts +46 -0
  54. package/src/metrics.ts +382 -0
  55. package/src/stdin-buffer.ts +444 -0
  56. package/src/symbols.ts +24 -0
  57. package/src/terminal-capabilities.ts +537 -0
  58. package/src/terminal.ts +807 -0
  59. package/src/ttyid.ts +73 -0
  60. package/src/tui.ts +1765 -0
  61. package/src/utils.ts +389 -0
package/src/metrics.ts ADDED
@@ -0,0 +1,382 @@
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 "@sayknow-cli/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 LineCountGauge {
108
+ last: number;
109
+ max: number;
110
+ }
111
+
112
+ export interface RenderMetricsSnapshot {
113
+ enabled: boolean;
114
+ renderCount: number;
115
+ renderDurations: DurationStats;
116
+ durationsTruncated: boolean;
117
+ requestSources: Record<string, number>;
118
+ fullRedrawCount: number;
119
+ fullRedrawCauses: Record<string, number>;
120
+ repaintStorms: number;
121
+ maxConsecutiveFullRedraws: number;
122
+ rss: RssStats;
123
+ ownerGauges: Record<string, number>;
124
+ timerGauges: Record<string, number>;
125
+ helperStats: Record<string, HelperStat>;
126
+ lineCounts: Record<string, LineCountGauge>;
127
+ }
128
+
129
+ function emptyDurationStats(): DurationStats {
130
+ return { count: 0, meanMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 };
131
+ }
132
+
133
+ function percentile(sorted: number[], p: number): number {
134
+ if (sorted.length === 0) return 0;
135
+ const rank = (p / 100) * (sorted.length - 1);
136
+ const lo = Math.floor(rank);
137
+ const hi = Math.ceil(rank);
138
+ if (lo === hi) return sorted[lo];
139
+ const frac = rank - lo;
140
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
141
+ }
142
+
143
+ export class RenderMetrics {
144
+ #enabled: boolean;
145
+ #renderCount = 0;
146
+ #durations: number[] = [];
147
+ #durationsTruncated = false;
148
+ #requestSources = new Map<string, number>();
149
+ #fullRedrawCount = 0;
150
+ #fullRedrawCauses = new Map<string, number>();
151
+ #pendingUnexpectedFullRedraw = false;
152
+ #consecutiveFullRedraws = 0;
153
+ #maxConsecutiveFullRedraws = 0;
154
+ #repaintStorms = 0;
155
+ #rssSamples = 0;
156
+ #rssBaseline: number | null = null;
157
+ #rssLast: number | null = null;
158
+ #rssPeak = 0;
159
+ #ownerGauges = new Map<string, number>();
160
+ #timerGauges = new Map<string, number>();
161
+ #helpers = new Map<string, { count: number; totalMs: number }>();
162
+ #lineGauges = new Map<string, LineCountGauge>();
163
+ #rssReturn: number | null = null;
164
+ #heapBaseline: number | null = null;
165
+ #heapReturn: number | null = null;
166
+
167
+ constructor(enabled = $flag("PI_TUI_METRICS")) {
168
+ this.#enabled = enabled;
169
+ }
170
+
171
+ get enabled(): boolean {
172
+ return this.#enabled;
173
+ }
174
+
175
+ enable(): void {
176
+ this.#enabled = true;
177
+ }
178
+
179
+ disable(): void {
180
+ this.#enabled = false;
181
+ }
182
+
183
+ /** Reset all collected data (keeps the enabled state). */
184
+ reset(): void {
185
+ this.#renderCount = 0;
186
+ this.#durations = [];
187
+ this.#durationsTruncated = false;
188
+ this.#requestSources.clear();
189
+ this.#fullRedrawCount = 0;
190
+ this.#fullRedrawCauses.clear();
191
+ this.#pendingUnexpectedFullRedraw = false;
192
+ this.#consecutiveFullRedraws = 0;
193
+ this.#maxConsecutiveFullRedraws = 0;
194
+ this.#repaintStorms = 0;
195
+ this.#rssSamples = 0;
196
+ this.#rssBaseline = null;
197
+ this.#rssLast = null;
198
+ this.#rssPeak = 0;
199
+ this.#ownerGauges.clear();
200
+ this.#timerGauges.clear();
201
+ this.#helpers.clear();
202
+ this.#lineGauges.clear();
203
+ this.#rssReturn = null;
204
+ this.#heapBaseline = null;
205
+ this.#heapReturn = null;
206
+ }
207
+
208
+ /** High-resolution clock for timing render passes. Returns 0 when disabled. */
209
+ now(): number {
210
+ return this.#enabled ? performance.now() : 0;
211
+ }
212
+
213
+ /** Record that a render was requested, attributed to a caller source. */
214
+ recordRequest(source = "unknown"): void {
215
+ if (!this.#enabled) return;
216
+ incrementCount(this.#requestSources, source);
217
+ }
218
+
219
+ /** Record one completed `#doRender` pass duration (ms). */
220
+ recordRender(durationMs: number): void {
221
+ if (!this.#enabled) return;
222
+ this.#renderCount += 1;
223
+ if (this.#durations.length < MAX_DURATION_SAMPLES) {
224
+ this.#durations.push(durationMs);
225
+ } else {
226
+ this.#durationsTruncated = true;
227
+ }
228
+
229
+ // Storm bookkeeping: a render that performed an unexpected full redraw
230
+ // extends the current run; any other render breaks it.
231
+ if (this.#pendingUnexpectedFullRedraw) {
232
+ this.#consecutiveFullRedraws += 1;
233
+ if (this.#consecutiveFullRedraws > this.#maxConsecutiveFullRedraws) {
234
+ this.#maxConsecutiveFullRedraws = this.#consecutiveFullRedraws;
235
+ }
236
+ if (this.#consecutiveFullRedraws === REPAINT_STORM_THRESHOLD) {
237
+ this.#repaintStorms += 1;
238
+ }
239
+ } else {
240
+ this.#consecutiveFullRedraws = 0;
241
+ }
242
+ this.#pendingUnexpectedFullRedraw = false;
243
+ }
244
+
245
+ /** Record a full-redraw event and classify its cause for storm detection. */
246
+ recordFullRedraw(cause: string): void {
247
+ if (!this.#enabled) return;
248
+ this.#fullRedrawCount += 1;
249
+ const normalizedCause = normalizeFullRedrawCause(cause);
250
+ incrementCount(this.#fullRedrawCauses, normalizedCause);
251
+ if (!isExpectedFullRedraw(normalizedCause)) {
252
+ this.#pendingUnexpectedFullRedraw = true;
253
+ }
254
+ }
255
+
256
+ /** Sample current RSS. Records baseline on first sample, tracks peak/last. */
257
+ sampleRss(): number {
258
+ if (!this.#enabled) return 0;
259
+ const mem = process.memoryUsage();
260
+ const rss = mem.rss;
261
+ this.#rssSamples += 1;
262
+ if (this.#rssBaseline === null) this.#rssBaseline = rss;
263
+ if (this.#heapBaseline === null) this.#heapBaseline = mem.heapUsed;
264
+ this.#rssLast = rss;
265
+ if (rss > this.#rssPeak) this.#rssPeak = rss;
266
+ return rss;
267
+ }
268
+
269
+ setOwnerGauge(name: string, value: number): void {
270
+ if (!this.#enabled) return;
271
+ this.#ownerGauges.set(name, value);
272
+ }
273
+
274
+ setTimerGauge(name: string, value: number): void {
275
+ if (!this.#enabled) return;
276
+ this.#timerGauges.set(name, value);
277
+ }
278
+
279
+ /** Accumulate timing/count for a named render helper (e.g. "renderTree"). */
280
+ recordHelper(name: string, durationMs: number): void {
281
+ if (!this.#enabled) return;
282
+ const retained = retainedLabel(this.#helpers, name);
283
+ const cur = this.#helpers.get(retained) ?? { count: 0, totalMs: 0 };
284
+ cur.count += 1;
285
+ cur.totalMs += durationMs;
286
+ this.#helpers.set(retained, cur);
287
+ }
288
+
289
+ /** Record a per-render line-count gauge (e.g. "rendered", "normalized", "diffed"). */
290
+ recordLineCount(name: string, value: number): void {
291
+ if (!this.#enabled) return;
292
+ const retained = retainedLabel(this.#lineGauges, name);
293
+ const cur = this.#lineGauges.get(retained) ?? { last: 0, max: 0 };
294
+ cur.last = value;
295
+ if (value > cur.max) cur.max = value;
296
+ this.#lineGauges.set(retained, cur);
297
+ }
298
+
299
+ /**
300
+ * Force a GC when the runtime exposes one and sample RSS as the post-run
301
+ * "return" value used by the memory-leak gate. Callers should drop large
302
+ * references before calling so reclaimable memory is actually freed.
303
+ */
304
+ sampleReturn(): number {
305
+ if (!this.#enabled) return 0;
306
+ const bunGc = (globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc;
307
+ const nodeGc = (globalThis as { gc?: () => void }).gc;
308
+ if (typeof bunGc === "function") bunGc(true);
309
+ else if (typeof nodeGc === "function") nodeGc();
310
+ const mem = process.memoryUsage();
311
+ this.#rssReturn = mem.rss;
312
+ this.#heapReturn = mem.heapUsed;
313
+ if (this.#rssBaseline === null) this.#rssBaseline = mem.rss;
314
+ if (this.#heapBaseline === null) this.#heapBaseline = mem.heapUsed;
315
+ return mem.rss;
316
+ }
317
+
318
+ #durationStats(): DurationStats {
319
+ if (this.#durations.length === 0) return emptyDurationStats();
320
+ const sorted = [...this.#durations].sort((a, b) => a - b);
321
+ const sum = sorted.reduce((acc, v) => acc + v, 0);
322
+ return {
323
+ count: sorted.length,
324
+ meanMs: sum / sorted.length,
325
+ p50Ms: percentile(sorted, 50),
326
+ p95Ms: percentile(sorted, 95),
327
+ p99Ms: percentile(sorted, 99),
328
+ maxMs: sorted[sorted.length - 1],
329
+ };
330
+ }
331
+
332
+ #helperStats(): Record<string, HelperStat> {
333
+ const out: Record<string, HelperStat> = {};
334
+ for (const [name, v] of this.#helpers) {
335
+ out[name] = { count: v.count, totalMs: v.totalMs, meanMs: v.count ? v.totalMs / v.count : 0 };
336
+ }
337
+ return out;
338
+ }
339
+
340
+ #lineCountStats(): Record<string, LineCountGauge> {
341
+ const out: Record<string, LineCountGauge> = {};
342
+ for (const [name, v] of this.#lineGauges) {
343
+ out[name] = { last: v.last, max: v.max };
344
+ }
345
+ return out;
346
+ }
347
+
348
+ snapshot(): RenderMetricsSnapshot {
349
+ return {
350
+ enabled: this.#enabled,
351
+ renderCount: this.#renderCount,
352
+ renderDurations: this.#durationStats(),
353
+ durationsTruncated: this.#durationsTruncated,
354
+ requestSources: Object.fromEntries(this.#requestSources),
355
+ fullRedrawCount: this.#fullRedrawCount,
356
+ fullRedrawCauses: Object.fromEntries(this.#fullRedrawCauses),
357
+ repaintStorms: this.#repaintStorms,
358
+ maxConsecutiveFullRedraws: this.#maxConsecutiveFullRedraws,
359
+ rss: {
360
+ samples: this.#rssSamples,
361
+ baselineBytes: this.#rssBaseline,
362
+ lastBytes: this.#rssLast,
363
+ peakBytes: this.#rssPeak,
364
+ growthBytes: this.#rssBaseline === null ? 0 : this.#rssPeak - this.#rssBaseline,
365
+ returnBytes: this.#rssReturn,
366
+ heapBaselineBytes: this.#heapBaseline,
367
+ heapReturnBytes: this.#heapReturn,
368
+ returnWithinBaselineFraction:
369
+ this.#heapBaseline && this.#heapReturn !== null
370
+ ? (this.#heapReturn - this.#heapBaseline) / this.#heapBaseline
371
+ : null,
372
+ },
373
+ ownerGauges: Object.fromEntries(this.#ownerGauges),
374
+ timerGauges: Object.fromEntries(this.#timerGauges),
375
+ helperStats: this.#helperStats(),
376
+ lineCounts: this.#lineCountStats(),
377
+ };
378
+ }
379
+ }
380
+
381
+ /** Shared metrics instance used by the TUI render loop. */
382
+ export const renderMetrics = new RenderMetrics();