@silvery/term 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.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. package/src/xterm/xterm-provider.ts +204 -0
@@ -0,0 +1,723 @@
1
+ /**
2
+ * Silvery Render Scheduler
3
+ *
4
+ * Batches rapid state updates to prevent flicker and improve performance.
5
+ * Uses queueMicrotask for coalescing multiple synchronous state changes
6
+ * into a single render pass.
7
+ *
8
+ * Features:
9
+ * - Microtask-based batching (coalesces synchronous updates)
10
+ * - Frame batching to prevent flicker
11
+ * - Resize handling with debounce
12
+ * - Clean shutdown
13
+ */
14
+
15
+ import { appendFileSync } from "node:fs"
16
+ import { type Logger, createLogger } from "loggily"
17
+ import { type TerminalBuffer, bufferToText, cellEquals } from "./buffer"
18
+ import { buildMismatchContext, formatMismatchContext } from "@silvery/test/debug-mismatch"
19
+ import {
20
+ type ResolvedNonTTYMode as ResolvedMode,
21
+ countLines,
22
+ createOutputTransformer,
23
+ resolveNonTTYMode,
24
+ stripAnsi,
25
+ } from "./non-tty"
26
+ import { getCursorState as globalGetCursorState, type CursorAccessors } from "@silvery/react/hooks/useCursor"
27
+ import { copyToClipboard as copyToClipboardImpl } from "./clipboard"
28
+ import { ANSI, notify as notifyTerminal, setCursorStyle, resetCursorStyle } from "./output"
29
+ import { executeRender, type PipelineConfig } from "./pipeline"
30
+ import type { ContentPhaseStats } from "./pipeline/types"
31
+ import type { TeaNode } from "@silvery/tea/types"
32
+
33
+ const log = createLogger("silvery:scheduler")
34
+
35
+ /**
36
+ * Whether synchronized update mode (DEC 2026) is enabled.
37
+ *
38
+ * Disabled by default due to a Ghostty rendering bug where incremental
39
+ * cursor-positioned updates inside a sync region cause progressive visual
40
+ * corruption. Works correctly in Kitty. Full renders (bufferToAnsi) work
41
+ * fine with sync — only incremental diff output (changesToAnsi) triggers it.
42
+ *
43
+ * Set SILVERY_SYNC_UPDATE=1 to force-enable (e.g., for testing in Kitty).
44
+ * TODO: Re-enable by default once the Ghostty bug is fixed.
45
+ * See: https://github.com/ghostty-org/ghostty/discussions/11002
46
+ */
47
+ const SYNC_UPDATE_ENABLED = process.env.SILVERY_SYNC_UPDATE === "1" || process.env.SILVERY_SYNC_UPDATE === "true"
48
+
49
+ // ============================================================================
50
+ // Errors
51
+ // ============================================================================
52
+
53
+ // Re-export from errors.ts (kept separate for React-free barrel imports)
54
+ export { IncrementalRenderMismatchError } from "./errors"
55
+
56
+ // ============================================================================
57
+ // Types
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Non-TTY mode for rendering in non-interactive environments.
62
+ */
63
+ export type NonTTYMode = "auto" | "tty" | "line-by-line" | "static" | "plain"
64
+
65
+ /**
66
+ * Resolved non-TTY mode after auto-detection.
67
+ */
68
+ export type ResolvedNonTTYMode = Exclude<NonTTYMode, "auto">
69
+
70
+ export interface SchedulerOptions {
71
+ /** stdout stream for writing output */
72
+ stdout: NodeJS.WriteStream
73
+ /** Root Silvery node */
74
+ root: TeaNode
75
+ /** Debug mode - logs render timing */
76
+ debug?: boolean
77
+ /** Minimum time between frames in ms (default: 16 for ~60fps) */
78
+ minFrameTime?: number
79
+ /** Render mode: fullscreen (absolute positioning) or inline (relative positioning) */
80
+ mode?: "fullscreen" | "inline"
81
+ /**
82
+ * Non-TTY mode for non-interactive environments (default: 'auto')
83
+ *
84
+ * - 'auto': Detect based on environment
85
+ * - 'tty': Force TTY mode
86
+ * - 'line-by-line': Simple line output
87
+ * - 'static': Only output final frame
88
+ * - 'plain': Strip all ANSI codes
89
+ */
90
+ nonTTYMode?: NonTTYMode
91
+ /** Slow frame warning threshold in ms (default: 50). Set to 0 to disable. */
92
+ slowFrameThreshold?: number
93
+ /** Pipeline configuration (caps-scoped measurer + output phase) */
94
+ pipelineConfig?: PipelineConfig
95
+ /** Per-instance cursor accessors. Falls back to module-level globals if not provided. */
96
+ cursorAccessors?: CursorAccessors
97
+ }
98
+
99
+ export interface RenderStats {
100
+ /** Number of renders executed */
101
+ renderCount: number
102
+ /** Number of renders skipped (batched) */
103
+ skippedCount: number
104
+ /** Last render duration in ms */
105
+ lastRenderTime: number
106
+ /** Average render time in ms */
107
+ avgRenderTime: number
108
+ }
109
+
110
+ // ============================================================================
111
+ // RenderScheduler Class
112
+ // ============================================================================
113
+
114
+ /**
115
+ * Schedules and batches render operations.
116
+ *
117
+ * Usage:
118
+ * ```ts
119
+ * const scheduler = new RenderScheduler({
120
+ * stdout: process.stdout,
121
+ * root: rootNode,
122
+ * });
123
+ *
124
+ * // Schedule renders (automatically batched)
125
+ * scheduler.scheduleRender();
126
+ * scheduler.scheduleRender(); // This won't cause duplicate render
127
+ *
128
+ * // Force immediate render
129
+ * scheduler.forceRender();
130
+ *
131
+ * // Clean shutdown
132
+ * scheduler.dispose();
133
+ * ```
134
+ */
135
+ export class RenderScheduler {
136
+ private stdout: NodeJS.WriteStream
137
+ private root: TeaNode
138
+ private debugMode: boolean
139
+ private minFrameTime: number
140
+ private slowFrameThreshold: number
141
+ private mode: "fullscreen" | "inline"
142
+ private pipelineConfig?: PipelineConfig
143
+ private getCursorState: () => import("@silvery/react/hooks/useCursor").CursorState | null
144
+ private nonTTYMode: ResolvedMode
145
+ private outputTransformer: (content: string, prevLineCount: number) => string
146
+ private log: Logger
147
+
148
+ /** Previous buffer for diffing */
149
+ private prevBuffer: TerminalBuffer | null = null
150
+
151
+ /** Line count of previous render (for non-TTY modes) */
152
+ private prevLineCount = 0
153
+
154
+ /** Accumulated output for static mode */
155
+ private staticOutput = ""
156
+
157
+ /** Is a render currently scheduled? */
158
+ private renderScheduled = false
159
+
160
+ /** Last render timestamp */
161
+ private lastRenderTime = 0
162
+
163
+ /** Pending frame timeout (for frame rate limiting) */
164
+ private frameTimeout: ReturnType<typeof setTimeout> | null = null
165
+
166
+ /** Resize listener cleanup */
167
+ private resizeCleanup: (() => void) | null = null
168
+
169
+ /** Render statistics */
170
+ private stats: RenderStats = {
171
+ renderCount: 0,
172
+ skippedCount: 0,
173
+ lastRenderTime: 0,
174
+ avgRenderTime: 0,
175
+ }
176
+
177
+ /** Is the scheduler disposed? */
178
+ private disposed = false
179
+
180
+ /** Is the scheduler paused? When paused, renders are deferred until resume. */
181
+ private paused = false
182
+
183
+ /** Was a render requested while paused? */
184
+ private pendingWhilePaused = false
185
+
186
+ /**
187
+ * Lines written to stdout between renders (inline mode only).
188
+ * When useScrollback or other code writes to stdout, those lines
189
+ * displace the terminal cursor. This offset is consumed on the next render.
190
+ */
191
+ private scrollbackOffset = 0
192
+
193
+ constructor(options: SchedulerOptions) {
194
+ this.stdout = options.stdout
195
+ this.root = options.root
196
+ this.debugMode = options.debug ?? false
197
+ this.minFrameTime = options.minFrameTime ?? 16
198
+ this.slowFrameThreshold = options.slowFrameThreshold ?? 50
199
+ this.mode = options.mode ?? "fullscreen"
200
+ this.pipelineConfig = options.pipelineConfig
201
+ this.getCursorState = options.cursorAccessors?.getCursorState ?? globalGetCursorState
202
+ this.log = createLogger("silvery:scheduler") as unknown as Logger
203
+
204
+ // Resolve non-TTY mode based on environment
205
+ this.nonTTYMode = resolveNonTTYMode({
206
+ mode: options.nonTTYMode,
207
+ stdout: this.stdout,
208
+ })
209
+ this.outputTransformer = createOutputTransformer(this.nonTTYMode)
210
+
211
+ log.debug?.(`non-TTY mode resolved to: ${this.nonTTYMode}`)
212
+
213
+ // Listen for terminal resize (only in TTY mode)
214
+ if (this.nonTTYMode === "tty") {
215
+ this.setupResizeListener()
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Get the resolved non-TTY mode.
221
+ */
222
+ getNonTTYMode(): ResolvedMode {
223
+ return this.nonTTYMode
224
+ }
225
+
226
+ // ==========================================================================
227
+ // Public API
228
+ // ==========================================================================
229
+
230
+ /**
231
+ * Schedule a render on the next microtask.
232
+ *
233
+ * Multiple calls within the same synchronous execution will be
234
+ * coalesced into a single render.
235
+ */
236
+ scheduleRender(): void {
237
+ if (this.disposed) return
238
+
239
+ if (this.paused) {
240
+ this.pendingWhilePaused = true
241
+ return
242
+ }
243
+
244
+ if (this.renderScheduled) {
245
+ this.stats.skippedCount++
246
+ log.debug?.(`render skipped (batched), total: ${this.stats.skippedCount}`)
247
+ return
248
+ }
249
+
250
+ this.renderScheduled = true
251
+ log.debug?.("render scheduled")
252
+
253
+ // Use queueMicrotask for batching synchronous updates
254
+ queueMicrotask(() => {
255
+ this.renderScheduled = false
256
+
257
+ if (this.disposed) return
258
+
259
+ // Check frame rate limiting
260
+ const now = Date.now()
261
+ const timeSinceLastRender = now - this.lastRenderTime
262
+
263
+ if (timeSinceLastRender < this.minFrameTime) {
264
+ // Schedule for next frame
265
+ log.debug?.(`frame limited, delay: ${this.minFrameTime - timeSinceLastRender}ms`)
266
+ this.scheduleNextFrame(this.minFrameTime - timeSinceLastRender)
267
+ } else {
268
+ this.executeRender()
269
+ }
270
+ })
271
+ }
272
+
273
+ /**
274
+ * Force an immediate render, bypassing batching.
275
+ */
276
+ forceRender(): void {
277
+ if (this.disposed) return
278
+
279
+ if (this.paused) {
280
+ this.pendingWhilePaused = true
281
+ return
282
+ }
283
+
284
+ // Cancel any pending scheduled render
285
+ this.renderScheduled = false
286
+ if (this.frameTimeout) {
287
+ clearTimeout(this.frameTimeout)
288
+ this.frameTimeout = null
289
+ }
290
+
291
+ this.executeRender()
292
+ }
293
+
294
+ /**
295
+ * Get render statistics.
296
+ */
297
+ getStats(): RenderStats {
298
+ return { ...this.stats }
299
+ }
300
+
301
+ /**
302
+ * Report lines written to stdout between renders (inline mode only).
303
+ * This adjusts cursor position tracking so the next render accounts
304
+ * for the extra lines. Used by useScrollback to notify the scheduler
305
+ * when it writes frozen items to stdout.
306
+ */
307
+ addScrollbackLines(lines: number): void {
308
+ if (this.mode !== "inline" || lines <= 0) return
309
+ this.scrollbackOffset += lines
310
+ }
311
+
312
+ /**
313
+ * Send a terminal notification.
314
+ *
315
+ * Auto-detects terminal type and uses the best available method:
316
+ * - iTerm2 → OSC 9
317
+ * - Kitty → OSC 99
318
+ * - Others → BEL
319
+ */
320
+ notify(message: string, opts?: { title?: string }): void {
321
+ if (this.disposed) return
322
+ notifyTerminal(this.stdout, message, opts)
323
+ }
324
+
325
+ /**
326
+ * Copy text to the system clipboard via OSC 52.
327
+ * Works across SSH sessions in terminals that support it.
328
+ */
329
+ copyToClipboard(text: string): void {
330
+ if (this.disposed) return
331
+ copyToClipboardImpl(this.stdout, text)
332
+ }
333
+
334
+ /**
335
+ * Pause rendering. While paused, scheduled and forced renders are deferred.
336
+ * Input handling continues normally. Call resume() to unpause and force a
337
+ * full redraw. Used for screen-switching (alt screen ↔ normal screen).
338
+ */
339
+ pause(): void {
340
+ if (this.disposed || this.paused) return
341
+ this.paused = true
342
+ this.pendingWhilePaused = false
343
+ log.debug?.("scheduler paused")
344
+ }
345
+
346
+ /**
347
+ * Resume rendering after pause. Resets the previous buffer so the next
348
+ * render outputs everything (full redraw), then forces an immediate render.
349
+ */
350
+ resume(): void {
351
+ if (this.disposed || !this.paused) return
352
+ this.paused = false
353
+ log.debug?.("scheduler resumed")
354
+
355
+ // Reset buffer for full redraw (alt screen was switched)
356
+ this.prevBuffer = null
357
+
358
+ // If anything was deferred, render now
359
+ if (this.pendingWhilePaused) {
360
+ this.pendingWhilePaused = false
361
+ this.executeRender()
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Whether the scheduler is currently paused.
367
+ */
368
+ isPaused(): boolean {
369
+ return this.paused
370
+ }
371
+
372
+ /**
373
+ * Clear the terminal and reset buffer.
374
+ */
375
+ clear(): void {
376
+ if (this.disposed) return
377
+
378
+ // Clear screen and keep cursor hidden
379
+ this.stdout.write("\x1b[2J\x1b[H\x1b[?25l")
380
+
381
+ // Reset buffer so next render outputs everything
382
+ this.prevBuffer = null
383
+ }
384
+
385
+ /**
386
+ * Dispose the scheduler and clean up resources.
387
+ */
388
+ [Symbol.dispose](): void {
389
+ this.dispose()
390
+ }
391
+
392
+ dispose(): void {
393
+ if (this.disposed) return
394
+
395
+ log.info?.(
396
+ `dispose: renders=${this.stats.renderCount}, skipped=${this.stats.skippedCount}, avg=${Math.round(this.stats.avgRenderTime)}ms`,
397
+ )
398
+ this.disposed = true
399
+
400
+ // Cancel pending renders
401
+ this.renderScheduled = false
402
+ if (this.frameTimeout) {
403
+ clearTimeout(this.frameTimeout)
404
+ this.frameTimeout = null
405
+ }
406
+
407
+ // Remove resize listener
408
+ if (this.resizeCleanup) {
409
+ this.resizeCleanup()
410
+ this.resizeCleanup = null
411
+ }
412
+
413
+ // In static mode, output the final frame on dispose
414
+ if (this.nonTTYMode === "static" && this.staticOutput) {
415
+ this.stdout.write(this.staticOutput)
416
+ this.stdout.write("\n")
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Get the last rendered output (for static mode).
422
+ * Returns the plain text output that would be written on dispose.
423
+ */
424
+ getStaticOutput(): string {
425
+ return this.staticOutput
426
+ }
427
+
428
+ // ==========================================================================
429
+ // Private Methods
430
+ // ==========================================================================
431
+
432
+ /**
433
+ * Schedule render for next frame (frame rate limiting).
434
+ */
435
+ private scheduleNextFrame(delay: number): void {
436
+ if (this.frameTimeout) return
437
+
438
+ this.frameTimeout = setTimeout(() => {
439
+ this.frameTimeout = null
440
+ if (!this.disposed) {
441
+ this.executeRender()
442
+ }
443
+ }, delay)
444
+ }
445
+
446
+ /**
447
+ * Execute the actual render.
448
+ */
449
+ private executeRender(): void {
450
+ using render = this.log.span("render")
451
+ const startTime = Date.now()
452
+
453
+ try {
454
+ // Get terminal dimensions
455
+ const width = this.stdout.columns ?? 80
456
+ // Inline mode: use NaN height so layout engine auto-sizes to content.
457
+ // Fullscreen mode: use terminal rows as the constraint.
458
+ const height = this.mode === "inline" ? NaN : (this.stdout.rows ?? 24)
459
+
460
+ log.debug?.(`render #${this.stats.renderCount + 1}: ${width}x${height}, nonTTYMode=${this.nonTTYMode}`)
461
+
462
+ // Run render pipeline
463
+ const scrollbackOffset = this.scrollbackOffset
464
+ this.scrollbackOffset = 0 // Consume the offset
465
+ // For inline mode, pass cursor state into the pipeline so the output
466
+ // phase can position the real terminal cursor at the useCursor() location.
467
+ const inlineCursor = this.mode === "inline" ? this.getCursorState() : undefined
468
+ const { output, buffer } = executeRender(
469
+ this.root,
470
+ width,
471
+ height,
472
+ this.prevBuffer,
473
+ {
474
+ mode: this.mode,
475
+ scrollbackOffset,
476
+ termRows: this.mode === "inline" ? (this.stdout.rows ?? 24) : undefined,
477
+ cursorPos: inlineCursor,
478
+ },
479
+ this.pipelineConfig,
480
+ )
481
+
482
+ // Transform output based on non-TTY mode
483
+ let transformedOutput: string
484
+ if (this.nonTTYMode === "tty") {
485
+ // Pass through unchanged
486
+ transformedOutput = output
487
+ } else if (this.nonTTYMode === "static") {
488
+ // Store for final output, don't write yet
489
+ this.staticOutput = stripAnsi(output)
490
+ transformedOutput = ""
491
+ } else {
492
+ // Apply line-by-line or plain transformation
493
+ transformedOutput = this.outputTransformer(output, this.prevLineCount)
494
+ this.prevLineCount = countLines(output)
495
+ }
496
+
497
+ // Build cursor control suffix (position + show/hide).
498
+ // This goes after rendered content so the terminal cursor lands
499
+ // at the right spot after painting.
500
+ let cursorSuffix = ""
501
+ if (this.nonTTYMode === "tty") {
502
+ const cursor = this.getCursorState()
503
+ if (cursor?.visible) {
504
+ const shapeSeq = cursor.shape ? setCursorStyle(cursor.shape) : resetCursorStyle()
505
+ cursorSuffix = ANSI.moveCursor(cursor.x, cursor.y) + shapeSeq + ANSI.CURSOR_SHOW
506
+ } else {
507
+ cursorSuffix = ANSI.CURSOR_HIDE
508
+ }
509
+ }
510
+
511
+ // Write output wrapped with synchronized update (DEC 2026) for TTY mode.
512
+ // This tells the terminal to batch the output and paint atomically,
513
+ // preventing tearing during rapid screen updates.
514
+ if (transformedOutput.length > 0 || cursorSuffix.length > 0) {
515
+ const fullOutput =
516
+ this.nonTTYMode === "tty" && SYNC_UPDATE_ENABLED
517
+ ? `${ANSI.SYNC_BEGIN}${transformedOutput}${cursorSuffix}${ANSI.SYNC_END}`
518
+ : transformedOutput + cursorSuffix
519
+
520
+ // Debug: log output sizes to detect potential pipe buffer splits
521
+ if (log.debug) {
522
+ const bytes = Buffer.byteLength(fullOutput)
523
+ log.debug?.(
524
+ `stdout.write: ${bytes} bytes (${transformedOutput.length} chars output + ${cursorSuffix.length} chars cursor)`,
525
+ )
526
+ if (bytes > 16384) {
527
+ log.warn?.(
528
+ `large output: ${bytes} bytes may exceed pipe buffer (16KB on macOS), risk of mid-sequence split`,
529
+ )
530
+ }
531
+ }
532
+
533
+ // Capture raw ANSI output to file for debugging garbled rendering
534
+ const captureFile = process.env.SILVERY_CAPTURE_OUTPUT
535
+ if (captureFile) {
536
+ const fs = require("fs")
537
+ fs.appendFileSync(
538
+ captureFile,
539
+ `--- FRAME ${this.stats.renderCount + 1} (${Buffer.byteLength(fullOutput)} bytes) ---\n`,
540
+ )
541
+ fs.appendFileSync(captureFile, fullOutput)
542
+ fs.appendFileSync(captureFile, "\n")
543
+ }
544
+
545
+ this.stdout.write(fullOutput)
546
+ }
547
+
548
+ // Save buffer for next diff
549
+ this.prevBuffer = buffer
550
+
551
+ // SILVERY_STRICT or SILVERY_CHECK_INCREMENTAL: compare incremental render against fresh render
552
+ const strictEnv = process.env.SILVERY_STRICT || process.env.SILVERY_CHECK_INCREMENTAL
553
+ const strictMode = strictEnv && strictEnv !== "0" && strictEnv !== "false"
554
+ if (strictMode && this.stats.renderCount > 0) {
555
+ const renderNum = this.stats.renderCount + 1
556
+ const { buffer: freshBuffer } = executeRender(
557
+ this.root,
558
+ width,
559
+ height,
560
+ null,
561
+ {
562
+ mode: this.mode === "fullscreen" ? "fullscreen" : "inline",
563
+ skipLayoutNotifications: true,
564
+ },
565
+ this.pipelineConfig,
566
+ )
567
+ let found = false
568
+ for (let y = 0; y < buffer.height && !found; y++) {
569
+ for (let x = 0; x < buffer.width && !found; x++) {
570
+ const a = buffer.getCell(x, y)
571
+ const b = freshBuffer.getCell(x, y)
572
+ if (!cellEquals(a, b)) {
573
+ found = true
574
+
575
+ // Build rich debug context
576
+ const ctx = buildMismatchContext(this.root, x, y, a, b, renderNum)
577
+
578
+ // Capture content-phase instrumentation snapshot
579
+ const contentPhaseStats: ContentPhaseStats | undefined = (globalThis as any).__silvery_content_detail
580
+ ? structuredClone((globalThis as any).__silvery_content_detail)
581
+ : undefined
582
+
583
+ const debugInfo = formatMismatchContext(ctx, contentPhaseStats)
584
+
585
+ // Include text output for full picture
586
+ const incText = bufferToText(buffer)
587
+ const freshText = bufferToText(freshBuffer)
588
+ const msg = debugInfo + `--- incremental ---\n${incText}\n--- fresh ---\n${freshText}`
589
+
590
+ if (process.env.DEBUG_LOG) {
591
+ appendFileSync(process.env.DEBUG_LOG, msg + "\n")
592
+ }
593
+ log.error?.(msg)
594
+ // Throw special error that won't be caught by general error handler
595
+ throw new IncrementalRenderMismatchError(msg, {
596
+ contentPhaseStats,
597
+ mismatchContext: ctx,
598
+ })
599
+ }
600
+ }
601
+ }
602
+ if (!found && process.env.DEBUG_LOG) {
603
+ appendFileSync(process.env.DEBUG_LOG, `SILVERY_CHECK_INCREMENTAL: render #${renderNum} OK\n`)
604
+ }
605
+ }
606
+
607
+ // Update stats
608
+ const renderTime = Date.now() - startTime
609
+ this.stats.renderCount++
610
+ this.stats.lastRenderTime = renderTime
611
+ this.stats.avgRenderTime =
612
+ (this.stats.avgRenderTime * (this.stats.renderCount - 1) + renderTime) / this.stats.renderCount
613
+ this.lastRenderTime = Date.now()
614
+
615
+ // Record span data
616
+ render.spanData.renderCount = this.stats.renderCount
617
+ render.spanData.renderTime = renderTime
618
+ render.spanData.bytes = transformedOutput.length
619
+
620
+ log.debug?.(
621
+ `render #${this.stats.renderCount} complete: ${renderTime}ms, output: ${transformedOutput.length} bytes`,
622
+ )
623
+
624
+ // First render is always slow (initialization); use 5x threshold for it
625
+ const threshold = this.stats.renderCount <= 1 ? this.slowFrameThreshold * 5 : this.slowFrameThreshold
626
+ if (threshold > 0 && renderTime > threshold) {
627
+ log.warn?.(
628
+ `slow frame: render #${this.stats.renderCount} took ${renderTime}ms (threshold: ${this.slowFrameThreshold}ms, bytes: ${transformedOutput.length})`,
629
+ )
630
+ }
631
+
632
+ if (this.debugMode) {
633
+ this.logDebug(`Render #${this.stats.renderCount} took ${renderTime}ms`)
634
+ }
635
+ } catch (error) {
636
+ // Log and re-throw all render errors - the app should handle cleanup
637
+ log.error?.(`render error: ${error}`)
638
+ this.logError("Render error:", error)
639
+ throw error
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Set up terminal resize listener.
645
+ */
646
+ private setupResizeListener(): void {
647
+ let resizeTimeout: ReturnType<typeof setTimeout> | null = null
648
+
649
+ const handleResize = () => {
650
+ // Debounce resize events
651
+ if (resizeTimeout) {
652
+ clearTimeout(resizeTimeout)
653
+ }
654
+
655
+ resizeTimeout = setTimeout(() => {
656
+ resizeTimeout = null
657
+
658
+ // Reset buffer to force full redraw
659
+ this.prevBuffer = null
660
+
661
+ // Schedule render
662
+ this.scheduleRender()
663
+ }, 50) // 50ms debounce
664
+ }
665
+
666
+ this.stdout.on("resize", handleResize)
667
+
668
+ this.resizeCleanup = () => {
669
+ this.stdout.off("resize", handleResize)
670
+ if (resizeTimeout) {
671
+ clearTimeout(resizeTimeout)
672
+ }
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Log debug message.
678
+ */
679
+ private logDebug(message: string): void {
680
+ // Write to stderr to avoid corrupting terminal output
681
+ process.stderr.write(`[Silvery Debug] ${message}\n`)
682
+ }
683
+
684
+ /**
685
+ * Log error message.
686
+ */
687
+ private logError(message: string, error: unknown): void {
688
+ process.stderr.write(`[Silvery Error] ${message}\n`)
689
+ if (error instanceof Error) {
690
+ process.stderr.write(`${error.stack ?? error.message}\n`)
691
+ } else {
692
+ process.stderr.write(`${String(error)}\n`)
693
+ }
694
+ }
695
+ }
696
+
697
+ // ============================================================================
698
+ // Factory Function
699
+ // ============================================================================
700
+
701
+ /**
702
+ * Create a render scheduler.
703
+ *
704
+ * @param options Scheduler options
705
+ * @returns A new RenderScheduler instance
706
+ */
707
+ export function createScheduler(options: SchedulerOptions): RenderScheduler {
708
+ return new RenderScheduler(options)
709
+ }
710
+
711
+ // ============================================================================
712
+ // Utility: Simple Render (for testing/debugging)
713
+ // ============================================================================
714
+
715
+ /**
716
+ * Render once to a string (for testing).
717
+ *
718
+ * Does not batch or diff - just runs the pipeline and returns ANSI output.
719
+ */
720
+ export function renderToString(root: TeaNode, width: number, height: number): string {
721
+ const { output } = executeRender(root, width, height, null)
722
+ return output
723
+ }