@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,1330 @@
1
+ /**
2
+ * Unified Render API for silvery
3
+ *
4
+ * Composable primitives:
5
+ * - render(element, opts | store) — always sync, returns full App
6
+ * - createStore(providers) — flattens providers into { cols, rows, events() }
7
+ * - run(app, events?) — event loop driver (sync or async)
8
+ * - createApp(element, providers) — sugar: render + createStore + run
9
+ * - createRenderer(opts | store) — factory with auto-cleanup
10
+ */
11
+
12
+ import { EventEmitter } from "node:events"
13
+ import React, { type ReactElement, type ReactNode, act } from "react"
14
+ import { type App, buildApp } from "./app.js"
15
+ import { type TerminalBuffer, cellEquals } from "./buffer.js"
16
+ import {
17
+ FocusManagerContext,
18
+ RuntimeContext,
19
+ type RuntimeContextValue,
20
+ StdoutContext,
21
+ TermContext,
22
+ } from "@silvery/react/context"
23
+ import { createFocusManager } from "@silvery/tea/focus-manager"
24
+ import {
25
+ type LayoutEngine,
26
+ ensureDefaultLayoutEngine,
27
+ isLayoutEngineInitialized,
28
+ setLayoutEngine,
29
+ } from "./layout-engine.js"
30
+ import { executeRender } from "./pipeline.js"
31
+ import {
32
+ createContainer,
33
+ createFiberRoot,
34
+ getContainerRoot,
35
+ reconciler,
36
+ setOnNodeRemoved,
37
+ } from "@silvery/react/reconciler"
38
+
39
+ import { createTerm } from "./ansi/index"
40
+ import { bufferToText } from "./buffer.js"
41
+ import { buildMismatchContext, formatMismatchContext } from "@silvery/test/debug-mismatch"
42
+ import { createCursorStore, CursorProvider } from "@silvery/react/hooks/useCursor"
43
+ import { keyToAnsi, parseKey, splitRawInput } from "@silvery/tea/keys"
44
+ import { parseBracketedPaste } from "./bracketed-paste"
45
+ import { IncrementalRenderMismatchError } from "./scheduler.js"
46
+ import type { ContentPhaseStats } from "./pipeline/types"
47
+ import { debugTree } from "@silvery/test/debug"
48
+
49
+ // ============================================================================
50
+ // Defensive Guards
51
+ // ============================================================================
52
+
53
+ /**
54
+ * Track all active (mounted) render instances to detect leaks.
55
+ * Uses a Set of WeakRefs so GC can clean up unreferenced apps.
56
+ */
57
+ const activeRenders = new Set<WeakRef<{ unmount: () => void; id: number }>>()
58
+ let renderIdCounter = 0
59
+
60
+ /**
61
+ * Maximum number of active render instances before throwing.
62
+ * Set high to allow large test files (each test may create a render without unmount),
63
+ * but catch genuine leaks like infinite loops creating renders.
64
+ */
65
+ const ACTIVE_RENDER_LEAK_THRESHOLD = 1000
66
+
67
+ /**
68
+ * Prune GC'd entries from activeRenders and return live count.
69
+ */
70
+ function pruneAndCountActiveRenders(): number {
71
+ let count = 0
72
+ for (const ref of activeRenders) {
73
+ if (ref.deref() === undefined) {
74
+ activeRenders.delete(ref)
75
+ } else {
76
+ count++
77
+ }
78
+ }
79
+ return count
80
+ }
81
+
82
+ /**
83
+ * Assert that the layout engine is initialized before rendering.
84
+ * This catches the common mistake of calling render() without await ensureEngine().
85
+ */
86
+ function assertLayoutEngine(): void {
87
+ if (!isLayoutEngineInitialized()) {
88
+ throw new Error(
89
+ "silvery: Layout engine not initialized. " +
90
+ "Call `await ensureEngine()` before render(), or use the testing module " +
91
+ "which initializes it automatically via top-level await.",
92
+ )
93
+ }
94
+ }
95
+
96
+ // ============================================================================
97
+ // Types
98
+ // ============================================================================
99
+
100
+ /**
101
+ * Options for headless render (no terminal).
102
+ */
103
+ export interface RenderOptions {
104
+ /** Terminal width for layout. Default: 80 */
105
+ cols?: number
106
+ /** Terminal height for layout. Default: 24 */
107
+ rows?: number
108
+ /** Layout engine to use. Default: current global engine */
109
+ layoutEngine?: LayoutEngine
110
+ /** Enable debug output. Default: false */
111
+ debug?: boolean
112
+ /** Enable incremental rendering. Default: true */
113
+ incremental?: boolean
114
+ /** Use Kitty keyboard protocol encoding for press(). When true, press() uses keyToKittyAnsi. */
115
+ kittyMode?: boolean
116
+ /**
117
+ * Use production-like single-pass layout in doRender().
118
+ *
119
+ * When false (default), doRender() runs a synchronous layout stabilization
120
+ * loop (up to 5 iterations) that re-runs executeRender whenever React
121
+ * commits new work from layout notifications (useContentRect, etc.).
122
+ *
123
+ * When true, doRender() does a single executeRender call (matching
124
+ * production's create-app.tsx behavior). Layout feedback effects are
125
+ * flushed via a separate act()/flushSyncWork() loop after doRender(),
126
+ * mimicking production's processEventBatch flush pattern.
127
+ *
128
+ * Use this to make tests exercise the same rendering pipeline as production.
129
+ */
130
+ singlePassLayout?: boolean
131
+ /**
132
+ * Auto-render on async React commits (e.g., setTimeout → setState).
133
+ *
134
+ * When true, the renderer schedules a microtask re-render whenever React
135
+ * commits new work outside of explicit render/sendInput/rerender calls.
136
+ * This enables test components with async state updates to produce new
137
+ * frames automatically.
138
+ *
139
+ * Default: false (renderer only renders on explicit triggers).
140
+ */
141
+ autoRender?: boolean
142
+ /**
143
+ * Callback fired after each frame render.
144
+ *
145
+ * Called with the frame output string and the underlying TerminalBuffer.
146
+ * Fires after initial render, sendInput, rerender, and (if autoRender)
147
+ * async state changes.
148
+ */
149
+ onFrame?: (frame: string, buffer: TerminalBuffer, contentHeight?: number) => void
150
+ /**
151
+ * Callback fired after each pipeline execution, before React effects flush.
152
+ *
153
+ * Called inside act() after executeRender produces the buffer but before
154
+ * useLayoutEffect/useEffect callbacks run. Use this to make pipeline output
155
+ * available to effects (e.g., Ink compat debug mode where useStdout().write()
156
+ * needs to replay the latest frame).
157
+ */
158
+ onBufferReady?: (frame: string, buffer: TerminalBuffer, contentHeight?: number) => void
159
+ /**
160
+ * Wrap the root element with additional providers.
161
+ * Called with the element after silvery's internal contexts are applied.
162
+ * Use this to inject additional context providers (e.g., Ink compatibility wrappers).
163
+ * The wrapper is applied INSIDE silvery's contexts, so wrapped providers
164
+ * can access silvery's Term, Stdout, FocusManager, and Runtime contexts.
165
+ */
166
+ wrapRoot?: (element: React.ReactElement) => React.ReactElement
167
+ /**
168
+ * External stdin stream to bridge to the renderer's input.
169
+ * When provided, readable data from this stream is forwarded to the renderer's
170
+ * input handler (equivalent to calling app.stdin.write()).
171
+ */
172
+ stdin?: NodeJS.ReadStream
173
+ }
174
+
175
+ /**
176
+ * Store — the TermDef-like environment for render().
177
+ * Provides cols, rows, and optionally an event stream for interactive mode.
178
+ */
179
+ export interface Store {
180
+ /** Terminal columns */
181
+ readonly cols: number
182
+ /** Terminal rows */
183
+ readonly rows: number
184
+ /** Async event stream (if present, enables interactive mode) */
185
+ events?(): AsyncIterable<StoreEvent>
186
+ }
187
+
188
+ /**
189
+ * Event from a store's event stream.
190
+ */
191
+ export interface StoreEvent {
192
+ type: string
193
+ data: unknown
194
+ }
195
+
196
+ /**
197
+ * Provider options for createStore().
198
+ */
199
+ export interface StoreOptions {
200
+ /** Terminal columns. Default: 80 */
201
+ cols?: number
202
+ /** Terminal rows. Default: 24 */
203
+ rows?: number
204
+ /** Event source for interactive mode */
205
+ events?: AsyncIterable<StoreEvent>
206
+ }
207
+
208
+ // ============================================================================
209
+ // Module Initialization
210
+ // ============================================================================
211
+
212
+ // Layout engine initialization promise (lazy)
213
+ let engineReady: Promise<void> | null = null
214
+
215
+ /**
216
+ * Ensure layout engine is initialized (async, cached).
217
+ */
218
+ export async function ensureEngine(): Promise<void> {
219
+ if (isLayoutEngineInitialized()) return
220
+ if (!engineReady) {
221
+ engineReady = ensureDefaultLayoutEngine()
222
+ }
223
+ await engineReady
224
+ }
225
+
226
+ // ============================================================================
227
+ // render() — sync, returns full App
228
+ // ============================================================================
229
+
230
+ /**
231
+ * Internal state for a render instance.
232
+ */
233
+ interface RenderInstance {
234
+ frames: string[]
235
+ container: ReturnType<typeof createContainer>
236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- React reconciler internal type
237
+ fiberRoot: any
238
+ prevBuffer: TerminalBuffer | null
239
+ mounted: boolean
240
+ /** True while inside act() or doRender() — detects re-entrant calls */
241
+ rendering: boolean
242
+ columns: number
243
+ rows: number
244
+ inputEmitter: EventEmitter
245
+ debug: boolean
246
+ incremental: boolean
247
+ /** Render count for SILVERY_STRICT checking (skip first render) */
248
+ renderCount: number
249
+ /** Use production-like single-pass layout (no stabilization loop) */
250
+ singlePassLayout: boolean
251
+ }
252
+
253
+ function isStore(arg: unknown): arg is Store {
254
+ // Store has cols and rows as required (not optional) properties.
255
+ // RenderOptions has them as optional. Disambiguate by checking for
256
+ // RenderOptions-only traits.
257
+ if (arg === null || typeof arg !== "object") return false
258
+ const obj = arg as Record<string, unknown>
259
+ return (
260
+ typeof obj.cols === "number" &&
261
+ typeof obj.rows === "number" &&
262
+ !("layoutEngine" in obj) &&
263
+ !("debug" in obj) &&
264
+ !("incremental" in obj) &&
265
+ !("singlePassLayout" in obj) &&
266
+ !("autoRender" in obj) &&
267
+ !("onFrame" in obj) &&
268
+ !("kittyMode" in obj) &&
269
+ !("wrapRoot" in obj) &&
270
+ !("stdin" in obj)
271
+ )
272
+ }
273
+
274
+ /**
275
+ * Render a React element synchronously. Returns a full App with locators,
276
+ * press(), text, ansi, etc.
277
+ *
278
+ * Layout engine must be initialized before calling (use ensureEngine() or
279
+ * the top-level await in testing module).
280
+ *
281
+ * Overloads:
282
+ * - render(element, { cols, rows }) — headless with dimensions
283
+ * - render(element, store) — with a Store from createStore()
284
+ *
285
+ * @example
286
+ * ```tsx
287
+ * const app = render(<Counter />, { cols: 80, rows: 24 })
288
+ * expect(app.text).toContain('Count: 0')
289
+ * await app.press('j')
290
+ * expect(app.text).toContain('Count: 1')
291
+ * ```
292
+ */
293
+ export function render(element: ReactElement, optsOrStore: RenderOptions | Store = {}): App {
294
+ // Guard: layout engine must be initialized
295
+ assertLayoutEngine()
296
+
297
+ const storeMode = isStore(optsOrStore)
298
+ const cols = storeMode ? optsOrStore.cols : (optsOrStore.cols ?? 80)
299
+ const rows = storeMode ? optsOrStore.rows : (optsOrStore.rows ?? 24)
300
+ const debug = storeMode ? false : (optsOrStore.debug ?? false)
301
+ // Incremental rendering is enabled by default for all renders
302
+ // Store mode also supports incremental - the RenderInstance tracks prevBuffer
303
+ const incremental = storeMode ? true : (optsOrStore.incremental ?? true)
304
+ const singlePassLayout = storeMode ? false : (optsOrStore.singlePassLayout ?? false)
305
+ const kittyMode = storeMode ? false : (optsOrStore.kittyMode ?? false)
306
+ const autoRender = storeMode ? false : (optsOrStore.autoRender ?? false)
307
+ const onFrame = storeMode ? undefined : optsOrStore.onFrame
308
+ const onBufferReady = storeMode ? undefined : optsOrStore.onBufferReady
309
+ const wrapRoot = storeMode ? undefined : optsOrStore.wrapRoot
310
+ const stdinStream = storeMode ? undefined : optsOrStore.stdin
311
+
312
+ // Guard: detect render leaks (absurd number of active instances)
313
+ const liveCount = pruneAndCountActiveRenders()
314
+ if (liveCount >= ACTIVE_RENDER_LEAK_THRESHOLD) {
315
+ throw new Error(
316
+ `silvery: ${liveCount} active render instances without unmount(). ` +
317
+ "This is a render leak. Use createRenderer() for auto-cleanup, " +
318
+ "or call unmount() when done with each render.",
319
+ )
320
+ }
321
+
322
+ // Set layout engine if provided
323
+ if (!storeMode && optsOrStore.layoutEngine) {
324
+ setLayoutEngine(optsOrStore.layoutEngine)
325
+ }
326
+
327
+ // Unique ID for this render instance (for tracking/debugging)
328
+ const renderId = ++renderIdCounter
329
+
330
+ const instance: RenderInstance = {
331
+ frames: [],
332
+ container: null as unknown as ReturnType<typeof createContainer>,
333
+ fiberRoot: null,
334
+ prevBuffer: null,
335
+ mounted: true,
336
+ rendering: false,
337
+ columns: cols,
338
+ rows: rows,
339
+ inputEmitter: new EventEmitter(),
340
+ debug,
341
+ incremental,
342
+ renderCount: 0,
343
+ singlePassLayout,
344
+ }
345
+
346
+ // Track whether React committed new work (from layout notifications etc.)
347
+ let hadReactCommit = false
348
+ let autoRenderScheduled = false
349
+ let inRenderCycle = false // true during doRender() and explicit operations
350
+ instance.container = createContainer(() => {
351
+ hadReactCommit = true
352
+ // Auto-render: schedule a microtask re-render on async React commits
353
+ // (e.g., setTimeout → setState). Skipped during explicit render operations
354
+ // (rendering=true or inRenderCycle=true) since those call doRender() themselves.
355
+ if (autoRender && !instance.rendering && !inRenderCycle && !autoRenderScheduled && instance.mounted) {
356
+ autoRenderScheduled = true
357
+ queueMicrotask(() => {
358
+ autoRenderScheduled = false
359
+ if (!instance.mounted || instance.rendering || inRenderCycle) return
360
+ inRenderCycle = true
361
+ try {
362
+ const newFrame = doRender()
363
+ instance.frames.push(newFrame)
364
+ onFrame?.(newFrame, instance.prevBuffer!, getRootContentHeight())
365
+ } finally {
366
+ inRenderCycle = false
367
+ }
368
+ })
369
+ }
370
+ })
371
+
372
+ instance.fiberRoot = createFiberRoot(instance.container)
373
+
374
+ /**
375
+ * Get the content extent of the root node's children (for buffer padding trimming).
376
+ * Returns the max outer bottom edge of the root's direct children, including
377
+ * margins. The root itself stretches to fill the terminal, but its children
378
+ * have specific layout-computed heights plus margins that extend beyond.
379
+ * Returns 0 if no children have layout (empty tree).
380
+ * Returns undefined if root has no layout at all.
381
+ */
382
+ const getRootContentHeight = (): number | undefined => {
383
+ try {
384
+ const root = getContainerRoot(instance.container)
385
+ if (!root?.contentRect) return undefined
386
+ let maxBottom = 0
387
+ let hasChildren = false
388
+ for (const child of root.children) {
389
+ if (child.contentRect) {
390
+ hasChildren = true
391
+ // contentRect includes marginTop in the y position but NOT marginBottom
392
+ // in the height. Read marginBottom from props to get the full outer extent.
393
+ const props = child.props as Record<string, unknown>
394
+ const mb = (props.marginBottom as number) ?? (props.marginY as number) ?? (props.margin as number) ?? 0
395
+ const childBottom = child.contentRect.y + child.contentRect.height + mb
396
+ if (childBottom > maxBottom) maxBottom = childBottom
397
+ }
398
+ }
399
+ return hasChildren ? maxBottom : 0
400
+ } catch {
401
+ return undefined
402
+ }
403
+ }
404
+
405
+ // Track exit state
406
+ let exitCalledFlag = false
407
+ let exitErrorValue: Error | undefined
408
+
409
+ const handleExit = (error?: Error) => {
410
+ exitCalledFlag = true
411
+ exitErrorValue = error
412
+ if (debug) {
413
+ console.log("[silvery] exit() called", error ? `with error: ${error.message}` : "")
414
+ }
415
+ }
416
+
417
+ // Create mock stdout with mutable dimensions and event support (for resize)
418
+ const stdoutEmitter = new EventEmitter()
419
+ const mockStdout = {
420
+ columns: instance.columns,
421
+ rows: instance.rows,
422
+ write: () => true,
423
+ isTTY: true,
424
+ on: (event: string, listener: (...args: unknown[]) => void) => {
425
+ stdoutEmitter.on(event, listener)
426
+ return mockStdout
427
+ },
428
+ off: (event: string, listener: (...args: unknown[]) => void) => {
429
+ stdoutEmitter.off(event, listener)
430
+ return mockStdout
431
+ },
432
+ once: (event: string, listener: (...args: unknown[]) => void) => {
433
+ stdoutEmitter.once(event, listener)
434
+ return mockStdout
435
+ },
436
+ removeListener: (event: string, listener: (...args: unknown[]) => void) => {
437
+ stdoutEmitter.removeListener(event, listener)
438
+ return mockStdout
439
+ },
440
+ addListener: (event: string, listener: (...args: unknown[]) => void) => {
441
+ stdoutEmitter.addListener(event, listener)
442
+ return mockStdout
443
+ },
444
+ } as unknown as NodeJS.WriteStream
445
+
446
+ // Create mock term with the mock stdout so useWindowSize reads correct dimensions
447
+ const mockTerm = createTerm({ color: "truecolor", stdout: mockStdout })
448
+
449
+ // Focus manager (tree-based focus system)
450
+ const focusManager = createFocusManager()
451
+
452
+ // Wire up focus cleanup on node removal
453
+ setOnNodeRemoved((removedNode) => focusManager.handleSubtreeRemoved(removedNode))
454
+
455
+ // Per-instance cursor state (replaces module-level globals)
456
+ const cursorStore = createCursorStore()
457
+
458
+ // RuntimeContext — typed event bus bridging from test renderer's inputEmitter
459
+ const runtimeValue: RuntimeContextValue = {
460
+ on(event, handler) {
461
+ if (event === "input") {
462
+ const wrapped = (data: string | Buffer) => {
463
+ const [input, key] = parseKey(data)
464
+ ;(handler as (input: string, key: import("@silvery/tea/keys").Key) => void)(input, key)
465
+ }
466
+ instance.inputEmitter.on("input", wrapped)
467
+ return () => {
468
+ instance.inputEmitter.removeListener("input", wrapped)
469
+ }
470
+ }
471
+ if (event === "paste") {
472
+ instance.inputEmitter.on("paste", handler)
473
+ return () => {
474
+ instance.inputEmitter.removeListener("paste", handler)
475
+ }
476
+ }
477
+ return () => {} // Unknown event — no-op cleanup
478
+ },
479
+ emit() {
480
+ // Test renderer doesn't support view → runtime events
481
+ },
482
+ exit: handleExit,
483
+ }
484
+
485
+ // Wrap element with contexts
486
+ function wrapWithContexts(el: ReactElement): ReactElement {
487
+ const inner = wrapRoot ? wrapRoot(el) : el
488
+ return React.createElement(
489
+ CursorProvider,
490
+ { store: cursorStore },
491
+ React.createElement(
492
+ TermContext.Provider,
493
+ { value: mockTerm },
494
+ React.createElement(
495
+ StdoutContext.Provider,
496
+ { value: { stdout: mockStdout, write: () => {} } },
497
+ React.createElement(
498
+ FocusManagerContext.Provider,
499
+ { value: focusManager },
500
+ React.createElement(RuntimeContext.Provider, { value: runtimeValue }, inner),
501
+ ),
502
+ ),
503
+ ),
504
+ )
505
+ }
506
+
507
+ // Check SILVERY_STRICT for automatic incremental checking (like scheduler does)
508
+ // Note: "0" and "false" are treated as disabled
509
+ const strictEnv = process.env.SILVERY_STRICT || process.env.SILVERY_CHECK_INCREMENTAL
510
+ const strictMode = incremental && strictEnv && strictEnv !== "0" && strictEnv !== "false"
511
+
512
+ // Render function that executes the pipeline.
513
+ //
514
+ // Two modes:
515
+ // 1. Multi-pass (default): Layout stabilization loop (up to 5 iterations).
516
+ // After executeRender fires notifyLayoutSubscribers (Phase 2.7), hooks
517
+ // like useContentRect call forceUpdate(). These React updates are flushed
518
+ // and the pipeline re-run until stable.
519
+ //
520
+ // 2. Single-pass (singlePassLayout=true): Matches production create-app.tsx.
521
+ // Single executeRender call per doRender(), with a separate effect flush
522
+ // loop afterward (like production's processEventBatch). This ensures tests
523
+ // exercise the same rendering pipeline as production.
524
+ //
525
+ // Key insight: executeRender must run inside act() so that forceUpdate/setState
526
+ // calls from layout notifications are properly captured by React's scheduler.
527
+ // With IS_REACT_ACT_ENVIRONMENT=true (set by silvery/testing), state updates
528
+ // outside act() boundaries may be dropped.
529
+ // Max iterations for singlePassLayout mode. Normally 1-2 passes, but resize
530
+ // can need 3+ (pass 0 stale zustand + pass 1 updated dims + pass 2+ layout
531
+ // feedback stabilization). Matches classic path's cap of 5.
532
+ const MAX_SINGLE_PASS_ITERATIONS = 5
533
+
534
+ function doRender(): string {
535
+ let output: string
536
+ let buffer!: TerminalBuffer
537
+
538
+ if (instance.singlePassLayout) {
539
+ // Production-matching single-pass: one executeRender, no stabilization
540
+ // loop. This matches create-app.tsx doRender() which does a single
541
+ // reconcile + pipeline pass. Layout feedback effects (useContentRect
542
+ // etc.) are NOT re-run within this doRender — they're flushed by the
543
+ // caller (sendInput) in a separate loop, matching production's
544
+ // processEventBatch flush pattern.
545
+ //
546
+ // IMPORTANT: Do NOT flush sync work here. executeRender fires
547
+ // notifyLayoutSubscribers (Phase 2.7) which may call forceUpdate().
548
+ // If we flushed that commit here, the pipeline output would still
549
+ // reflect the pre-forceUpdate state. Instead, let the sendInput
550
+ // flush loop detect the pending commit and call doRender() again
551
+ // with the updated React tree.
552
+ // Single-pass: run executeRender once, then flush any pending React
553
+ // work from layout notifications. If React committed new work, run
554
+ // additional passes to stabilize. Normally 1-2 passes suffice, but
555
+ // resize can need 3 (pass 0 with stale zustand, pass 1 with updated
556
+ // dimensions, pass 2 for layout feedback from pass 1).
557
+ let singlePassCount = 0
558
+ for (let pass = 0; pass < MAX_SINGLE_PASS_ITERATIONS; pass++) {
559
+ hadReactCommit = false
560
+ singlePassCount++
561
+ let renderError: Error | null = null
562
+ withActEnvironment(() => {
563
+ act(() => {
564
+ const root = getContainerRoot(instance.container)
565
+ try {
566
+ const result = executeRender(
567
+ root,
568
+ instance.columns,
569
+ instance.rows,
570
+ incremental ? instance.prevBuffer : null,
571
+ )
572
+ output = result.output
573
+ buffer = result.buffer
574
+ } catch (e) {
575
+ // SILVERY_STRICT_OUTPUT may throw from the output phase.
576
+ // The content phase buffer is still valid and attached to the
577
+ // error by executeRenderCore — extract it so lastBuffer()
578
+ // returns the correct frame, not a stale one.
579
+ renderError = e as Error
580
+ const attachedBuffer = (e as any)?.__silvery_buffer
581
+ if (attachedBuffer) {
582
+ buffer = attachedBuffer
583
+ }
584
+ }
585
+ // Always update prevBuffer when a new buffer was produced,
586
+ // even if the output phase threw. The buffer from contentPhase
587
+ // is correct; the STRICT_OUTPUT exception is a diagnostic that
588
+ // should not corrupt incremental rendering state.
589
+ if (buffer) {
590
+ instance.prevBuffer = buffer
591
+ }
592
+ instance.renderCount++
593
+ onBufferReady?.(output, buffer, getRootContentHeight())
594
+ })
595
+ if (!hadReactCommit) {
596
+ act(() => {
597
+ reconciler.flushSyncWork()
598
+ })
599
+ }
600
+ })
601
+ // Re-throw non-diagnostic errors. IncrementalRenderMismatchError from
602
+ // SILVERY_STRICT_OUTPUT is diagnostic — the buffer was saved above, and
603
+ // the content-phase STRICT check below will detect real mismatches.
604
+ // Propagating diagnostic throws would crash sendInput() callers.
605
+ if (renderError) {
606
+ if (!((renderError as Error) instanceof IncrementalRenderMismatchError)) {
607
+ throw renderError
608
+ }
609
+ }
610
+ if (!hadReactCommit) break
611
+ }
612
+
613
+ if (hadReactCommit && singlePassCount >= MAX_SINGLE_PASS_ITERATIONS) {
614
+ if (process.env.SILVERY_STRICT) {
615
+ console.warn(
616
+ `[SILVERY] singlePassLayout exhausted ${MAX_SINGLE_PASS_ITERATIONS} iterations ` +
617
+ `with pending React commit — output may be stale`,
618
+ )
619
+ }
620
+ }
621
+
622
+ // When multiple passes ran, the final buffer's dirty rows only cover
623
+ // the LAST pass's content phase writes. Mark all rows dirty so the
624
+ // output phase does a full diff scan.
625
+ if (incremental && buffer && singlePassCount > 1) {
626
+ buffer.markAllRowsDirty()
627
+ }
628
+ } else {
629
+ // Classic multi-pass layout stabilization loop
630
+ const MAX_LAYOUT_ITERATIONS = 5
631
+ let iterationCount = 0
632
+
633
+ for (let iteration = 0; iteration < MAX_LAYOUT_ITERATIONS; iteration++) {
634
+ hadReactCommit = false
635
+ iterationCount++
636
+
637
+ // Run the render pipeline inside act() so that forceUpdate/setState
638
+ // from notifyLayoutSubscribers (Phase 2.7) are properly captured.
639
+ let classicRenderError: Error | null = null
640
+ withActEnvironment(() => {
641
+ act(() => {
642
+ const root = getContainerRoot(instance.container)
643
+ try {
644
+ const result = executeRender(
645
+ root,
646
+ instance.columns,
647
+ instance.rows,
648
+ incremental ? instance.prevBuffer : null,
649
+ )
650
+ output = result.output
651
+ buffer = result.buffer
652
+ } catch (e) {
653
+ classicRenderError = e as Error
654
+ const attachedBuffer = (e as any)?.__silvery_buffer
655
+ if (attachedBuffer) {
656
+ buffer = attachedBuffer
657
+ }
658
+ }
659
+ if (buffer) {
660
+ instance.prevBuffer = buffer
661
+ }
662
+ instance.renderCount++
663
+ onBufferReady?.(output, buffer, getRootContentHeight())
664
+ })
665
+ // Flush any React work scheduled during executeRender (e.g. from
666
+ // useSyncExternalStore updates triggered by Phase 2.7 callbacks).
667
+ // Without this, external store changes from layout notification callbacks
668
+ // (Phase 2.7) won't be committed until after doRender returns, causing
669
+ // stale text in the buffer (e.g. breadcrumb showing old cursor position).
670
+ if (!hadReactCommit) {
671
+ act(() => {
672
+ reconciler.flushSyncWork()
673
+ })
674
+ }
675
+ })
676
+ if (classicRenderError) {
677
+ if (!((classicRenderError as Error) instanceof IncrementalRenderMismatchError)) {
678
+ throw classicRenderError
679
+ }
680
+ }
681
+
682
+ // If React didn't commit any new work from layout notifications,
683
+ // the layout is stable — no more iterations needed.
684
+ if (!hadReactCommit) break
685
+ }
686
+
687
+ if (hadReactCommit && iterationCount >= MAX_LAYOUT_ITERATIONS) {
688
+ if (process.env.SILVERY_STRICT) {
689
+ console.warn(
690
+ `[SILVERY] classic layout loop exhausted ${MAX_LAYOUT_ITERATIONS} iterations ` +
691
+ `with pending React commit — output may be stale`,
692
+ )
693
+ }
694
+ }
695
+
696
+ // When multiple iterations ran, the final buffer's dirty rows only cover
697
+ // the LAST iteration's content phase writes. Rows changed in earlier
698
+ // iterations but not the last are invisible to diffBuffers' dirty row
699
+ // scan, causing those rows to be skipped → garbled output. Mark all rows
700
+ // dirty so the output phase does a full diff scan.
701
+ if (incremental && buffer && iterationCount > 1) {
702
+ buffer.markAllRowsDirty()
703
+ }
704
+ }
705
+
706
+ // SILVERY_STRICT: Compare incremental vs fresh on every render (like scheduler)
707
+ // Skip first render (nothing to compare against)
708
+ if (strictMode && instance.renderCount > 1) {
709
+ const root = getContainerRoot(instance.container)
710
+ const freshBuffer = doFreshRender()
711
+ for (let y = 0; y < buffer!.height; y++) {
712
+ for (let x = 0; x < buffer!.width; x++) {
713
+ const a = buffer!.getCell(x, y)
714
+ const b = freshBuffer.getCell(x, y)
715
+ if (!cellEquals(a, b)) {
716
+ // Re-run fresh render with write trap to capture what writes here
717
+ let trapInfo = ""
718
+ const trap = { x, y, log: [] as string[] }
719
+ ;(globalThis as any).__silvery_write_trap = trap
720
+ try {
721
+ doFreshRender()
722
+ } catch {
723
+ // ignore
724
+ }
725
+ ;(globalThis as any).__silvery_write_trap = null
726
+ if (trap.log.length > 0) {
727
+ trapInfo = `\nWRITE TRAP (${trap.log.length} writes to (${x},${y})):\n${trap.log.join("\n")}\n`
728
+ } else {
729
+ trapInfo = `\nWRITE TRAP: NO WRITES to (${x},${y})\n`
730
+ }
731
+
732
+ // Build rich debug context
733
+ const ctx = buildMismatchContext(root, x, y, a, b, instance.renderCount)
734
+
735
+ // Capture content-phase instrumentation snapshot
736
+ const contentPhaseStats: ContentPhaseStats | undefined = (globalThis as any).__silvery_content_detail
737
+ ? structuredClone((globalThis as any).__silvery_content_detail)
738
+ : undefined
739
+
740
+ const debugInfo = formatMismatchContext(ctx, contentPhaseStats)
741
+
742
+ // Include text output for full picture
743
+ const incText = bufferToText(buffer!)
744
+ const freshText = bufferToText(freshBuffer)
745
+ const msg = debugInfo + trapInfo + `--- incremental ---\n${incText}\n--- fresh ---\n${freshText}`
746
+ throw new IncrementalRenderMismatchError(msg, {
747
+ contentPhaseStats,
748
+ mismatchContext: ctx,
749
+ })
750
+ }
751
+ }
752
+ }
753
+ }
754
+
755
+ return output!
756
+ }
757
+
758
+ // Fresh render: renders from scratch without updating incremental state
759
+ function doFreshRender(): TerminalBuffer {
760
+ const root = getContainerRoot(instance.container)
761
+ const { buffer } = executeRender(root, instance.columns, instance.rows, null, {
762
+ skipLayoutNotifications: true,
763
+ skipScrollStateUpdates: true,
764
+ })
765
+ return buffer
766
+ }
767
+
768
+ // Synchronously update React tree within act()
769
+ instance.rendering = true
770
+ try {
771
+ withActEnvironment(() => {
772
+ act(() => {
773
+ reconciler.updateContainerSync(wrapWithContexts(element), instance.fiberRoot, null, null)
774
+ reconciler.flushSyncWork()
775
+ })
776
+ })
777
+ } finally {
778
+ instance.rendering = false
779
+ }
780
+
781
+ // Execute the render pipeline.
782
+ // The initial render always uses the multi-pass stabilization loop regardless
783
+ // of singlePassLayout, because hooks like useContentRect need multiple passes
784
+ // to stabilize (subscribe → layout → forceUpdate → re-render). This matches
785
+ // production where the initial render runs once and the first user-visible
786
+ // frame comes after the event loop starts. For tests, we need the initial
787
+ // state to be stable. singlePassLayout only affects subsequent renders
788
+ // (sendInput/press) to match production's processEventBatch path.
789
+ const savedSinglePass = instance.singlePassLayout
790
+ instance.singlePassLayout = false
791
+ const output = doRender()
792
+ instance.singlePassLayout = savedSinglePass
793
+
794
+ instance.frames.push(output)
795
+ onFrame?.(output, instance.prevBuffer!, getRootContentHeight())
796
+
797
+ if (debug) {
798
+ console.log("[silvery] Initial render:", output)
799
+ }
800
+
801
+ // Set up stdin bridge: forward external stdin data to the renderer's input
802
+ let stdinOnReadable: (() => void) | undefined
803
+ if (stdinStream) {
804
+ stdinOnReadable = () => {
805
+ let chunk: string | null
806
+ while ((chunk = (stdinStream as any).read?.()) !== null && chunk !== undefined) {
807
+ instance.inputEmitter.emit("input", chunk)
808
+ }
809
+ }
810
+ stdinStream.on("readable", stdinOnReadable)
811
+ }
812
+
813
+ // Helper functions for App
814
+ const getContainer = () => getContainerRoot(instance.container)
815
+ const getBuffer = () => instance.prevBuffer
816
+
817
+ const sendInput = (data: string) => {
818
+ if (!instance.mounted) {
819
+ throw new Error("Cannot write to stdin after unmount")
820
+ }
821
+ if (instance.rendering) {
822
+ throw new Error(
823
+ "silvery: Re-entrant render detected. " +
824
+ "Cannot call press()/stdin.write() from inside a React render or effect. " +
825
+ "Use setTimeout or an event handler instead.",
826
+ )
827
+ }
828
+ const t0 = performance.now()
829
+ instance.rendering = true
830
+ try {
831
+ // Check for bracketed paste before splitting into individual keys.
832
+ // Paste content is delivered as a single "paste" event, not individual keystrokes.
833
+ // This mirrors the production path in term-provider.ts.
834
+ const pasteResult = parseBracketedPaste(data)
835
+ if (pasteResult) {
836
+ withActEnvironment(() => {
837
+ act(() => {
838
+ instance.inputEmitter.emit("paste", pasteResult.content)
839
+ })
840
+ })
841
+ } else {
842
+ // Split multi-character data into individual keypresses.
843
+ // This mirrors the production path (render.tsx handleReadable)
844
+ // where stdin.read() can return buffered characters.
845
+ withActEnvironment(() => {
846
+ for (const keypress of splitRawInput(data)) {
847
+ // Default Tab/Shift+Tab focus cycling and Escape blur.
848
+ // Matches production behavior in run.tsx and render.tsx.
849
+ // Tab events are consumed (not passed to useInput handlers).
850
+ // Each focus change runs in its own act() boundary so React
851
+ // commits the re-render before the next keypress or doRender().
852
+ const [, key] = parseKey(keypress)
853
+ if (key.tab && !key.shift) {
854
+ act(() => {
855
+ const root = getContainerRoot(instance.container)
856
+ focusManager.focusNext(root)
857
+ })
858
+ continue
859
+ }
860
+ if (key.tab && key.shift) {
861
+ act(() => {
862
+ const root = getContainerRoot(instance.container)
863
+ focusManager.focusPrev(root)
864
+ })
865
+ continue
866
+ }
867
+ if (key.escape && focusManager.activeElement) {
868
+ act(() => {
869
+ focusManager.blur()
870
+ })
871
+ continue
872
+ }
873
+ act(() => {
874
+ instance.inputEmitter.emit("input", keypress)
875
+ })
876
+ }
877
+ })
878
+ } // end else (non-paste input)
879
+ } finally {
880
+ instance.rendering = false
881
+ }
882
+
883
+ const t1 = performance.now()
884
+ // doRender() handles SILVERY_STRICT checking internally
885
+ let newFrame = doRender()
886
+
887
+ // In single-pass mode, flush effects after doRender() — matching
888
+ // production's processEventBatch pattern (lines 1107-1118 of create-app.tsx).
889
+ // Production does: doRender → await Promise.resolve() → check pendingRerender → repeat.
890
+ // In tests, we use act(flushSyncWork) as the synchronous equivalent.
891
+ let doRenderCount = 1
892
+ if (instance.singlePassLayout) {
893
+ const MAX_EFFECT_FLUSHES = 5
894
+ for (let flush = 0; flush < MAX_EFFECT_FLUSHES; flush++) {
895
+ hadReactCommit = false
896
+ withActEnvironment(() => {
897
+ act(() => {
898
+ reconciler.flushSyncWork()
899
+ })
900
+ })
901
+ if (!hadReactCommit) break
902
+ // React committed new work from effects — re-render
903
+ newFrame = doRender()
904
+ doRenderCount++
905
+ }
906
+ }
907
+
908
+ // When multiple doRender() calls ran (layout feedback, effects), the final
909
+ // buffer's dirty rows only cover the LAST call's writes. Rows changed in
910
+ // earlier doRender calls are invisible to callers using outputPhase to diff
911
+ // against an older prevBuffer. Mark all rows dirty for correctness.
912
+ if (incremental && doRenderCount > 1 && instance.prevBuffer) {
913
+ instance.prevBuffer.markAllRowsDirty()
914
+ }
915
+
916
+ const t2 = performance.now()
917
+ instance.frames.push(newFrame)
918
+ onFrame?.(newFrame, instance.prevBuffer!, getRootContentHeight())
919
+ if (debug) {
920
+ console.log("[silvery] stdin.write:", newFrame)
921
+ }
922
+ // Expose timing on global for benchmarking
923
+ ;(globalThis as any).__silvery_last_timing = {
924
+ actMs: t1 - t0,
925
+ renderMs: t2 - t1,
926
+ }
927
+ }
928
+
929
+ const rerenderFn = (newElement: ReactNode) => {
930
+ if (!instance.mounted) {
931
+ throw new Error("Cannot rerender after unmount")
932
+ }
933
+ if (instance.rendering) {
934
+ throw new Error(
935
+ "silvery: Re-entrant render detected. " + "Cannot call rerender() from inside a React render or effect.",
936
+ )
937
+ }
938
+ instance.rendering = true
939
+ try {
940
+ withActEnvironment(() => {
941
+ act(() => {
942
+ reconciler.updateContainerSync(wrapWithContexts(newElement as ReactElement), instance.fiberRoot, null, null)
943
+ reconciler.flushSyncWork()
944
+ })
945
+ })
946
+ } finally {
947
+ instance.rendering = false
948
+ }
949
+ const newFrame = doRender()
950
+ instance.frames.push(newFrame)
951
+ onFrame?.(newFrame, instance.prevBuffer!, getRootContentHeight())
952
+ if (debug) {
953
+ console.log("[silvery] Rerender:", newFrame)
954
+ }
955
+ }
956
+
957
+ // Track this render for leak detection
958
+ const renderTracker = { unmount: () => {}, id: renderId }
959
+ const renderRef = new WeakRef(renderTracker)
960
+ activeRenders.add(renderRef)
961
+
962
+ const unmountFn = () => {
963
+ if (!instance.mounted) {
964
+ throw new Error("Already unmounted")
965
+ }
966
+ withActEnvironment(() => {
967
+ act(() => {
968
+ reconciler.updateContainer(null, instance.fiberRoot, null, () => {})
969
+ })
970
+ })
971
+ instance.mounted = false
972
+ instance.inputEmitter.removeAllListeners()
973
+
974
+ // Clean up stdin bridge
975
+ if (stdinStream && stdinOnReadable) {
976
+ stdinStream.removeListener("readable", stdinOnReadable)
977
+ }
978
+
979
+ // Unregister node removal hook
980
+ setOnNodeRemoved(null)
981
+
982
+ // Untrack this render
983
+ activeRenders.delete(renderRef)
984
+
985
+ if (debug) {
986
+ console.log("[silvery] Unmounted")
987
+ }
988
+ }
989
+ renderTracker.unmount = unmountFn
990
+
991
+ const clearFn = () => {
992
+ instance.frames.length = 0
993
+ instance.prevBuffer = null
994
+ }
995
+
996
+ const debugFn = () => {
997
+ console.log(debugTree(getContainerRoot(instance.container)))
998
+ }
999
+
1000
+ // actAndRender: wrap a callback in act() so React state updates are flushed,
1001
+ // then doRender() to update the buffer. Used by click/wheel/doubleClick.
1002
+ const actAndRenderFn = (fn: () => void) => {
1003
+ if (!instance.mounted) return
1004
+ withActEnvironment(() => {
1005
+ act(() => {
1006
+ fn()
1007
+ })
1008
+ })
1009
+ const newFrame = doRender()
1010
+ instance.frames.push(newFrame)
1011
+ onFrame?.(newFrame, instance.prevBuffer!, getRootContentHeight())
1012
+ }
1013
+
1014
+ // Resize: update dimensions, clear prevBuffer, re-render (matches scheduler resize behavior)
1015
+ const resizeFn = (newCols: number, newRows: number) => {
1016
+ if (!instance.mounted) {
1017
+ throw new Error("Cannot resize after unmount")
1018
+ }
1019
+ instance.columns = newCols
1020
+ instance.rows = newRows
1021
+ mockStdout.columns = newCols
1022
+ mockStdout.rows = newRows
1023
+ // Emit resize event so component-level listeners (e.g., ScrollbackView's
1024
+ // width tracking) fire before the render, matching real terminal behavior.
1025
+ stdoutEmitter.emit("resize")
1026
+ // Clear prevBuffer to force full redraw (matches scheduler.setupResizeListener)
1027
+ instance.prevBuffer = null
1028
+ // Re-render at new dimensions
1029
+ const newFrame = doRender()
1030
+ instance.frames.push(newFrame)
1031
+ onFrame?.(newFrame, instance.prevBuffer!, getRootContentHeight())
1032
+ if (debug) {
1033
+ console.log("[silvery] Resize:", newCols, "x", newRows)
1034
+ }
1035
+ }
1036
+
1037
+ // Build unified App instance
1038
+ return buildApp({
1039
+ getContainer,
1040
+ getBuffer,
1041
+ sendInput,
1042
+ rerender: rerenderFn,
1043
+ unmount: unmountFn,
1044
+ waitUntilExit: () => Promise.resolve(),
1045
+ clear: clearFn,
1046
+ exitCalled: () => exitCalledFlag,
1047
+ exitError: () => exitErrorValue,
1048
+ freshRender: doFreshRender,
1049
+ debugFn,
1050
+ frames: instance.frames,
1051
+ columns: cols,
1052
+ rows: rows,
1053
+ kittyMode,
1054
+ actAndRender: actAndRenderFn,
1055
+ resize: resizeFn,
1056
+ focusManager,
1057
+ getCursorState: cursorStore.accessors.getCursorState,
1058
+ })
1059
+ }
1060
+
1061
+ // ============================================================================
1062
+ // createStore() — flatten providers into { cols, rows, events() }
1063
+ // ============================================================================
1064
+
1065
+ /**
1066
+ * Create a Store from provider options.
1067
+ *
1068
+ * A Store is the TermDef-like environment: cols, rows, and optionally events.
1069
+ *
1070
+ * @example
1071
+ * ```tsx
1072
+ * const store = createStore({ cols: 80, rows: 24 })
1073
+ * const app = render(<App />, store)
1074
+ * ```
1075
+ */
1076
+ export function createStore(options: StoreOptions = {}): Store {
1077
+ const { cols = 80, rows = 24, events: eventsSource } = options
1078
+
1079
+ const store: Store = {
1080
+ cols,
1081
+ rows,
1082
+ }
1083
+
1084
+ if (eventsSource) {
1085
+ store.events = () => eventsSource
1086
+ }
1087
+
1088
+ return store
1089
+ }
1090
+
1091
+ // ============================================================================
1092
+ // createRenderer() — factory with auto-cleanup
1093
+ // ============================================================================
1094
+
1095
+ /**
1096
+ * Per-render overrides for createRenderer's returned function.
1097
+ */
1098
+ export interface PerRenderOptions {
1099
+ /** Enable incremental rendering for this render. */
1100
+ incremental?: boolean
1101
+ /** Use production-like single-pass layout. See RenderOptions.singlePassLayout. */
1102
+ singlePassLayout?: boolean
1103
+ /** Use Kitty keyboard protocol encoding for press(). */
1104
+ kittyMode?: boolean
1105
+ }
1106
+
1107
+ /**
1108
+ * Create a render function that auto-cleans previous renders.
1109
+ *
1110
+ * By default, incremental rendering is ENABLED for test renders.
1111
+ * This matches production behavior (live scheduler uses incremental)
1112
+ * and enables withDiagnostics to catch incremental vs fresh mismatches.
1113
+ *
1114
+ * @example
1115
+ * ```tsx
1116
+ * const render = createRenderer({ cols: 80, rows: 24 })
1117
+ * const app1 = render(<Foo />) // incremental: true by default
1118
+ * const app2 = render(<Bar />) // unmounts app1
1119
+ *
1120
+ * // Explicitly disable incremental if needed
1121
+ * const render2 = createRenderer({ cols: 80, rows: 24, incremental: false })
1122
+ * ```
1123
+ */
1124
+ export function createRenderer(
1125
+ optsOrStore: RenderOptions | Store = {},
1126
+ ): (el: ReactElement, overrides?: PerRenderOptions) => App {
1127
+ let current: App | null = null
1128
+
1129
+ // Default to incremental: true for test renders (matches production behavior)
1130
+ // User can explicitly pass incremental: false to disable
1131
+ // Note: When passed a Store-like object (cols/rows only), convert to RenderOptions with incremental
1132
+ const baseOpts = isStore(optsOrStore)
1133
+ ? { incremental: true, cols: optsOrStore.cols, rows: optsOrStore.rows }
1134
+ : { incremental: true, ...optsOrStore }
1135
+
1136
+ return (element: ReactElement, overrides?: PerRenderOptions): App => {
1137
+ if (current) {
1138
+ try {
1139
+ current.unmount()
1140
+ } catch {
1141
+ // Already unmounted
1142
+ }
1143
+ }
1144
+ let opts = baseOpts
1145
+ if (overrides && !isStore(opts)) {
1146
+ opts = { ...opts, ...overrides }
1147
+ }
1148
+ current = render(element, opts)
1149
+ return current
1150
+ }
1151
+ }
1152
+
1153
+ // ============================================================================
1154
+ // run() — event loop driver
1155
+ // ============================================================================
1156
+
1157
+ /**
1158
+ * Result of run() with sync events — iterable over events.
1159
+ */
1160
+ export interface SyncRunResult extends Iterable<string> {
1161
+ /** Current rendered text */
1162
+ readonly text: string
1163
+ /** The app being driven */
1164
+ readonly app: App
1165
+ }
1166
+
1167
+ /**
1168
+ * Result of run() with async events — async iterable and awaitable.
1169
+ */
1170
+ export interface AsyncRunResult extends AsyncIterable<string>, PromiseLike<void> {
1171
+ /** Current rendered text */
1172
+ readonly text: string
1173
+ /** The app being driven */
1174
+ readonly app: App
1175
+ /** Unmount and stop the event loop */
1176
+ unmount(): void
1177
+ }
1178
+
1179
+ /**
1180
+ * Drive an App with events.
1181
+ *
1182
+ * - `run(app, ['j', 'k', 'Enter'])` — sync, explicit key events
1183
+ * - `run(app, syncIterable)` — sync iteration over events
1184
+ * - `await run(app)` — async, reads events from store (if rendered with one)
1185
+ * - `for await (const text of run(app, asyncEvents))` — async iteration
1186
+ *
1187
+ * @example
1188
+ * ```tsx
1189
+ * const app = render(<Counter />, { cols: 80, rows: 24 })
1190
+ * run(app, ['j', 'k', 'Enter'])
1191
+ * expect(app.text).toContain('Count: 2')
1192
+ * ```
1193
+ */
1194
+ export function run(app: App, events: string[]): SyncRunResult
1195
+ export function run(app: App, events: Iterable<string>): SyncRunResult
1196
+ export function run(app: App, events?: AsyncIterable<string>): AsyncRunResult
1197
+ export function run(
1198
+ app: App,
1199
+ events?: string[] | Iterable<string> | AsyncIterable<string>,
1200
+ ): SyncRunResult | AsyncRunResult {
1201
+ // Sync path: array or sync iterable
1202
+ if (events !== undefined && !isAsyncIterable(events)) {
1203
+ const iter = Array.isArray(events) ? events : events
1204
+ const processedEvents: string[] = []
1205
+
1206
+ for (const key of iter) {
1207
+ app.stdin.write(keyToAnsi(key))
1208
+ processedEvents.push(key)
1209
+ }
1210
+
1211
+ return {
1212
+ get text() {
1213
+ return app.text
1214
+ },
1215
+ app,
1216
+ [Symbol.iterator]() {
1217
+ return processedEvents[Symbol.iterator]()
1218
+ },
1219
+ }
1220
+ }
1221
+
1222
+ // Async path
1223
+ let stopped = false
1224
+ const unmount = () => {
1225
+ stopped = true
1226
+ app.unmount()
1227
+ }
1228
+
1229
+ const asyncResult: AsyncRunResult = {
1230
+ get text() {
1231
+ return app.text
1232
+ },
1233
+ app,
1234
+ unmount,
1235
+
1236
+ // PromiseLike — `await run(app)` or `await run(app, asyncEvents)`
1237
+ then<TResult1 = void, TResult2 = never>(
1238
+ onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
1239
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
1240
+ ): Promise<TResult1 | TResult2> {
1241
+ const promise = (async () => {
1242
+ if (events) {
1243
+ for await (const key of events) {
1244
+ if (stopped) break
1245
+ await app.press(key)
1246
+ }
1247
+ } else {
1248
+ // No events — wait until exit
1249
+ await app.run()
1250
+ }
1251
+ })()
1252
+ return promise.then(onfulfilled, onrejected)
1253
+ },
1254
+
1255
+ // AsyncIterable — `for await (const text of run(app, asyncEvents))`
1256
+ [Symbol.asyncIterator](): AsyncIterator<string> {
1257
+ if (!events) {
1258
+ // No events source — yield current text, then done
1259
+ let yielded = false
1260
+ return {
1261
+ async next(): Promise<IteratorResult<string>> {
1262
+ if (yielded || stopped) {
1263
+ return { done: true, value: undefined as unknown as string }
1264
+ }
1265
+ yielded = true
1266
+ return { done: false, value: app.text }
1267
+ },
1268
+ }
1269
+ }
1270
+
1271
+ const iter = (events as AsyncIterable<string>)[Symbol.asyncIterator]()
1272
+ return {
1273
+ async next(): Promise<IteratorResult<string>> {
1274
+ if (stopped) {
1275
+ return { done: true, value: undefined as unknown as string }
1276
+ }
1277
+ const result = await iter.next()
1278
+ if (result.done) {
1279
+ return { done: true, value: undefined as unknown as string }
1280
+ }
1281
+ await app.press(result.value)
1282
+ return { done: false, value: app.text }
1283
+ },
1284
+ async return(): Promise<IteratorResult<string>> {
1285
+ unmount()
1286
+ return { done: true, value: undefined as unknown as string }
1287
+ },
1288
+ }
1289
+ },
1290
+ }
1291
+
1292
+ return asyncResult
1293
+ }
1294
+
1295
+ function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
1296
+ return value !== null && typeof value === "object" && Symbol.asyncIterator in value
1297
+ }
1298
+
1299
+ /**
1300
+ * Run a function with IS_REACT_ACT_ENVIRONMENT temporarily set to true.
1301
+ * This ensures act() works correctly without polluting the global scope.
1302
+ */
1303
+ function withActEnvironment(fn: () => void): void {
1304
+ const prev = (globalThis as any).IS_REACT_ACT_ENVIRONMENT
1305
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
1306
+ try {
1307
+ fn()
1308
+ } finally {
1309
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = prev
1310
+ }
1311
+ }
1312
+
1313
+ // ============================================================================
1314
+ // Test Utilities
1315
+ // ============================================================================
1316
+
1317
+ /**
1318
+ * Get the number of currently active (mounted) render instances.
1319
+ * Useful for tests to verify cleanup.
1320
+ */
1321
+ export function getActiveRenderCount(): number {
1322
+ return pruneAndCountActiveRenders()
1323
+ }
1324
+
1325
+ // ============================================================================
1326
+ // Re-exports
1327
+ // ============================================================================
1328
+
1329
+ export { keyToAnsi } from "@silvery/tea/keys"
1330
+ export type { App } from "./app.js"