@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.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
package/src/renderer.ts
ADDED
|
@@ -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"
|