@silvery/tea 0.3.0

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.
@@ -0,0 +1,955 @@
1
+ /**
2
+ * withDiagnostics - Plugin for buffer and rendering diagnostic checks
3
+ *
4
+ * Wraps the `cmd` object to check invariants after command execution:
5
+ * - All commands: Check incremental vs fresh render
6
+ * - Cursor moves: Also check buffer content stability
7
+ * - Optional: ANSI replay verification (characters AND SGR styles)
8
+ *
9
+ * ## Design Note: Why wrap `cmd` instead of `sendInput`?
10
+ *
11
+ * The two approaches are complementary:
12
+ *
13
+ * 1. **`sendInput()` level** (in renderer.ts) — Already has `SILVERY_CHECK_INCREMENTAL`
14
+ * which catches ALL inputs regardless of how they arrive (raw key presses,
15
+ * type(), press(), etc.). This is the right place for incremental render checks.
16
+ *
17
+ * 2. **`cmd` level** (this plugin) — Command-aware, can selectively check stability
18
+ * for cursor moves only. Raw sendInput doesn't know which inputs are cursor
19
+ * commands that should preserve content. Another option would be `withInput()`
20
+ * which could wrap sendInput with awareness of what input was sent.
21
+ *
22
+ * This plugin focuses on the command-aware checks. For comprehensive incremental
23
+ * render checking, use `SILVERY_STRICT=1` environment variable which enables all checks.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * import { withDiagnostics } from '@silvery/term/toolbelt';
28
+ *
29
+ * // All checks enabled by default when you call withDiagnostics()
30
+ * const driver = withDiagnostics(createBoardDriver(repo, rootId));
31
+ *
32
+ * // Or disable specific checks
33
+ * const driver = withDiagnostics(createBoardDriver(repo, rootId), {
34
+ * checkReplay: false // skip ANSI replay check
35
+ * });
36
+ *
37
+ * // Commands now run invariant checks automatically
38
+ * await driver.cmd.down(); // Checks incremental + stability + replay
39
+ * await driver.cmd.search(); // Checks incremental + replay
40
+ * ```
41
+ */
42
+
43
+ import { mkdir } from "node:fs/promises"
44
+ import { join } from "node:path"
45
+ import { type Color, type TerminalBuffer, colorEquals } from "@silvery/term/buffer"
46
+ import { outputPhase } from "@silvery/term/pipeline"
47
+ import { compareBuffers, formatMismatch } from "@silvery/test/compare-buffers"
48
+ import type { BoxProps, TeaNode } from "./types"
49
+ import type { AppWithCommands, Cmd, Command } from "./with-commands"
50
+
51
+ // =============================================================================
52
+ // Types
53
+ // =============================================================================
54
+
55
+ export interface DiagnosticOptions {
56
+ /** Check incremental vs fresh render (default: true when plugin is used) */
57
+ checkIncremental?: boolean
58
+ /** Check buffer stability for cursor commands (default: true when plugin is used) */
59
+ checkStability?: boolean
60
+ /** Check ANSI replay produces correct result (default: true when plugin is used) */
61
+ checkReplay?: boolean
62
+ /** Check layout tree integrity after each command (default: true when plugin is used) */
63
+ checkLayout?: boolean
64
+ /** Lines to skip for stability check (e.g., [0, -1] for breadcrumb/statusbar) */
65
+ skipLines?: number[]
66
+ /** Capture screenshot on failure (default: false) */
67
+ captureOnFailure?: boolean
68
+ /** Directory for failure screenshots (default: /tmp/silvery-diagnostics) */
69
+ screenshotDir?: string
70
+ }
71
+
72
+ /**
73
+ * Text mismatch between before and after states
74
+ */
75
+ interface TextMismatch {
76
+ line: number
77
+ before: string
78
+ after: string
79
+ }
80
+
81
+ /**
82
+ * ANSI replay mismatch (character content)
83
+ */
84
+ interface ReplayMismatch {
85
+ x: number
86
+ y: number
87
+ expected: string
88
+ actual: string
89
+ }
90
+
91
+ /**
92
+ * SGR style attributes tracked per cell in the virtual terminal.
93
+ */
94
+ interface VTermStyle {
95
+ fg: number | { r: number; g: number; b: number } | null
96
+ bg: number | { r: number; g: number; b: number } | null
97
+ bold: boolean
98
+ dim: boolean
99
+ italic: boolean
100
+ underline: boolean
101
+ blink: boolean
102
+ inverse: boolean
103
+ hidden: boolean
104
+ strikethrough: boolean
105
+ }
106
+
107
+ /**
108
+ * ANSI replay style mismatch
109
+ */
110
+ interface StyleMismatch {
111
+ x: number
112
+ y: number
113
+ char: string
114
+ diffs: string[]
115
+ }
116
+
117
+ // =============================================================================
118
+ // VirtualTerminal - ANSI Replay Simulator
119
+ // =============================================================================
120
+
121
+ /**
122
+ * Virtual terminal simulator for testing ANSI replay equivalence.
123
+ *
124
+ * Parses ANSI sequences and applies them to a 2D grid tracking both
125
+ * character content and SGR style attributes (fg, bg, bold, italic, etc.).
126
+ * Used to verify the Replay Equivalence invariant: applying the ANSI
127
+ * diff to the previous buffer state should produce the target buffer.
128
+ *
129
+ * Handles:
130
+ * - Cursor positioning (H, G, A, B, C, D)
131
+ * - Line clear (K)
132
+ * - Wide characters (emojis, CJK)
133
+ * - CR/LF
134
+ * - SGR style sequences (m) — fg/bg colors, text attributes
135
+ */
136
+ export class VirtualTerminal {
137
+ private grid: string[][]
138
+ private wideMarker: boolean[][]
139
+ private styles: VTermStyle[][]
140
+ private sgr: VTermStyle
141
+ private cursorX = 0
142
+ private cursorY = 0
143
+
144
+ constructor(
145
+ public readonly width: number,
146
+ public readonly height: number,
147
+ ) {
148
+ this.grid = Array.from({ length: height }, () => Array(width).fill(" "))
149
+ this.wideMarker = Array.from({ length: height }, () => Array(width).fill(false))
150
+ this.styles = Array.from({ length: height }, () => Array.from({ length: width }, () => createDefaultVTermStyle()))
151
+ this.sgr = createDefaultVTermStyle()
152
+ }
153
+
154
+ /**
155
+ * Initialize grid from a TerminalBuffer (for incremental replay).
156
+ * Loads both character content and style attributes.
157
+ */
158
+ loadFromBuffer(buffer: TerminalBuffer): void {
159
+ for (let y = 0; y < Math.min(this.height, buffer.height); y++) {
160
+ for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
161
+ if (buffer.isCellContinuation(x, y)) {
162
+ this.wideMarker[y]![x] = true
163
+ this.grid[y]![x] = ""
164
+ // Continuation cells have default style
165
+ this.styles[y]![x] = createDefaultVTermStyle()
166
+ } else {
167
+ this.grid[y]![x] = buffer.getCellChar(x, y)
168
+ this.wideMarker[y]![x] = false
169
+ // Load style from buffer cell
170
+ const cell = buffer.getCell(x, y)
171
+ this.styles[y]![x] = {
172
+ fg: cell.fg,
173
+ bg: cell.bg,
174
+ bold: !!cell.attrs.bold,
175
+ dim: !!cell.attrs.dim,
176
+ italic: !!cell.attrs.italic,
177
+ underline: !!cell.attrs.underline || !!cell.attrs.underlineStyle,
178
+ blink: !!cell.attrs.blink,
179
+ inverse: !!cell.attrs.inverse,
180
+ hidden: !!cell.attrs.hidden,
181
+ strikethrough: !!cell.attrs.strikethrough,
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Apply ANSI escape sequence string to the virtual terminal.
190
+ */
191
+ applyAnsi(ansi: string): void {
192
+ let i = 0
193
+ while (i < ansi.length) {
194
+ if (ansi[i] === "\x1b" && ansi[i + 1] === "[") {
195
+ const match = ansi.slice(i).match(/^\x1b\[([0-9;:?]*)([A-Za-z])/)
196
+ if (match) {
197
+ this.handleCsi(match[1] || "", match[2]!)
198
+ i += match[0].length
199
+ continue
200
+ }
201
+ }
202
+
203
+ if (ansi[i] === "\r") {
204
+ this.cursorX = 0
205
+ i++
206
+ continue
207
+ }
208
+
209
+ if (ansi[i] === "\n") {
210
+ this.cursorY = Math.min(this.cursorY + 1, this.height - 1)
211
+ i++
212
+ continue
213
+ }
214
+
215
+ // Handle multi-byte Unicode characters
216
+ const char = this.extractChar(ansi, i)
217
+ if (this.cursorX < this.width && this.cursorY < this.height) {
218
+ this.grid[this.cursorY]![this.cursorX] = char
219
+ this.wideMarker[this.cursorY]![this.cursorX] = false
220
+ // Apply current SGR state to the cell
221
+ this.styles[this.cursorY]![this.cursorX] = { ...this.sgr }
222
+ this.cursorX++
223
+
224
+ // Wide characters take 2 columns
225
+ if (this.isWideChar(char) && this.cursorX < this.width) {
226
+ this.grid[this.cursorY]![this.cursorX] = ""
227
+ this.wideMarker[this.cursorY]![this.cursorX] = true
228
+ // Continuation cell gets default style
229
+ this.styles[this.cursorY]![this.cursorX] = createDefaultVTermStyle()
230
+ this.cursorX++
231
+ }
232
+ }
233
+ i += char.length
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Check if a character is wide (emoji, CJK, etc).
239
+ */
240
+ private isWideChar(char: string): boolean {
241
+ if (char.length === 0) return false
242
+
243
+ // Characters with VS16 (U+FE0F) are emoji presentation = 2 columns
244
+ if (char.includes("\uFE0F")) return true
245
+
246
+ const code = char.codePointAt(0) || 0
247
+
248
+ // Emoji ranges
249
+ if (code >= 0x1f300 && code <= 0x1f9ff) return true
250
+ if (code >= 0x2600 && code <= 0x26ff) return true
251
+ if (code >= 0x2700 && code <= 0x27bf) return true
252
+
253
+ // CJK ranges
254
+ if (code >= 0x4e00 && code <= 0x9fff) return true
255
+ if (code >= 0x3000 && code <= 0x303f) return true
256
+ if (code >= 0xff00 && code <= 0xffef) return true
257
+
258
+ return false
259
+ }
260
+
261
+ /**
262
+ * Extract a single Unicode character (which may be multiple bytes).
263
+ * Includes VS16 (U+FE0F) if it follows, since VS16 is a presentation selector
264
+ * that modifies the preceding character's rendering width.
265
+ */
266
+ private extractChar(str: string, start: number): string {
267
+ const code = str.codePointAt(start)
268
+ if (code === undefined) return str[start] || ""
269
+ let char: string
270
+ if (code > 0xffff) {
271
+ char = String.fromCodePoint(code)
272
+ } else {
273
+ char = str[start] || ""
274
+ }
275
+ // Absorb VS16 (U+FE0F) if it follows — it's a presentation modifier
276
+ const nextIdx = start + char.length
277
+ if (nextIdx < str.length && str.codePointAt(nextIdx) === 0xfe0f) {
278
+ char += "\uFE0F"
279
+ }
280
+ return char
281
+ }
282
+
283
+ private handleCsi(params: string, cmd: string): void {
284
+ switch (cmd) {
285
+ case "H": {
286
+ const parts = params.split(";")
287
+ this.cursorY = Math.max(0, (Number.parseInt(parts[0] || "1", 10) || 1) - 1)
288
+ this.cursorX = Math.max(0, (Number.parseInt(parts[1] || "1", 10) || 1) - 1)
289
+ break
290
+ }
291
+ case "G": {
292
+ this.cursorX = Math.max(0, (Number.parseInt(params || "1", 10) || 1) - 1)
293
+ break
294
+ }
295
+ case "A": {
296
+ const n = Number.parseInt(params || "1", 10) || 1
297
+ this.cursorY = Math.max(0, this.cursorY - n)
298
+ break
299
+ }
300
+ case "B": {
301
+ const n = Number.parseInt(params || "1", 10) || 1
302
+ this.cursorY = Math.min(this.height - 1, this.cursorY + n)
303
+ break
304
+ }
305
+ case "C": {
306
+ const n = Number.parseInt(params || "1", 10) || 1
307
+ this.cursorX = Math.min(this.width - 1, this.cursorX + n)
308
+ break
309
+ }
310
+ case "D": {
311
+ const n = Number.parseInt(params || "1", 10) || 1
312
+ this.cursorX = Math.max(0, this.cursorX - n)
313
+ break
314
+ }
315
+ case "K": {
316
+ const mode = Number.parseInt(params || "0", 10)
317
+ // Erase in Line: fills cleared cells with current bg (per ECMA-48)
318
+ if (mode === 0) {
319
+ for (let x = this.cursorX; x < this.width; x++) {
320
+ this.grid[this.cursorY]![x] = " "
321
+ this.wideMarker[this.cursorY]![x] = false
322
+ this.styles[this.cursorY]![x] = {
323
+ ...createDefaultVTermStyle(),
324
+ bg: this.sgr.bg,
325
+ }
326
+ }
327
+ } else if (mode === 1) {
328
+ for (let x = 0; x <= this.cursorX; x++) {
329
+ this.grid[this.cursorY]![x] = " "
330
+ this.wideMarker[this.cursorY]![x] = false
331
+ this.styles[this.cursorY]![x] = {
332
+ ...createDefaultVTermStyle(),
333
+ bg: this.sgr.bg,
334
+ }
335
+ }
336
+ } else if (mode === 2) {
337
+ for (let x = 0; x < this.width; x++) {
338
+ this.grid[this.cursorY]![x] = " "
339
+ this.wideMarker[this.cursorY]![x] = false
340
+ this.styles[this.cursorY]![x] = {
341
+ ...createDefaultVTermStyle(),
342
+ bg: this.sgr.bg,
343
+ }
344
+ }
345
+ }
346
+ break
347
+ }
348
+ case "m":
349
+ // SGR (Select Graphic Rendition) — apply style changes
350
+ this.applySgr(params)
351
+ break
352
+ case "l":
353
+ case "h":
354
+ // Private modes — ignore for replay
355
+ break
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Apply SGR parameters to the current style state.
361
+ * Parses the semicolon-separated parameter string and updates this.sgr.
362
+ */
363
+ private applySgr(params: string): void {
364
+ if (params === "" || params === "0") {
365
+ // Reset all attributes
366
+ Object.assign(this.sgr, createDefaultVTermStyle())
367
+ return
368
+ }
369
+
370
+ const parts = params.split(";")
371
+ let i = 0
372
+ while (i < parts.length) {
373
+ const code = parts[i]!
374
+ // Handle subparameters (e.g., "4:3" for curly underline)
375
+ const colonIdx = code.indexOf(":")
376
+ if (colonIdx >= 0) {
377
+ const mainCode = Number.parseInt(code.substring(0, colonIdx), 10)
378
+ if (mainCode === 4) {
379
+ const sub = Number.parseInt(code.substring(colonIdx + 1), 10)
380
+ this.sgr.underline = sub > 0
381
+ }
382
+ i++
383
+ continue
384
+ }
385
+
386
+ const n = Number.parseInt(code, 10)
387
+ if (n === 0) {
388
+ Object.assign(this.sgr, createDefaultVTermStyle())
389
+ } else if (n === 1) {
390
+ this.sgr.bold = true
391
+ } else if (n === 2) {
392
+ this.sgr.dim = true
393
+ } else if (n === 3) {
394
+ this.sgr.italic = true
395
+ } else if (n === 4) {
396
+ this.sgr.underline = true
397
+ } else if (n === 5 || n === 6) {
398
+ this.sgr.blink = true
399
+ } else if (n === 7) {
400
+ this.sgr.inverse = true
401
+ } else if (n === 8) {
402
+ this.sgr.hidden = true
403
+ } else if (n === 9) {
404
+ this.sgr.strikethrough = true
405
+ } else if (n === 22) {
406
+ this.sgr.bold = false
407
+ this.sgr.dim = false
408
+ } else if (n === 23) {
409
+ this.sgr.italic = false
410
+ } else if (n === 24) {
411
+ this.sgr.underline = false
412
+ } else if (n === 25) {
413
+ this.sgr.blink = false
414
+ } else if (n === 27) {
415
+ this.sgr.inverse = false
416
+ } else if (n === 28) {
417
+ this.sgr.hidden = false
418
+ } else if (n === 29) {
419
+ this.sgr.strikethrough = false
420
+ } else if (n >= 30 && n <= 37) {
421
+ // Standard foreground colors (0-7)
422
+ this.sgr.fg = n - 30
423
+ } else if (n === 38) {
424
+ // Extended foreground color
425
+ if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
426
+ // 256-color: \x1b[38;5;Nm
427
+ this.sgr.fg = Number.parseInt(parts[i + 2]!, 10)
428
+ i += 2
429
+ } else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
430
+ // True color: \x1b[38;2;R;G;Bm
431
+ this.sgr.fg = {
432
+ r: Number.parseInt(parts[i + 2]!, 10),
433
+ g: Number.parseInt(parts[i + 3]!, 10),
434
+ b: Number.parseInt(parts[i + 4]!, 10),
435
+ }
436
+ i += 4
437
+ }
438
+ } else if (n === 39) {
439
+ this.sgr.fg = null
440
+ } else if (n >= 40 && n <= 47) {
441
+ // Standard background colors (0-7)
442
+ this.sgr.bg = n - 40
443
+ } else if (n === 48) {
444
+ // Extended background color
445
+ if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
446
+ // 256-color: \x1b[48;5;Nm
447
+ this.sgr.bg = Number.parseInt(parts[i + 2]!, 10)
448
+ i += 2
449
+ } else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
450
+ // True color: \x1b[48;2;R;G;Bm
451
+ this.sgr.bg = {
452
+ r: Number.parseInt(parts[i + 2]!, 10),
453
+ g: Number.parseInt(parts[i + 3]!, 10),
454
+ b: Number.parseInt(parts[i + 4]!, 10),
455
+ }
456
+ i += 4
457
+ }
458
+ } else if (n === 49) {
459
+ this.sgr.bg = null
460
+ } else if (n >= 90 && n <= 97) {
461
+ // Bright foreground colors (8-15)
462
+ this.sgr.fg = n - 90 + 8
463
+ } else if (n >= 100 && n <= 107) {
464
+ // Bright background colors (8-15)
465
+ this.sgr.bg = n - 100 + 8
466
+ }
467
+ // 58/59 (underline color) not tracked in diagnostic comparison
468
+ i++
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Get the character at a position.
474
+ */
475
+ getChar(x: number, y: number): string {
476
+ if (this.wideMarker[y]?.[x]) return ""
477
+ return this.grid[y]?.[x] ?? " "
478
+ }
479
+
480
+ /**
481
+ * Get the style at a position.
482
+ */
483
+ getStyle(x: number, y: number): VTermStyle {
484
+ return this.styles[y]?.[x] ?? createDefaultVTermStyle()
485
+ }
486
+
487
+ /**
488
+ * Compare with a TerminalBuffer and return character mismatches.
489
+ */
490
+ compareToBuffer(buffer: TerminalBuffer): ReplayMismatch[] {
491
+ const mismatches: ReplayMismatch[] = []
492
+ for (let y = 0; y < Math.min(this.height, buffer.height); y++) {
493
+ for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
494
+ if (buffer.isCellContinuation(x, y)) continue
495
+
496
+ const expected = buffer.getCellChar(x, y)
497
+ const actual = this.getChar(x, y)
498
+ if (expected !== actual) {
499
+ mismatches.push({ x, y, expected, actual })
500
+ }
501
+ }
502
+ }
503
+ return mismatches
504
+ }
505
+
506
+ /**
507
+ * Compare styles with a TerminalBuffer and return style mismatches.
508
+ * Checks fg, bg, bold, dim, italic, underline, blink, inverse, hidden, strikethrough.
509
+ */
510
+ compareStylesToBuffer(buffer: TerminalBuffer): StyleMismatch[] {
511
+ const mismatches: StyleMismatch[] = []
512
+ for (let y = 0; y < Math.min(this.height, buffer.height); y++) {
513
+ for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
514
+ if (buffer.isCellContinuation(x, y)) continue
515
+
516
+ const cell = buffer.getCell(x, y)
517
+ const actual = this.getStyle(x, y)
518
+ const diffs: string[] = []
519
+
520
+ // Compare foreground color
521
+ if (!colorEquals(actual.fg as Color, cell.fg)) {
522
+ diffs.push(`fg: ${formatVTermColor(actual.fg)} vs ${formatVTermColor(cell.fg)}`)
523
+ }
524
+ // Compare background color
525
+ if (!colorEquals(actual.bg as Color, cell.bg)) {
526
+ diffs.push(`bg: ${formatVTermColor(actual.bg)} vs ${formatVTermColor(cell.bg)}`)
527
+ }
528
+ // Compare text attributes
529
+ if (actual.bold !== !!cell.attrs.bold) {
530
+ diffs.push(`bold: ${actual.bold} vs ${!!cell.attrs.bold}`)
531
+ }
532
+ if (actual.dim !== !!cell.attrs.dim) {
533
+ diffs.push(`dim: ${actual.dim} vs ${!!cell.attrs.dim}`)
534
+ }
535
+ if (actual.italic !== !!cell.attrs.italic) {
536
+ diffs.push(`italic: ${actual.italic} vs ${!!cell.attrs.italic}`)
537
+ }
538
+ // Underline: buffer can have underline or underlineStyle
539
+ const expectedUnderline = !!cell.attrs.underline || !!cell.attrs.underlineStyle
540
+ if (actual.underline !== expectedUnderline) {
541
+ diffs.push(`underline: ${actual.underline} vs ${expectedUnderline}`)
542
+ }
543
+ if (actual.blink !== !!cell.attrs.blink) {
544
+ diffs.push(`blink: ${actual.blink} vs ${!!cell.attrs.blink}`)
545
+ }
546
+ if (actual.inverse !== !!cell.attrs.inverse) {
547
+ diffs.push(`inverse: ${actual.inverse} vs ${!!cell.attrs.inverse}`)
548
+ }
549
+ if (actual.hidden !== !!cell.attrs.hidden) {
550
+ diffs.push(`hidden: ${actual.hidden} vs ${!!cell.attrs.hidden}`)
551
+ }
552
+ if (actual.strikethrough !== !!cell.attrs.strikethrough) {
553
+ diffs.push(`strikethrough: ${actual.strikethrough} vs ${!!cell.attrs.strikethrough}`)
554
+ }
555
+
556
+ if (diffs.length > 0) {
557
+ mismatches.push({ x, y, char: cell.char, diffs })
558
+ }
559
+ }
560
+ }
561
+ return mismatches
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Create a default VTermStyle with all attributes reset to defaults.
567
+ */
568
+ function createDefaultVTermStyle(): VTermStyle {
569
+ return {
570
+ fg: null,
571
+ bg: null,
572
+ bold: false,
573
+ dim: false,
574
+ italic: false,
575
+ underline: false,
576
+ blink: false,
577
+ inverse: false,
578
+ hidden: false,
579
+ strikethrough: false,
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Format a color value for diagnostic display.
585
+ */
586
+ function formatVTermColor(c: number | { r: number; g: number; b: number } | null): string {
587
+ if (c === null) return "default"
588
+ if (typeof c === "number") return `${c}`
589
+ return `rgb(${c.r},${c.g},${c.b})`
590
+ }
591
+
592
+ // =============================================================================
593
+ // Constants
594
+ // =============================================================================
595
+
596
+ /** Commands that should preserve buffer content (only cursor position changes) */
597
+ const CURSOR_COMMANDS = new Set([
598
+ // Full names
599
+ "cursor_up",
600
+ "cursor_down",
601
+ "cursor_left",
602
+ "cursor_right",
603
+ // Short names
604
+ "up",
605
+ "down",
606
+ "left",
607
+ "right",
608
+ ])
609
+
610
+ // =============================================================================
611
+ // Helper Functions
612
+ // =============================================================================
613
+
614
+ /**
615
+ * Parse SILVERY_STABILITY_SKIP_LINES environment variable.
616
+ * Format: comma-separated integers, e.g., "0,-1"
617
+ */
618
+ function parseSkipLines(env?: string): number[] {
619
+ if (!env) return []
620
+ return env
621
+ .split(",")
622
+ .map((s) => Number.parseInt(s.trim(), 10))
623
+ .filter((n) => !isNaN(n))
624
+ }
625
+
626
+ /**
627
+ * Compare text content before and after a command.
628
+ * Returns the first mismatch found, or null if content matches.
629
+ *
630
+ * @param before - Text before command execution
631
+ * @param after - Text after command execution
632
+ * @param skipLines - Line indices to skip (supports negative indices from end)
633
+ */
634
+ function compareText(before: string, after: string, skipLines: number[]): TextMismatch | null {
635
+ const beforeLines = before.split("\n")
636
+ const afterLines = after.split("\n")
637
+ const maxLines = Math.max(beforeLines.length, afterLines.length)
638
+
639
+ // Build set of lines to skip, resolving negative indices
640
+ const skipSet = new Set<number>()
641
+ for (const line of skipLines) {
642
+ if (line >= 0) {
643
+ skipSet.add(line)
644
+ } else {
645
+ // Negative index: -1 = last line, -2 = second to last, etc.
646
+ skipSet.add(maxLines + line)
647
+ }
648
+ }
649
+
650
+ for (let i = 0; i < maxLines; i++) {
651
+ if (skipSet.has(i)) continue
652
+ const b = beforeLines[i] ?? ""
653
+ const a = afterLines[i] ?? ""
654
+ if (b !== a) {
655
+ return { line: i, before: b, after: a }
656
+ }
657
+ }
658
+ return null
659
+ }
660
+
661
+ // =============================================================================
662
+ // Layout Invariant Checks
663
+ // =============================================================================
664
+
665
+ /**
666
+ * Check layout tree integrity. Returns violation messages, or empty array if valid.
667
+ *
668
+ * Checks:
669
+ * - All rect dimensions are finite and non-negative (width >= 0, height >= 0)
670
+ * - All positions are finite (x, y are valid numbers)
671
+ * - No NaN values in computed layout
672
+ * - Children don't overflow parent bounds (1px tolerance for rounding)
673
+ * - Skips overflow check for nodes with overflow:hidden/scroll (they intentionally clip)
674
+ */
675
+ export function checkLayoutInvariants(node: TeaNode): string[] {
676
+ const violations: string[] = []
677
+ walkLayout(node, null, violations)
678
+ return violations
679
+ }
680
+
681
+ function walkLayout(
682
+ node: TeaNode,
683
+ parentRect: {
684
+ x: number
685
+ y: number
686
+ width: number
687
+ height: number
688
+ clipped: boolean
689
+ } | null,
690
+ violations: string[],
691
+ ): void {
692
+ const rect = node.contentRect
693
+ if (!rect) return // No layout computed yet — skip
694
+
695
+ const id = (node.props as BoxProps).id ?? node.type
696
+
697
+ // Check finite and non-negative dimensions
698
+ if (!Number.isFinite(rect.width) || rect.width < 0) {
699
+ violations.push(`${id}: invalid width ${rect.width}`)
700
+ }
701
+ if (!Number.isFinite(rect.height) || rect.height < 0) {
702
+ violations.push(`${id}: invalid height ${rect.height}`)
703
+ }
704
+ if (!Number.isFinite(rect.x)) {
705
+ violations.push(`${id}: invalid x ${rect.x}`)
706
+ }
707
+ if (!Number.isFinite(rect.y)) {
708
+ violations.push(`${id}: invalid y ${rect.y}`)
709
+ }
710
+
711
+ // Check children don't overflow parent (with 1px tolerance)
712
+ if (parentRect && !parentRect.clipped) {
713
+ const TOLERANCE = 1
714
+ if (rect.x + rect.width > parentRect.x + parentRect.width + TOLERANCE) {
715
+ violations.push(`${id}: overflows parent right (${rect.x + rect.width} > ${parentRect.x + parentRect.width})`)
716
+ }
717
+ if (rect.y + rect.height > parentRect.y + parentRect.height + TOLERANCE) {
718
+ violations.push(`${id}: overflows parent bottom (${rect.y + rect.height} > ${parentRect.y + parentRect.height})`)
719
+ }
720
+ if (rect.x < parentRect.x - TOLERANCE) {
721
+ violations.push(`${id}: overflows parent left (${rect.x} < ${parentRect.x})`)
722
+ }
723
+ if (rect.y < parentRect.y - TOLERANCE) {
724
+ violations.push(`${id}: overflows parent top (${rect.y} < ${parentRect.y})`)
725
+ }
726
+ }
727
+
728
+ // Determine if this node clips its children
729
+ const overflow = (node.props as BoxProps).overflow
730
+ const clipped = overflow === "hidden" || overflow === "scroll"
731
+
732
+ const childParentRect = {
733
+ x: rect.x,
734
+ y: rect.y,
735
+ width: rect.width,
736
+ height: rect.height,
737
+ clipped,
738
+ }
739
+
740
+ for (const child of node.children) {
741
+ walkLayout(child, childParentRect, violations)
742
+ }
743
+ }
744
+
745
+ // =============================================================================
746
+ // Plugin Implementation
747
+ // =============================================================================
748
+
749
+ /**
750
+ * Add diagnostic checking to an app with commands.
751
+ *
752
+ * Wraps the `cmd` proxy to intercept all command executions and run checks:
753
+ * - **All commands**: Check that incremental render matches fresh render
754
+ * - **Cursor commands**: Also check that buffer content didn't change
755
+ *
756
+ * **All checks are enabled by default** when you call this function.
757
+ * The principle: if you wrapped with withDiagnostics(), you want diagnostics.
758
+ *
759
+ * @param app - App with command system (from withCommands)
760
+ * @param options - Diagnostic check configuration (all enabled by default)
761
+ * @returns App with wrapped cmd that runs diagnostic checks
762
+ */
763
+ export function withDiagnostics<T extends AppWithCommands>(app: T, options: DiagnosticOptions = {}): T {
764
+ // All checks enabled by default when plugin is used
765
+ const {
766
+ checkIncremental = true,
767
+ checkStability = true,
768
+ checkReplay = true,
769
+ checkLayout = true,
770
+ skipLines = parseSkipLines(process.env.SILVERY_STABILITY_SKIP_LINES),
771
+ captureOnFailure = false,
772
+ screenshotDir = "/tmp/silvery-diagnostics",
773
+ } = options
774
+
775
+ // If all checks are explicitly disabled, return app unchanged
776
+ if (!checkIncremental && !checkStability && !checkReplay && !checkLayout) return app
777
+
778
+ /** Capture screenshot on diagnostic failure (best-effort, never masks original error) */
779
+ async function captureFailureScreenshot(commandId: string, checkType: string): Promise<string | null> {
780
+ if (!captureOnFailure) return null
781
+ try {
782
+ await mkdir(screenshotDir, { recursive: true })
783
+ const filename = `fail-${commandId}-${checkType}.png`
784
+ const filepath = join(screenshotDir, filename)
785
+ await app.screenshot(filepath)
786
+ return filepath
787
+ } catch {
788
+ return null
789
+ }
790
+ }
791
+
792
+ // Wrap the cmd proxy
793
+ const wrappedCmd = new Proxy(app.cmd, {
794
+ get(target, prop: string | symbol): unknown {
795
+ // Handle symbol access (for JS internals)
796
+ if (typeof prop === "symbol") return Reflect.get(target, prop)
797
+
798
+ const original = Reflect.get(target, prop)
799
+
800
+ // Pass through non-function properties and special methods
801
+ if (typeof original !== "function") return original
802
+ if (prop === "all" || prop === "describe") return original
803
+
804
+ // Wrap command execution
805
+ const command = original as Command
806
+ const wrapped = async () => {
807
+ // Capture state before command
808
+ const beforeText = app.text
809
+ const beforeBuffer = checkReplay ? app.lastBuffer() : null
810
+
811
+ // Execute the original command
812
+ await command()
813
+
814
+ // Check 1: Incremental vs fresh render
815
+ if (checkIncremental) {
816
+ const incremental = app.lastBuffer()
817
+ // freshRender() may throw if not available (non-test renderer)
818
+ try {
819
+ const fresh = app.freshRender()
820
+ if (incremental && fresh) {
821
+ const mismatch = compareBuffers(incremental, fresh)
822
+ if (mismatch) {
823
+ // Include full buffer text for debugging
824
+ const incrementalText = app.text
825
+ const freshText = fresh
826
+ ? Array.from({ length: fresh.height }, (_, y) =>
827
+ Array.from({ length: fresh.width }, (_, x) => fresh.getCellChar(x, y)).join(""),
828
+ ).join("\n")
829
+ : "(no fresh buffer)"
830
+ const screenshotPath = await captureFailureScreenshot(command.id, "incremental")
831
+ throw new Error(
832
+ `SILVERY_DIAGNOSTIC: Incremental/fresh mismatch after ${command.id}\n` +
833
+ formatMismatch(mismatch, {
834
+ key: command.id,
835
+ incrementalText,
836
+ freshText,
837
+ }) +
838
+ (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
839
+ )
840
+ }
841
+ }
842
+ } catch (e) {
843
+ // If freshRender isn't available, skip the check
844
+ if (!(e instanceof Error) || !e.message.includes("only available in test renderer")) {
845
+ throw e
846
+ }
847
+ }
848
+ }
849
+
850
+ // Check 2: Content stability for cursor commands
851
+ if (checkStability && CURSOR_COMMANDS.has(command.id)) {
852
+ const afterText = app.text
853
+ const mismatch = compareText(beforeText, afterText, skipLines)
854
+ if (mismatch) {
855
+ const screenshotPath = await captureFailureScreenshot(command.id, "stability")
856
+ throw new Error(
857
+ `SILVERY_DIAGNOSTIC: Content changed after cursor move ${command.id}\n` +
858
+ ` Line ${mismatch.line}: "${mismatch.before}" → "${mismatch.after}"` +
859
+ (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
860
+ )
861
+ }
862
+ }
863
+
864
+ // Check 3: ANSI replay produces correct result
865
+ if (checkReplay && beforeBuffer) {
866
+ const afterBuffer = app.lastBuffer()
867
+ if (afterBuffer) {
868
+ // Get the ANSI diff that would be sent to terminal
869
+ const ansiDiff = outputPhase(beforeBuffer, afterBuffer)
870
+
871
+ // Create virtual terminal initialized with previous state
872
+ const vterm = new VirtualTerminal(afterBuffer.width, afterBuffer.height)
873
+ vterm.loadFromBuffer(beforeBuffer)
874
+
875
+ // Apply the ANSI diff
876
+ vterm.applyAnsi(ansiDiff)
877
+
878
+ // Compare character content
879
+ const mismatches = vterm.compareToBuffer(afterBuffer)
880
+ if (mismatches.length > 0) {
881
+ const first5 = mismatches.slice(0, 5)
882
+ const details = first5
883
+ .map((m) => ` (${m.x},${m.y}): expected="${m.expected}" actual="${m.actual}"`)
884
+ .join("\n")
885
+ const screenshotPath = await captureFailureScreenshot(command.id, "replay")
886
+ throw new Error(
887
+ `SILVERY_DIAGNOSTIC: ANSI replay mismatch after ${command.id}\n` +
888
+ ` ${mismatches.length} cells differ:\n${details}` +
889
+ (mismatches.length > 5 ? `\n ... and ${mismatches.length - 5} more` : "") +
890
+ (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
891
+ )
892
+ }
893
+
894
+ // Compare SGR styles (fg, bg, bold, italic, underline, etc.)
895
+ const styleMismatches = vterm.compareStylesToBuffer(afterBuffer)
896
+ if (styleMismatches.length > 0) {
897
+ const first5 = styleMismatches.slice(0, 5)
898
+ const details = first5.map((m) => ` (${m.x},${m.y}) char="${m.char}": ${m.diffs.join(", ")}`).join("\n")
899
+ const screenshotPath = await captureFailureScreenshot(command.id, "replay-style")
900
+ throw new Error(
901
+ `SILVERY_DIAGNOSTIC: ANSI replay style mismatch after ${command.id}\n` +
902
+ ` ${styleMismatches.length} cells have style differences:\n${details}` +
903
+ (styleMismatches.length > 5 ? `\n ... and ${styleMismatches.length - 5} more` : "") +
904
+ (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
905
+ )
906
+ }
907
+ }
908
+ }
909
+
910
+ // Check 4: Layout tree integrity
911
+ if (checkLayout) {
912
+ const root = app.getContainer()
913
+ const violations = checkLayoutInvariants(root)
914
+ if (violations.length > 0) {
915
+ const details = violations
916
+ .slice(0, 10)
917
+ .map((v) => ` ${v}`)
918
+ .join("\n")
919
+ const screenshotPath = await captureFailureScreenshot(command.id, "layout")
920
+ throw new Error(
921
+ `SILVERY_DIAGNOSTIC: Layout invariant violation after ${command.id}\n` +
922
+ ` ${violations.length} violation(s):\n${details}` +
923
+ (violations.length > 10 ? `\n ... and ${violations.length - 10} more` : "") +
924
+ (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""),
925
+ )
926
+ }
927
+ }
928
+ }
929
+
930
+ // Copy metadata from original command
931
+ Object.defineProperties(wrapped, {
932
+ id: { value: command.id, enumerable: true },
933
+ name: { value: command.name, enumerable: true },
934
+ help: { value: command.help, enumerable: true },
935
+ keys: { value: command.keys, enumerable: true },
936
+ })
937
+
938
+ return wrapped
939
+ },
940
+
941
+ has(target, prop): boolean {
942
+ return Reflect.has(target, prop)
943
+ },
944
+
945
+ ownKeys(target): (string | symbol)[] {
946
+ return Reflect.ownKeys(target)
947
+ },
948
+
949
+ getOwnPropertyDescriptor(target, prop): PropertyDescriptor | undefined {
950
+ return Reflect.getOwnPropertyDescriptor(target, prop)
951
+ },
952
+ })
953
+
954
+ return { ...app, cmd: wrappedCmd as Cmd } as T
955
+ }