@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,1845 @@
1
+ /**
2
+ * createApp() - Layer 3 entry point for silvery-loop
3
+ *
4
+ * Provides Zustand store integration with unified providers.
5
+ * Providers are stores (getState/subscribe) + event sources (events()).
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * import { createApp, useApp, createTermProvider } from '@silvery/term/runtime'
10
+ *
11
+ * const app = createApp(
12
+ * // Store factory
13
+ * ({ term }) => (set, get) => ({
14
+ * count: 0,
15
+ * increment: () => set(s => ({ count: s.count + 1 })),
16
+ * }),
17
+ * // Event handlers - namespaced as 'provider:event'
18
+ * {
19
+ * 'term:key': ({ input, key }, { set }) => {
20
+ * if (input === 'j') set(s => ({ count: s.count + 1 }))
21
+ * if (input === 'q') return 'exit'
22
+ * },
23
+ * 'term:resize': ({ cols, rows }, { set }) => {
24
+ * // handle resize
25
+ * },
26
+ * }
27
+ * )
28
+ *
29
+ * function Counter() {
30
+ * const count = useApp(s => s.count)
31
+ * return <Text>Count: {count}</Text>
32
+ * }
33
+ *
34
+ * const term = createTermProvider(process.stdin, process.stdout)
35
+ * await app.run(<Counter />, { term })
36
+ *
37
+ * // Frame iteration:
38
+ * for await (const frame of app.run(<Counter />, { term })) {
39
+ * expect(frame.text).toContain('Count:')
40
+ * }
41
+ * ```
42
+ */
43
+
44
+ import process from "node:process"
45
+ import React, { createContext, useContext, useEffect, useRef, type ReactElement } from "react"
46
+ import { type StateCreator, type StoreApi, createStore } from "zustand"
47
+
48
+ import { createTerm } from "../ansi/index"
49
+ import {
50
+ FocusManagerContext,
51
+ RuntimeContext,
52
+ type RuntimeContextValue,
53
+ StdoutContext,
54
+ StderrContext,
55
+ TermContext,
56
+ } from "@silvery/react/context"
57
+ import { SilveryErrorBoundary } from "@silvery/react/error-boundary"
58
+ import { createFocusManager } from "@silvery/tea/focus-manager"
59
+ import { createCursorStore, CursorProvider } from "@silvery/react/hooks/useCursor"
60
+ import { createFocusEvent, dispatchFocusEvent } from "@silvery/tea/focus-events"
61
+ import { executeRender } from "../pipeline"
62
+ import { createPipeline } from "../measurer"
63
+ import { isTextSizingLikelySupported } from "../text-sizing"
64
+ import { IncrementalRenderMismatchError } from "../scheduler"
65
+ import {
66
+ createContainer,
67
+ createFiberRoot,
68
+ getContainerRoot,
69
+ reconciler,
70
+ setOnNodeRemoved,
71
+ } from "@silvery/react/reconciler"
72
+ import { map, merge, takeUntil } from "@silvery/tea/streams"
73
+ import { createBuffer } from "./create-buffer"
74
+ import { createRuntime } from "./create-runtime"
75
+ import {
76
+ createHandlerContext,
77
+ dispatchKeyToHandlers,
78
+ handleFocusNavigation,
79
+ invokeEventHandler,
80
+ type NamespacedEvent,
81
+ } from "./event-handlers"
82
+ import { keyToAnsi, keyToKittyAnsi } from "@silvery/tea/keys"
83
+ import { parseKey, type Key } from "./keys"
84
+ import { ensureLayoutEngine } from "./layout"
85
+ import { createMouseEventProcessor } from "../mouse-events"
86
+ import { enableKittyKeyboard, disableKittyKeyboard, KittyFlags, enableMouse, disableMouse } from "../output"
87
+ import { enableFocusReporting, disableFocusReporting } from "../focus-reporting"
88
+ import { detectKittyFromStdio } from "../kitty-detect"
89
+ import { captureTerminalState, performSuspend } from "./terminal-lifecycle"
90
+ import { type TermProvider, createTermProvider } from "./term-provider"
91
+ import type { Buffer, Dims, Provider, RenderTarget } from "./types"
92
+
93
+ // ============================================================================
94
+ // Types
95
+ // ============================================================================
96
+
97
+ /**
98
+ * Check if value is a Provider with events (full interface).
99
+ */
100
+ function isFullProvider(value: unknown): value is Provider<unknown, Record<string, unknown>> {
101
+ return (
102
+ value !== null &&
103
+ typeof value === "object" &&
104
+ "getState" in value &&
105
+ "subscribe" in value &&
106
+ "events" in value &&
107
+ typeof (value as Provider).getState === "function" &&
108
+ typeof (value as Provider).subscribe === "function" &&
109
+ typeof (value as Provider).events === "function"
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Check if value is a basic Provider (just getState/subscribe, Zustand-compatible).
115
+ */
116
+ function isBasicProvider(value: unknown): value is {
117
+ getState(): unknown
118
+ subscribe(l: (s: unknown) => void): () => void
119
+ } {
120
+ return (
121
+ value !== null &&
122
+ typeof value === "object" &&
123
+ "getState" in value &&
124
+ "subscribe" in value &&
125
+ typeof (value as { getState: unknown }).getState === "function" &&
126
+ typeof (value as { subscribe: unknown }).subscribe === "function"
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Event handler context passed to handlers.
132
+ *
133
+ * When the store uses `tea()` middleware, `dispatch` is available with the
134
+ * correct Op type inferred from the store. For non-tea stores it's `undefined`.
135
+ */
136
+ export interface EventHandlerContext<S> {
137
+ set: StoreApi<S>["setState"]
138
+ get: StoreApi<S>["getState"]
139
+ /** The tree-based focus manager */
140
+ focusManager: import("@silvery/tea/focus-manager").FocusManager
141
+ /** Convenience: focus a node by testID */
142
+ focus(testID: string): void
143
+ /** Activate a peer focus scope (saves/restores focus per scope) */
144
+ activateScope(scopeId: string): void
145
+ /** Get the focus path from focused node to root */
146
+ getFocusPath(): string[]
147
+ /**
148
+ * Dispatch an operation through the tea() reducer.
149
+ *
150
+ * Available when the store was created with `tea()` middleware from `silvery/tea`.
151
+ * Type-safe: the Op type is inferred from the store's TeaSlice.
152
+ * For non-tea stores, this is `undefined`.
153
+ */
154
+ dispatch?: "dispatch" extends keyof S ? S["dispatch"] : undefined
155
+ /** Hit-test the render tree at (x, y). Returns the deepest SilveryNode at that point, or null. */
156
+ hitTest(x: number, y: number): import("@silvery/tea/types").TeaNode | null
157
+ }
158
+
159
+ /**
160
+ * Generic event handler function.
161
+ * Return 'exit' to exit the app.
162
+ */
163
+ export type EventHandler<T, S> = (data: T, ctx: EventHandlerContext<S>) => void | "exit" | "flush"
164
+
165
+ /**
166
+ * Event handlers map.
167
+ * Keys are namespaced as 'provider:event' (e.g., 'term:key', 'term:resize').
168
+ */
169
+ export type EventHandlers<S> = {
170
+ [event: `${string}:${string}`]: EventHandler<unknown, S> | undefined
171
+ }
172
+
173
+ /**
174
+ * Options for app.run().
175
+ */
176
+ export interface AppRunOptions {
177
+ /** Terminal dimensions (default: from process.stdout) */
178
+ cols?: number
179
+ rows?: number
180
+ /** Standard output (default: process.stdout) */
181
+ stdout?: NodeJS.WriteStream
182
+ /** Standard input (default: process.stdin) */
183
+ stdin?: NodeJS.ReadStream
184
+ /**
185
+ * Plain writable sink for ANSI output. Headless mode with active output.
186
+ * Requires cols and rows. Input via handle.press().
187
+ */
188
+ writable?: { write(data: string): void }
189
+ /**
190
+ * Subscribe to resize events in headless mode.
191
+ * Called with a handler that should be invoked when dimensions change.
192
+ * Returns an unsubscribe function.
193
+ */
194
+ onResize?: (handler: (dims: { cols: number; rows: number }) => void) => () => void
195
+ /** Abort signal for external cleanup */
196
+ signal?: AbortSignal
197
+ /** Enter alternate screen buffer (clean slate, restore on exit). Default: false */
198
+ alternateScreen?: boolean
199
+ /** Use Kitty keyboard protocol encoding for press(). Default: false */
200
+ kittyMode?: boolean
201
+ /**
202
+ * Enable Kitty keyboard protocol.
203
+ * - `true`: auto-detect and enable with DISAMBIGUATE flag (1)
204
+ * - number: enable with specific KittyFlags bitfield
205
+ * - `false`/undefined: don't enable (default)
206
+ */
207
+ kitty?: boolean | number
208
+ /**
209
+ * Enable SGR mouse tracking (mode 1006).
210
+ * When true, enables mouse events and disables on cleanup.
211
+ * Default: false
212
+ */
213
+ mouse?: boolean
214
+ /**
215
+ * Handle Ctrl+Z by suspending the process (save terminal state,
216
+ * send SIGTSTP, restore on SIGCONT). Default: true
217
+ */
218
+ suspendOnCtrlZ?: boolean
219
+ /**
220
+ * Handle Ctrl+C by restoring terminal and exiting.
221
+ * Default: true
222
+ */
223
+ exitOnCtrlC?: boolean
224
+ /** Called before suspend. Return false to prevent. */
225
+ onSuspend?: () => boolean | void
226
+ /** Called after resume from suspend. */
227
+ onResume?: () => void
228
+ /** Called on Ctrl+C. Return false to prevent exit. */
229
+ onInterrupt?: () => boolean | void
230
+ /**
231
+ * Enable Kitty text sizing protocol (OSC 66) for PUA characters.
232
+ * When enabled, nerdfont/powerline icons are measured as 2-wide and
233
+ * wrapped in OSC 66 sequences so the terminal renders them at the
234
+ * correct width.
235
+ * - `true`: force enable
236
+ * - `"auto"`: enable if terminal likely supports it (Kitty 0.40+, Ghostty)
237
+ * - `false`/undefined: disabled (default)
238
+ */
239
+ textSizing?: boolean | "auto"
240
+ /**
241
+ * Enable terminal focus reporting (CSI ?1004h).
242
+ * When enabled, the terminal sends focus-in/focus-out events that are
243
+ * dispatched as 'term:focus' events with `{ focused: boolean }`.
244
+ * Default: false
245
+ */
246
+ focusReporting?: boolean
247
+ /**
248
+ * Terminal capabilities for width measurement and output suppression.
249
+ * When provided, configures the render pipeline to use these caps
250
+ * (scoped width measurer + output phase). Typically from term.caps.
251
+ */
252
+ caps?: import("../terminal-caps.js").TerminalCaps
253
+ /**
254
+ * Root component that wraps the element tree with additional providers.
255
+ * Set by plugins (e.g., withInk) via the `app.Root` pattern.
256
+ * The Root component receives children and wraps them with providers.
257
+ */
258
+ Root?: React.ComponentType<{ children: React.ReactNode }>
259
+ /** Providers and plain values to inject */
260
+ [key: string]: unknown
261
+ }
262
+
263
+ /**
264
+ * Handle returned by app.run().
265
+ *
266
+ * Also AsyncIterable<Buffer> — iterate to get frames after each event:
267
+ * ```typescript
268
+ * for await (const frame of app.run(<App />)) {
269
+ * expect(frame.text).toContain('expected')
270
+ * }
271
+ * ```
272
+ */
273
+ export interface AppHandle<S> {
274
+ /** Current rendered text (no ANSI) */
275
+ readonly text: string
276
+ /** Access to the Zustand store */
277
+ readonly store: StoreApi<S>
278
+ /** Wait until the app exits */
279
+ waitUntilExit(): Promise<void>
280
+ /** Unmount and cleanup */
281
+ unmount(): void
282
+ /** Dispose (alias for unmount) — enables `using` */
283
+ [Symbol.dispose](): void
284
+ /** Send a key press (simulates term:key event) */
285
+ press(key: string): Promise<void>
286
+ /** Iterate frames yielded after each event */
287
+ [Symbol.asyncIterator](): AsyncIterator<Buffer>
288
+ }
289
+
290
+ /**
291
+ * App definition returned by createApp().
292
+ */
293
+ export interface AppDefinition<S> {
294
+ run(element: ReactElement, options?: AppRunOptions): AppRunner<S>
295
+ }
296
+
297
+ /**
298
+ * Result of app.run() — both a Promise<AppHandle> and an AsyncIterable<Buffer>.
299
+ *
300
+ * - `await app.run(el)` → AppHandle (backward compat)
301
+ * - `for await (const frame of app.run(el))` → iterate frames
302
+ */
303
+ export interface AppRunner<S> extends AsyncIterable<Buffer>, PromiseLike<AppHandle<S>> {}
304
+
305
+ // ============================================================================
306
+ // Store Context
307
+ // ============================================================================
308
+
309
+ export const StoreContext = createContext<StoreApi<unknown> | null>(null)
310
+
311
+ /**
312
+ * Hook for accessing app state with selectors.
313
+ *
314
+ * @example
315
+ * ```tsx
316
+ * const count = useApp(s => s.count)
317
+ * const { count, increment } = useApp(s => ({ count: s.count, increment: s.increment }))
318
+ * ```
319
+ */
320
+ export function useApp<S, T>(selector: (state: S) => T): T {
321
+ const store = useContext(StoreContext) as StoreApi<S> | null
322
+ if (!store) throw new Error("useApp must be used within createApp().run()")
323
+
324
+ const [state, setState] = React.useState(() => selector(store.getState()))
325
+ const selectorRef = useRef(selector)
326
+ selectorRef.current = selector
327
+
328
+ useEffect(() => {
329
+ return store.subscribe((newState) => {
330
+ const next = selectorRef.current(newState)
331
+ // Only update if the selected value actually changed (avoids
332
+ // unnecessary re-renders when unrelated store slices change)
333
+ setState((prev) => (Object.is(prev, next) ? prev : next))
334
+ })
335
+ }, [store])
336
+
337
+ return state
338
+ }
339
+
340
+ /**
341
+ * Shallow comparison for plain objects.
342
+ * Returns true if objects have same keys with Object.is() equal values.
343
+ */
344
+ function shallowEqual<T>(a: T, b: T): boolean {
345
+ if (Object.is(a, b)) return true
346
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
347
+ return false
348
+ }
349
+ const keysA = Object.keys(a as Record<string, unknown>)
350
+ const keysB = Object.keys(b as Record<string, unknown>)
351
+ if (keysA.length !== keysB.length) return false
352
+ for (const key of keysA) {
353
+ if (!Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
354
+ return false
355
+ }
356
+ }
357
+ return true
358
+ }
359
+
360
+ /**
361
+ * Hook for accessing app state with shallow comparison.
362
+ *
363
+ * Like useApp, but uses shallow object comparison instead of Object.is().
364
+ * Use when your selector returns a new object on each call — this prevents
365
+ * re-renders when all individual fields are unchanged.
366
+ *
367
+ * @example
368
+ * ```tsx
369
+ * const { cursor, mode } = useAppShallow(s => ({
370
+ * cursor: s.cursorNodeId,
371
+ * mode: s.viewMode,
372
+ * }))
373
+ * ```
374
+ */
375
+ export function useAppShallow<S, T>(selector: (state: S) => T): T {
376
+ const store = useContext(StoreContext) as StoreApi<S> | null
377
+ if (!store) throw new Error("useAppShallow must be used within createApp().run()")
378
+
379
+ const [state, setState] = React.useState(() => selector(store.getState()))
380
+ const selectorRef = useRef(selector)
381
+ selectorRef.current = selector
382
+
383
+ useEffect(() => {
384
+ return store.subscribe((newState) => {
385
+ const next = selectorRef.current(newState)
386
+ setState((prev) => (shallowEqual(prev, next) ? prev : next))
387
+ })
388
+ }, [store])
389
+
390
+ return state
391
+ }
392
+
393
+ // ============================================================================
394
+ // Implementation
395
+ // ============================================================================
396
+
397
+ /**
398
+ * Create an app with Zustand store and provider integration.
399
+ *
400
+ * This is Layer 3 - it provides:
401
+ * - Zustand store with fine-grained subscriptions
402
+ * - Providers as unified stores + event sources
403
+ * - Event handlers namespaced as 'provider:event'
404
+ *
405
+ * @param factory Store factory function that receives providers
406
+ * @param handlers Optional event handlers (namespaced as 'provider:event')
407
+ */
408
+ export function createApp<I extends Record<string, unknown>, S extends Record<string, unknown>>(
409
+ factory: (inject: I) => StateCreator<S>,
410
+ handlers?: EventHandlers<S & I>,
411
+ ): AppDefinition<S & I> {
412
+ return {
413
+ run(element: ReactElement, options: AppRunOptions = {}): AppRunner<S & I> {
414
+ // Lazy-init: the actual setup happens once, on first access
415
+ let handlePromise: Promise<AppHandle<S & I>> | null = null
416
+
417
+ const init = (): Promise<AppHandle<S & I>> => {
418
+ if (handlePromise) return handlePromise
419
+ handlePromise = initApp(factory, handlers, element, options)
420
+ return handlePromise
421
+ }
422
+
423
+ return {
424
+ // PromiseLike — makes `await app.run(el)` work
425
+ then<TResult1 = AppHandle<S & I>, TResult2 = never>(
426
+ onfulfilled?: ((value: AppHandle<S & I>) => TResult1 | PromiseLike<TResult1>) | null,
427
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
428
+ ): Promise<TResult1 | TResult2> {
429
+ return init().then(onfulfilled, onrejected)
430
+ },
431
+
432
+ // AsyncIterable — makes `for await (const frame of app.run(el))` work
433
+ [Symbol.asyncIterator](): AsyncIterator<Buffer> {
434
+ let handle: AppHandle<S & I> | null = null
435
+ let iterator: AsyncIterator<Buffer> | null = null
436
+ let started = false
437
+
438
+ return {
439
+ async next(): Promise<IteratorResult<Buffer>> {
440
+ if (!started) {
441
+ started = true
442
+ handle = await init()
443
+ iterator = handle[Symbol.asyncIterator]()
444
+ }
445
+ return iterator!.next()
446
+ },
447
+ async return(): Promise<IteratorResult<Buffer>> {
448
+ if (handle) handle.unmount()
449
+ return { done: true, value: undefined as unknown as Buffer }
450
+ },
451
+ }
452
+ },
453
+ }
454
+ },
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Initialize the app — extracted from run() for clarity.
460
+ */
461
+ async function initApp<I extends Record<string, unknown>, S extends Record<string, unknown>>(
462
+ factory: (inject: I) => StateCreator<S>,
463
+ handlers: EventHandlers<S & I> | undefined,
464
+ element: ReactElement,
465
+ options: AppRunOptions,
466
+ ): Promise<AppHandle<S & I>> {
467
+ const {
468
+ cols: explicitCols,
469
+ rows: explicitRows,
470
+ stdout: explicitStdout,
471
+ stdin = process.stdin,
472
+ signal: externalSignal,
473
+ alternateScreen = false,
474
+ kittyMode: useKittyMode = false,
475
+ kitty: kittyOption,
476
+ mouse: mouseOption = false,
477
+ suspendOnCtrlZ: suspendOption = true,
478
+ exitOnCtrlC: exitOnCtrlCOption = true,
479
+ onSuspend: onSuspendHook,
480
+ onResume: onResumeHook,
481
+ onInterrupt: onInterruptHook,
482
+ textSizing: textSizingOption,
483
+ focusReporting: focusReportingOption = false,
484
+ caps: capsOption,
485
+ Root: RootComponent,
486
+ writable: explicitWritable,
487
+ onResize: explicitOnResize,
488
+ ...injectValues
489
+ } = options
490
+
491
+ const headless = (explicitCols != null && explicitRows != null && !explicitStdout) || explicitWritable != null
492
+ const cols = explicitCols ?? process.stdout.columns ?? 80
493
+ const rows = explicitRows ?? process.stdout.rows ?? 24
494
+ const stdout = explicitStdout ?? process.stdout
495
+
496
+ // Initialize layout engine
497
+ await ensureLayoutEngine()
498
+
499
+ // Create abort controller for cleanup
500
+ const controller = new AbortController()
501
+ const signal = controller.signal
502
+
503
+ // Wire external signal
504
+ if (externalSignal) {
505
+ if (externalSignal.aborted) {
506
+ controller.abort()
507
+ } else {
508
+ externalSignal.addEventListener("abort", () => controller.abort(), {
509
+ once: true,
510
+ })
511
+ }
512
+ }
513
+
514
+ // Separate providers from plain values
515
+ const providers: Record<string, Provider<unknown, Record<string, unknown>>> = {}
516
+ const plainValues: Record<string, unknown> = {}
517
+ const providerCleanups: (() => void)[] = []
518
+
519
+ // Create term provider if not provided
520
+ let termProvider: TermProvider | null = null
521
+ if (!("term" in injectValues) || !isFullProvider(injectValues.term)) {
522
+ // In headless mode, provide mock streams so termProvider doesn't touch real stdin/stdout.
523
+ // When onResize is provided, the mock supports resize events so the term provider
524
+ // picks up dimension changes and triggers re-renders through the event loop.
525
+ const resizeListeners = new Set<() => void>()
526
+ const termStdout = headless
527
+ ? ({
528
+ columns: cols,
529
+ rows,
530
+ write: () => true,
531
+ isTTY: false,
532
+ on(event: string, handler: () => void) {
533
+ if (event === "resize") resizeListeners.add(handler)
534
+ return termStdout
535
+ },
536
+ off(event: string, handler: () => void) {
537
+ if (event === "resize") resizeListeners.delete(handler)
538
+ return termStdout
539
+ },
540
+ } as unknown as NodeJS.WriteStream)
541
+ : stdout
542
+ const termStdin = headless
543
+ ? ({
544
+ isTTY: false,
545
+ on: () => termStdin,
546
+ off: () => termStdin,
547
+ setRawMode: () => {},
548
+ resume: () => {},
549
+ pause: () => {},
550
+ setEncoding: () => {},
551
+ } as unknown as NodeJS.ReadStream)
552
+ : stdin
553
+ termProvider = createTermProvider(termStdin, termStdout, { cols, rows })
554
+ providers.term = termProvider as unknown as Provider<unknown, Record<string, unknown>>
555
+ providerCleanups.push(() => termProvider![Symbol.dispose]())
556
+
557
+ // Wire onResize to the mock termStdout so the term provider sees resize events.
558
+ // This updates:
559
+ // 1. currentDims — so getDims() returns correct values for doRender()
560
+ // 2. mock termStdout columns/rows — so the term provider reads correct dimensions
561
+ // 3. mock termStdout resize listeners — triggers term:resize through the provider's
562
+ // event stream → event loop → doRender()
563
+ if (headless && explicitOnResize) {
564
+ const unsub = explicitOnResize((dims) => {
565
+ currentDims = dims
566
+ ;(termStdout as { columns: number; rows: number }).columns = dims.cols
567
+ ;(termStdout as { columns: number; rows: number }).rows = dims.rows
568
+ for (const listener of resizeListeners) listener()
569
+ })
570
+ providerCleanups.push(unsub)
571
+ }
572
+ }
573
+
574
+ // Categorize injected values
575
+ for (const [name, value] of Object.entries(injectValues)) {
576
+ if (isFullProvider(value)) {
577
+ providers[name] = value
578
+ } else {
579
+ plainValues[name] = value
580
+ }
581
+ }
582
+
583
+ // Build inject object (providers + plain values)
584
+ const inject = { ...providers, ...plainValues } as I
585
+
586
+ // Subscribe to provider state changes
587
+ const stateUnsubscribes: (() => void)[] = []
588
+
589
+ // Create store
590
+ const store = createStore<S & I>((set, get, api) => {
591
+ // Get base state from factory
592
+ const baseState = factory(inject)(
593
+ set as StoreApi<S>["setState"],
594
+ get as StoreApi<S>["getState"],
595
+ api as StoreApi<S>,
596
+ )
597
+
598
+ // Merge provider references into state (for access via selectors)
599
+ const mergedState: Record<string, unknown> = { ...baseState }
600
+
601
+ for (const [name, provider] of Object.entries(providers)) {
602
+ mergedState[name] = provider
603
+
604
+ // Subscribe to provider state changes (basic providers only)
605
+ if (isBasicProvider(provider)) {
606
+ const unsub = provider.subscribe((_providerState) => {
607
+ // Could flatten provider state here if desired
608
+ // For now, just trigger a re-check
609
+ })
610
+ stateUnsubscribes.push(unsub)
611
+ }
612
+ }
613
+
614
+ // Add plain values
615
+ for (const [name, value] of Object.entries(plainValues)) {
616
+ mergedState[name] = value
617
+ }
618
+
619
+ return mergedState as S & I
620
+ })
621
+
622
+ // Track current dimensions
623
+ let currentDims: Dims = { cols, rows }
624
+ let shouldExit = false
625
+ let renderPaused = false
626
+ let isRendering = false // Re-entrancy guard for store subscription
627
+ let inEventHandler = false // True during processEvent/press — suppresses subscription renders
628
+ let pendingRerender = false // Deferred render flag for re-entrancy
629
+
630
+ // ========================================================================
631
+ // ANSI Trace: SILVERY_TRACE=1 logs all stdout writes with decoded sequences
632
+ // ========================================================================
633
+ const _ansiTrace = !headless && process.env?.SILVERY_TRACE === "1"
634
+
635
+ let _traceSeq = 0
636
+ const _traceStart = performance.now()
637
+ let _origStdoutWrite: typeof process.stdout.write | undefined
638
+
639
+ if (_ansiTrace) {
640
+ const fs =
641
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
642
+ require("node:fs") as typeof import("node:fs")
643
+ fs.writeFileSync("/tmp/silvery-trace.log", `=== SILVERY TRACE START ===\n`)
644
+
645
+ _origStdoutWrite = stdout.write.bind(stdout) as typeof stdout.write
646
+
647
+ const symbolize = (s: string): string =>
648
+ s
649
+ .replace(/\x1b\[\?1049h/g, "⟨ALT_ON⟩")
650
+ .replace(/\x1b\[\?1049l/g, "⟨ALT_OFF⟩")
651
+ .replace(/\x1b\[2J/g, "⟨CLEAR⟩")
652
+ .replace(/\x1b\[H/g, "⟨HOME⟩")
653
+ .replace(/\x1b\[\?25l/g, "⟨CUR_HIDE⟩")
654
+ .replace(/\x1b\[\?25h/g, "⟨CUR_SHOW⟩")
655
+ .replace(/\x1b\[\?2026h/g, "⟨SYNC_ON⟩")
656
+ .replace(/\x1b\[\?2026l/g, "⟨SYNC_OFF⟩")
657
+ .replace(/\x1b\[\?2004h/g, "⟨BPASTE_ON⟩")
658
+ .replace(/\x1b\[\?2004l/g, "⟨BPASTE_OFF⟩")
659
+ .replace(/\x1b\[0m/g, "⟨RST⟩")
660
+ .replace(/\x1b\[(\d+);(\d+)H/g, "⟨GO $1,$2⟩")
661
+ .replace(/\x1b\[38;5;(\d+)m/g, "⟨F$1⟩")
662
+ .replace(/\x1b\[48;5;(\d+)m/g, "⟨B$1⟩")
663
+ .replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, "⟨FR$1,$2,$3⟩")
664
+ .replace(/\x1b\[48;2;(\d+);(\d+);(\d+)m/g, "⟨BR$1,$2,$3⟩")
665
+ .replace(/\x1b\[1m/g, "⟨BOLD⟩")
666
+ .replace(/\x1b\[2m/g, "⟨DIM⟩")
667
+ .replace(/\x1b\[3m/g, "⟨ITAL⟩")
668
+ .replace(/\x1b\[4m/g, "⟨UL⟩")
669
+ .replace(/\x1b\[7m/g, "⟨INV⟩")
670
+ .replace(/\x1b\[22m/g, "⟨/BOLD⟩")
671
+ .replace(/\x1b\[23m/g, "⟨/ITAL⟩")
672
+ .replace(/\x1b\[24m/g, "⟨/UL⟩")
673
+ .replace(/\x1b\[27m/g, "⟨/INV⟩")
674
+ .replace(/\x1b\[39m/g, "⟨/FG⟩")
675
+ .replace(/\x1b\[49m/g, "⟨/BG⟩")
676
+ // Catch remaining CSI sequences
677
+ .replace(/\x1b\[([0-9;]*)([A-Za-z])/g, "⟨CSI $1$2⟩")
678
+ // Catch remaining ESC sequences
679
+ .replace(/\x1b([^\[])/, "⟨ESC $1⟩")
680
+
681
+ const traceWrite = function (this: typeof stdout, chunk: unknown, ...args: unknown[]): boolean {
682
+ const str = typeof chunk === "string" ? chunk : String(chunk)
683
+ const seq = ++_traceSeq
684
+ const ms = (performance.now() - _traceStart).toFixed(0)
685
+ const decoded = symbolize(str)
686
+ // Truncate for readability but keep enough to identify content
687
+ const preview =
688
+ decoded.length > 400 ? decoded.slice(0, 200) + ` ...[${decoded.length}ch]... ` + decoded.slice(-100) : decoded
689
+ fs.appendFileSync(
690
+ "/tmp/silvery-trace.log",
691
+ `[${String(seq).padStart(4, "0")}] +${ms}ms (${str.length}b): ${preview}\n`,
692
+ )
693
+ return (_origStdoutWrite as Function).call(this, chunk, ...args)
694
+ } as typeof stdout.write
695
+
696
+ stdout.write = traceWrite
697
+ // Restore original stdout.write on cleanup (providerCleanups runs during cleanup())
698
+ providerCleanups.push(() => {
699
+ if (_origStdoutWrite) stdout.write = _origStdoutWrite
700
+ })
701
+ }
702
+
703
+ // Create render target
704
+ const target: RenderTarget = headless
705
+ ? {
706
+ write(frame: string) {
707
+ if (explicitWritable) explicitWritable.write(frame)
708
+ },
709
+ getDims: () => currentDims,
710
+ }
711
+ : {
712
+ write(frame: string): void {
713
+ if (_perfLog) {
714
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
715
+ require("node:fs").appendFileSync(
716
+ "/tmp/silvery-perf.log",
717
+ `TARGET.write: ${frame.length} bytes (paused=${renderPaused})\n`,
718
+ )
719
+ }
720
+ if (!renderPaused) stdout.write(frame)
721
+ },
722
+ getDims(): Dims {
723
+ return currentDims
724
+ },
725
+ onResize(handler: (dims: Dims) => void): () => void {
726
+ const onResize = () => {
727
+ currentDims = {
728
+ cols: stdout.columns || 80,
729
+ rows: stdout.rows || 24,
730
+ }
731
+ handler(currentDims)
732
+ }
733
+ stdout.on("resize", onResize)
734
+ return () => stdout.off("resize", onResize)
735
+ },
736
+ }
737
+
738
+ // Resolve textSizing from caps + option (matches run.tsx gate)
739
+ const textSizingEnabled =
740
+ textSizingOption === true ||
741
+ (textSizingOption === "auto" && (capsOption?.textSizingSupported ?? isTextSizingLikelySupported()))
742
+
743
+ // Create pipeline config from caps (scoped width measurer + output phase)
744
+ const pipelineConfig = capsOption
745
+ ? createPipeline({ caps: { ...capsOption, textSizingSupported: textSizingEnabled } })
746
+ : undefined
747
+
748
+ // Create runtime (pass scoped output phase to ensure measurer/caps are threaded)
749
+ // mode must match alternateScreen: inline apps (alternateScreen=false) need
750
+ // inline output phase rendering (relative cursor) + scrollback offset tracking.
751
+ const runtime = createRuntime({
752
+ target,
753
+ signal,
754
+ mode: alternateScreen ? "fullscreen" : "inline",
755
+ outputPhaseFn: pipelineConfig?.outputPhaseFn,
756
+ })
757
+
758
+ // Cleanup state
759
+ let cleanedUp = false
760
+ let storeUnsubscribeFn: (() => void) | null = null
761
+ // Track protocol state for cleanup and suspend/resume
762
+ let kittyEnabled = false
763
+ let kittyFlags: number = KittyFlags.DISAMBIGUATE
764
+ let mouseEnabled = false
765
+ let focusReportingEnabled = false
766
+
767
+ // Focus manager (tree-based focus system) with event dispatch wiring
768
+ const focusManager = createFocusManager({
769
+ onFocusChange(oldNode, newNode, _origin) {
770
+ // Dispatch blur event on the old element
771
+ if (oldNode) {
772
+ const blurEvent = createFocusEvent("blur", oldNode, newNode)
773
+ dispatchFocusEvent(blurEvent)
774
+ }
775
+ // Dispatch focus event on the new element
776
+ if (newNode) {
777
+ const focusEvent = createFocusEvent("focus", newNode, oldNode)
778
+ dispatchFocusEvent(focusEvent)
779
+ }
780
+ },
781
+ })
782
+
783
+ // Wire up focus cleanup on node removal — when React unmounts a subtree,
784
+ // the host-config calls this to clear focus if the active element was removed.
785
+ setOnNodeRemoved((removedNode) => focusManager.handleSubtreeRemoved(removedNode))
786
+
787
+ // Per-instance cursor state (replaces module-level globals)
788
+ const cursorStore = createCursorStore()
789
+
790
+ // Mouse event processor for DOM-level dispatch (with click-to-focus)
791
+ const mouseEventState = createMouseEventProcessor({ focusManager })
792
+
793
+ // Cleanup function - idempotent, can be called from exit() or finally
794
+ const cleanup = () => {
795
+ if (cleanedUp) return
796
+ cleanedUp = true
797
+
798
+ // Unmount React tree first — this runs effect cleanups (clears intervals,
799
+ // cancels subscriptions) before we tear down the infrastructure.
800
+ try {
801
+ reconciler.updateContainerSync(null, fiberRoot, null, () => {})
802
+ reconciler.flushSyncWork()
803
+ } catch {
804
+ // Ignore — component tree may already be partially torn down
805
+ }
806
+
807
+ // Unregister node removal hook
808
+ setOnNodeRemoved(null)
809
+
810
+ // Unsubscribe from store
811
+ if (storeUnsubscribeFn) {
812
+ storeUnsubscribeFn()
813
+ }
814
+
815
+ // Unsubscribe from provider state changes
816
+ stateUnsubscribes.forEach((unsub) => {
817
+ try {
818
+ unsub()
819
+ } catch {
820
+ // Ignore
821
+ }
822
+ })
823
+
824
+ // Cleanup providers (including termProvider)
825
+ providerCleanups.forEach((fn) => {
826
+ try {
827
+ fn()
828
+ } catch {
829
+ // Ignore
830
+ }
831
+ })
832
+
833
+ // Dispose runtime
834
+ runtime[Symbol.dispose]()
835
+
836
+ // Restore cursor and leave alternate screen
837
+ if (!headless) {
838
+ // Disable focus reporting before restoring terminal
839
+ if (focusReportingEnabled) disableFocusReporting((s) => stdout.write(s))
840
+ // Disable mouse tracking before restoring terminal
841
+ if (mouseEnabled) stdout.write(disableMouse())
842
+ // Disable Kitty keyboard protocol before restoring terminal
843
+ if (kittyEnabled) stdout.write(disableKittyKeyboard())
844
+ stdout.write("\x1b[?25h\x1b[0m\n")
845
+ if (alternateScreen) stdout.write("\x1b[?1049l")
846
+ }
847
+ }
848
+
849
+ let exit: () => void
850
+
851
+ // Create SilveryNode container.
852
+ // onRender fires during React's resetAfterCommit — inside the commit phase.
853
+ // Calling doRender from there would be re-entrant (doRender calls updateContainerSync
854
+ // which triggers commit which calls onRender again). Always defer via microtask.
855
+ // Without this callback, setInterval/setTimeout-driven setState never flushes to terminal.
856
+ const container = createContainer(() => {
857
+ if (shouldExit) return
858
+ if (inEventHandler) {
859
+ // During processEvent/press: just flag, caller's flush loop handles it.
860
+ pendingRerender = true
861
+ return
862
+ }
863
+ // Always defer — onRender fires during React commit, re-entry is unsafe.
864
+ if (!pendingRerender) {
865
+ pendingRerender = true
866
+ queueMicrotask(() => {
867
+ if (!pendingRerender) return
868
+ pendingRerender = false
869
+ if (!shouldExit && !isRendering) {
870
+ isRendering = true
871
+ try {
872
+ currentBuffer = doRender()
873
+ runtime.render(currentBuffer)
874
+ } finally {
875
+ isRendering = false
876
+ }
877
+ }
878
+ })
879
+ }
880
+ })
881
+
882
+ // Create React fiber root
883
+ const fiberRoot = createFiberRoot(container)
884
+
885
+ // Track current buffer for text access
886
+ let currentBuffer: Buffer
887
+
888
+ // Create mock stdout for contexts
889
+ const mockStdout = {
890
+ columns: cols,
891
+ rows: rows,
892
+ write: () => true,
893
+ isTTY: false,
894
+ on: () => mockStdout,
895
+ off: () => mockStdout,
896
+ once: () => mockStdout,
897
+ removeListener: () => mockStdout,
898
+ addListener: () => mockStdout,
899
+ } as unknown as NodeJS.WriteStream
900
+
901
+ // Create mock term
902
+ const mockTerm = createTerm({ color: "truecolor" })
903
+
904
+ // RuntimeContext input listeners — allows components using hooks/useInput
905
+ // (TextInput, TextArea, SelectList etc.) to work inside createApp apps.
906
+ const runtimeInputListeners = new Set<(input: string, key: Key) => void>()
907
+ const runtimePasteListeners = new Set<(text: string) => void>()
908
+ const runtimeFocusListeners = new Set<(focused: boolean) => void>()
909
+
910
+ // Typed event bus — supports view → runtime events via emit()
911
+ const runtimeEventListeners = new Map<string, Set<Function>>()
912
+ runtimeEventListeners.set("input", runtimeInputListeners as unknown as Set<Function>)
913
+ runtimeEventListeners.set("paste", runtimePasteListeners as unknown as Set<Function>)
914
+ runtimeEventListeners.set("focus", runtimeFocusListeners as unknown as Set<Function>)
915
+
916
+ const runtimeContextValue: RuntimeContextValue = {
917
+ on(event, handler) {
918
+ let listeners = runtimeEventListeners.get(event)
919
+ if (!listeners) {
920
+ listeners = new Set()
921
+ runtimeEventListeners.set(event, listeners)
922
+ }
923
+ listeners.add(handler)
924
+ return () => listeners!.delete(handler)
925
+ },
926
+ emit(event, ...args) {
927
+ const listeners = runtimeEventListeners.get(event)
928
+ if (listeners) {
929
+ for (const listener of listeners) {
930
+ listener(...args)
931
+ }
932
+ }
933
+ },
934
+ exit: () => exit(),
935
+ }
936
+
937
+ // Wrap element with all required providers
938
+ // SilveryErrorBoundary is always the outermost wrapper — catches render errors gracefully.
939
+ // If a Root component is provided (e.g., from withInk), wrap the element with it
940
+ // inside silvery's contexts so it can access Term, Stdout, FocusManager, Runtime.
941
+ const Root = RootComponent ?? React.Fragment
942
+ const wrappedElement = (
943
+ <SilveryErrorBoundary>
944
+ <CursorProvider store={cursorStore}>
945
+ <TermContext.Provider value={mockTerm}>
946
+ <StdoutContext.Provider
947
+ value={{
948
+ stdout: mockStdout,
949
+ write: () => {},
950
+ notifyScrollback: (lines: number) => runtime.addScrollbackLines(lines),
951
+ promoteScrollback: (content: string, lines: number) => runtime.promoteScrollback(content, lines),
952
+ resetInlineCursor: () => runtime.resetInlineCursor(),
953
+ getInlineCursorRow: () => runtime.getInlineCursorRow(),
954
+ }}
955
+ >
956
+ <StderrContext.Provider
957
+ value={{
958
+ stderr: process.stderr,
959
+ write: (data: string) => {
960
+ process.stderr.write(data)
961
+ },
962
+ }}
963
+ >
964
+ <FocusManagerContext.Provider value={focusManager}>
965
+ <RuntimeContext.Provider value={runtimeContextValue}>
966
+ <Root>
967
+ <StoreContext.Provider value={store as StoreApi<unknown>}>{element}</StoreContext.Provider>
968
+ </Root>
969
+ </RuntimeContext.Provider>
970
+ </FocusManagerContext.Provider>
971
+ </StderrContext.Provider>
972
+ </StdoutContext.Provider>
973
+ </TermContext.Provider>
974
+ </CursorProvider>
975
+ </SilveryErrorBoundary>
976
+ )
977
+
978
+ // Performance instrumentation — count renders per event
979
+ let _renderCount = 0
980
+ let _eventStart = 0
981
+ const _perfLog = typeof process !== "undefined" && process.env?.DEBUG?.includes("silvery:perf")
982
+
983
+ // Incremental rendering — store previous pipeline buffer for diffing.
984
+ // Without this, every render walks the entire node tree from scratch.
985
+ // Set SILVERY_NO_INCREMENTAL=1 to disable (for debugging blank screen issues).
986
+ const _noIncremental = process.env?.SILVERY_NO_INCREMENTAL === "1"
987
+ let _prevTermBuffer: import("../buffer.js").TerminalBuffer | null = null
988
+
989
+ // Helper to render and get text
990
+ function doRender(): Buffer {
991
+ _renderCount++
992
+ if (_ansiTrace) {
993
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
994
+ require("node:fs").appendFileSync(
995
+ "/tmp/silvery-trace.log",
996
+ `--- doRender #${_renderCount} (prev=${_prevTermBuffer ? "yes" : "null"}, incremental=${!_noIncremental && !!_prevTermBuffer}) ---\n`,
997
+ )
998
+ }
999
+ const renderStart = performance.now()
1000
+
1001
+ // Phase A: React reconciliation
1002
+ reconciler.updateContainerSync(wrappedElement, fiberRoot, null, () => {})
1003
+ reconciler.flushSyncWork()
1004
+ const reconcileMs = performance.now() - renderStart
1005
+
1006
+ // Phase B: Render pipeline (incremental when prevBuffer available)
1007
+ const pipelineStart = performance.now()
1008
+ const rootNode = getContainerRoot(container)
1009
+ const dims = runtime.getDims()
1010
+
1011
+ const isInline = !alternateScreen
1012
+
1013
+ // Invalidate prevBuffer on dimension change (resize).
1014
+ // Both pipeline-level (_prevTermBuffer) and runtime-level (runtime.invalidate())
1015
+ // must be cleared — otherwise the ANSI diff compares different-sized buffers.
1016
+ //
1017
+ // In inline mode, only WIDTH changes trigger invalidation. Height changes are
1018
+ // normal (content grows/shrinks as items are added/frozen) and are handled
1019
+ // incrementally by the output phase. Invalidating on height causes the runtime's
1020
+ // prevBuffer to be null, which triggers the first-render clear path with \x1b[J
1021
+ // — wiping the entire visible screen including shell prompt content above the app.
1022
+ if (_prevTermBuffer) {
1023
+ const widthChanged = dims.cols !== _prevTermBuffer.width
1024
+ const heightChanged = !isInline && dims.rows !== _prevTermBuffer.height
1025
+ if (widthChanged || heightChanged) {
1026
+ _prevTermBuffer = null
1027
+ runtime.invalidate()
1028
+ }
1029
+ }
1030
+
1031
+ // Clear diagnostic arrays before the render so we capture only this render's data
1032
+ ;(globalThis as any).__silvery_content_all = undefined
1033
+ ;(globalThis as any).__silvery_node_trace = undefined
1034
+ // Cell debug: enable during real incremental render for SILVERY_STRICT diagnosis.
1035
+ // Set SILVERY_CELL_DEBUG=x,y to trace which nodes cover a specific cell.
1036
+ // The log is captured during the render and included in any mismatch error.
1037
+ ;(globalThis as any).__silvery_cell_debug = undefined
1038
+ const _cellDebugVal = typeof process !== "undefined" ? process.env?.SILVERY_CELL_DEBUG : undefined
1039
+ if (_cellDebugVal?.includes(",")) {
1040
+ const [cx, cy] = _cellDebugVal.split(",").map(Number)
1041
+ if (Number.isFinite(cx) && Number.isFinite(cy)) {
1042
+ ;(globalThis as any).__silvery_cell_debug = { x: cx, y: cy, log: [] as string[] }
1043
+ }
1044
+ }
1045
+
1046
+ // Early return: if reconciliation produced no dirty flags on the tree,
1047
+ // skip the pipeline entirely. This avoids cloning _prevTermBuffer (which
1048
+ // resets dirty rows to 0), preserving the row-level dirty markers that
1049
+ // the runtime diff needs to detect actual changes.
1050
+ const rootHasDirty =
1051
+ rootNode.layoutDirty ||
1052
+ rootNode.contentDirty ||
1053
+ rootNode.paintDirty ||
1054
+ rootNode.bgDirty ||
1055
+ rootNode.subtreeDirty ||
1056
+ rootNode.childrenDirty
1057
+ if (!rootHasDirty && _prevTermBuffer && currentBuffer) {
1058
+ return currentBuffer
1059
+ }
1060
+
1061
+ const wasIncremental = !_noIncremental && _prevTermBuffer !== null
1062
+ const { buffer: termBuffer } = executeRender(
1063
+ rootNode,
1064
+ dims.cols,
1065
+ dims.rows,
1066
+ wasIncremental ? _prevTermBuffer : null,
1067
+ // Always use fullscreen mode here — the pipeline's output is discarded.
1068
+ // The runtime's render() handles inline mode output separately.
1069
+ // Using inline mode here would modify the shared inline cursor state
1070
+ // (prevCursorRow, prevBuffer) before runtime.render() gets a chance,
1071
+ // causing the runtime to produce 0-byte output.
1072
+ undefined,
1073
+ pipelineConfig,
1074
+ )
1075
+ if (!_noIncremental) _prevTermBuffer = termBuffer
1076
+ const pipelineMs = performance.now() - pipelineStart
1077
+
1078
+ // SILVERY_CHECK_INCREMENTAL: compare incremental render against fresh render.
1079
+ // createApp bypasses Scheduler/Renderer which have this check built-in,
1080
+ // so we add it here to catch incremental rendering bugs at runtime.
1081
+ const strictEnv =
1082
+ typeof process !== "undefined" && (process.env?.SILVERY_STRICT || process.env?.SILVERY_CHECK_INCREMENTAL)
1083
+ if (strictEnv && strictEnv !== "0" && strictEnv !== "false" && wasIncremental) {
1084
+ const { buffer: freshBuffer } = executeRender(
1085
+ rootNode,
1086
+ dims.cols,
1087
+ dims.rows,
1088
+ null,
1089
+ {
1090
+ skipLayoutNotifications: true,
1091
+ skipScrollStateUpdates: true,
1092
+ },
1093
+ pipelineConfig,
1094
+ )
1095
+ const { cellEquals, bufferToText } =
1096
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1097
+ require("../buffer.js") as typeof import("../buffer.js")
1098
+ for (let y = 0; y < termBuffer.height; y++) {
1099
+ for (let x = 0; x < termBuffer.width; x++) {
1100
+ const a = termBuffer.getCell(x, y)
1101
+ const b = freshBuffer.getCell(x, y)
1102
+ if (!cellEquals(a, b)) {
1103
+ // Use cell debug log collected during the real incremental render
1104
+ let cellDebugInfo = ""
1105
+ const savedCellDbg = (globalThis as any).__silvery_cell_debug as
1106
+ | { x: number; y: number; log: string[] }
1107
+ | undefined
1108
+ if (savedCellDbg && savedCellDbg.x === x && savedCellDbg.y === y && savedCellDbg.log.length > 0) {
1109
+ cellDebugInfo = `\nCELL DEBUG (${savedCellDbg.log.length} entries for (${x},${y})):\n${savedCellDbg.log.join("\n")}\n`
1110
+ } else if (savedCellDbg && savedCellDbg.x === x && savedCellDbg.y === y) {
1111
+ cellDebugInfo = `\nCELL DEBUG: No nodes cover (${x},${y}) during incremental render\n`
1112
+ } else {
1113
+ cellDebugInfo = `\nCELL DEBUG: Target cell (${x},${y}) differs from debug cell (${savedCellDbg?.x},${savedCellDbg?.y})\n`
1114
+ }
1115
+
1116
+ // Re-run fresh render with write trap to capture what writes to the mismatched cell
1117
+ let trapInfo = ""
1118
+ const trap = { x, y, log: [] as string[] }
1119
+ ;(globalThis as any).__silvery_write_trap = trap
1120
+ try {
1121
+ executeRender(
1122
+ rootNode,
1123
+ dims.cols,
1124
+ dims.rows,
1125
+ null,
1126
+ {
1127
+ skipLayoutNotifications: true,
1128
+ skipScrollStateUpdates: true,
1129
+ },
1130
+ pipelineConfig,
1131
+ )
1132
+ } catch {
1133
+ // ignore
1134
+ }
1135
+ ;(globalThis as any).__silvery_write_trap = null
1136
+ if (trap.log.length > 0) {
1137
+ trapInfo = `\nWRITE TRAP (${trap.log.length} writes to (${x},${y})):\n${trap.log.join("\n")}\n`
1138
+ } else {
1139
+ trapInfo = `\nWRITE TRAP: NO WRITES to (${x},${y})\n`
1140
+ }
1141
+ const incText = bufferToText(termBuffer)
1142
+ const freshText = bufferToText(freshBuffer)
1143
+ const cellStr = (c: typeof a) =>
1144
+ `char=${JSON.stringify(c.char)} fg=${c.fg} bg=${c.bg} ulColor=${c.underlineColor} wide=${c.wide} cont=${c.continuation} attrs={bold=${c.attrs.bold},dim=${c.attrs.dim},italic=${c.attrs.italic},ul=${c.attrs.underline},ulStyle=${c.attrs.underlineStyle},blink=${c.attrs.blink},inv=${c.attrs.inverse},hidden=${c.attrs.hidden},strike=${c.attrs.strikethrough}}`
1145
+ // Dump content phase stats for diagnosis
1146
+ const contentAll = (globalThis as any).__silvery_content_all as unknown[]
1147
+ const statsStr = contentAll
1148
+ ? `\n--- content phase stats (${contentAll.length} calls) ---\n` +
1149
+ contentAll
1150
+ .map(
1151
+ (s: any, i: number) =>
1152
+ ` #${i}: visited=${s.nodesVisited} rendered=${s.nodesRendered} skipped=${s.nodesSkipped} ` +
1153
+ `clearOps=${s.clearOps} cascade="${s.cascadeNodes}" ` +
1154
+ `flags={C=${s.flagContentDirty} P=${s.flagPaintDirty} L=${s.flagLayoutChanged} ` +
1155
+ `S=${s.flagSubtreeDirty} Ch=${s.flagChildrenDirty} CP=${s.flagChildPositionChanged} AL=${s.flagAncestorLayoutChanged} noPrev=${s.noPrevBuffer}} ` +
1156
+ `scroll={containers=${s.scrollContainerCount} cleared=${s.scrollViewportCleared} reason="${s.scrollClearReason}"} ` +
1157
+ `normalRepaint="${s.normalRepaintReason}" ` +
1158
+ `prevBuf={null=${s._prevBufferNull} dimMismatch=${s._prevBufferDimMismatch} hasPrev=${s._hasPrevBuffer} ` +
1159
+ `layout=${s._layoutW}x${s._layoutH} prev=${s._prevW}x${s._prevH}}`,
1160
+ )
1161
+ .join("\n")
1162
+ : ""
1163
+ const msg =
1164
+ `SILVERY_CHECK_INCREMENTAL (createApp): MISMATCH at (${x}, ${y}) on render #${_renderCount}\n` +
1165
+ ` incremental: ${cellStr(a)}\n` +
1166
+ ` fresh: ${cellStr(b)}` +
1167
+ statsStr +
1168
+ // Per-node trace
1169
+ (() => {
1170
+ const traces = (globalThis as any).__silvery_node_trace as unknown[][] | undefined
1171
+ if (!traces || traces.length === 0) return ""
1172
+ let out = "\n--- node trace ---"
1173
+ for (let ti = 0; ti < traces.length; ti++) {
1174
+ out += `\n contentPhase #${ti}:`
1175
+ for (const t of traces[ti] as any[]) {
1176
+ out += `\n ${t.decision} ${t.id}(${t.type})@${t.depth} rect=${t.rect} prev=${t.prevLayout}`
1177
+ out += ` hasPrev=${t.hasPrev} ancClr=${t.ancestorCleared} flags=[${t.flags}] layout∆=${t.layoutChanged}`
1178
+ if (t.decision === "RENDER") {
1179
+ out += ` caa=${t.contentAreaAffected} prc=${t.parentRegionCleared} prm=${t.parentRegionChanged}`
1180
+ out += ` childPrev=${t.childHasPrev} childAnc=${t.childAncestorCleared} skipBg=${t.skipBgFill} bg=${t.bgColor ?? "none"}`
1181
+ }
1182
+ }
1183
+ }
1184
+ return out
1185
+ })() +
1186
+ cellDebugInfo +
1187
+ trapInfo +
1188
+ `\n--- incremental ---\n${incText}\n--- fresh ---\n${freshText}`
1189
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1190
+ require("node:fs").appendFileSync("/tmp/silvery-perf.log", msg + "\n")
1191
+ // Also throw to make it visible
1192
+ throw new IncrementalRenderMismatchError(msg)
1193
+ }
1194
+ }
1195
+ }
1196
+ if (_perfLog) {
1197
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1198
+ require("node:fs").appendFileSync(
1199
+ "/tmp/silvery-perf.log",
1200
+ `SILVERY_CHECK_INCREMENTAL (createApp): render #${_renderCount} OK\n`,
1201
+ )
1202
+ }
1203
+ }
1204
+
1205
+ const buf = createBuffer(termBuffer, rootNode)
1206
+ if (_perfLog) {
1207
+ const renderDuration = performance.now() - renderStart
1208
+ const phases = (globalThis as any).__silvery_last_pipeline
1209
+ const detail = (globalThis as any).__silvery_content_detail
1210
+ const phaseStr = phases
1211
+ ? ` [measure=${phases.measure.toFixed(1)} layout=${phases.layout.toFixed(1)} content=${phases.content.toFixed(1)} output=${phases.output.toFixed(1)}]`
1212
+ : ""
1213
+ const detailStr = detail
1214
+ ? ` {visited=${detail.nodesVisited} rendered=${detail.nodesRendered} skipped=${detail.nodesSkipped} noPrev=${detail.noPrevBuffer ?? 0} dirty=${detail.flagContentDirty ?? 0} paint=${detail.flagPaintDirty ?? 0} layoutChg=${detail.flagLayoutChanged ?? 0} subtree=${detail.flagSubtreeDirty ?? 0} children=${detail.flagChildrenDirty ?? 0} childPos=${detail.flagChildPositionChanged ?? 0} scroll=${detail.scrollContainerCount ?? 0}/${detail.scrollViewportCleared ?? 0}${detail.scrollClearReason ? `(${detail.scrollClearReason})` : ""}}${detail.cascadeNodes ? ` CASCADE[minDepth=${detail.cascadeMinDepth} ${detail.cascadeNodes}]` : ""}`
1215
+ : ""
1216
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1217
+ require("node:fs").appendFileSync(
1218
+ "/tmp/silvery-perf.log",
1219
+ `doRender #${_renderCount}: ${renderDuration.toFixed(1)}ms (reconcile=${reconcileMs.toFixed(1)}ms pipeline=${pipelineMs.toFixed(1)}ms ${dims.cols}x${dims.rows})${phaseStr}${detailStr}\n`,
1220
+ )
1221
+ }
1222
+ return buf
1223
+ }
1224
+
1225
+ // Initial render
1226
+ if (_ansiTrace) {
1227
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1228
+ require("node:fs").appendFileSync("/tmp/silvery-trace.log", "=== INITIAL RENDER ===\n")
1229
+ }
1230
+ currentBuffer = doRender()
1231
+
1232
+ // Enter alternate screen if requested, then clear and hide cursor
1233
+ if (!headless) {
1234
+ if (_ansiTrace) {
1235
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1236
+ require("node:fs").appendFileSync("/tmp/silvery-trace.log", "=== ALT SCREEN + CLEAR ===\n")
1237
+ }
1238
+ if (alternateScreen) {
1239
+ stdout.write("\x1b[?1049h")
1240
+ stdout.write("\x1b[2J\x1b[H")
1241
+ }
1242
+ stdout.write("\x1b[?25l")
1243
+
1244
+ // Kitty keyboard protocol
1245
+ if (kittyOption != null && kittyOption !== false) {
1246
+ if (kittyOption === true) {
1247
+ // Auto-detect: probe terminal, enable if supported
1248
+ const result = await detectKittyFromStdio(stdout, stdin as NodeJS.ReadStream)
1249
+ if (result.supported) {
1250
+ stdout.write(enableKittyKeyboard(KittyFlags.DISAMBIGUATE))
1251
+ kittyEnabled = true
1252
+ kittyFlags = KittyFlags.DISAMBIGUATE
1253
+ }
1254
+ } else {
1255
+ // Explicit flags — enable directly without detection
1256
+ stdout.write(enableKittyKeyboard(kittyOption as 1))
1257
+ kittyEnabled = true
1258
+ kittyFlags = kittyOption as number
1259
+ }
1260
+ } else {
1261
+ // Legacy behavior: always enable Kitty DISAMBIGUATE
1262
+ stdout.write(enableKittyKeyboard(KittyFlags.DISAMBIGUATE))
1263
+ kittyEnabled = true
1264
+ kittyFlags = KittyFlags.DISAMBIGUATE
1265
+ }
1266
+
1267
+ // Mouse tracking
1268
+ if (mouseOption) {
1269
+ stdout.write(enableMouse())
1270
+ mouseEnabled = true
1271
+ }
1272
+
1273
+ // Focus reporting is deferred to after the event loop starts (see below).
1274
+ // Enabling it here would cause the terminal's immediate CSI I/O response
1275
+ // to arrive before the input parser's stdin listener is attached, leaking
1276
+ // raw escape sequences to the screen.
1277
+ }
1278
+ if (_ansiTrace) {
1279
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1280
+ require("node:fs").appendFileSync("/tmp/silvery-trace.log", "=== RUNTIME.RENDER (initial) ===\n")
1281
+ }
1282
+ runtime.render(currentBuffer)
1283
+ if (_perfLog) {
1284
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1285
+ require("node:fs").appendFileSync(
1286
+ "/tmp/silvery-perf.log",
1287
+ `STARTUP: initial render done (render #${_renderCount}, incremental=${!_noIncremental})\n`,
1288
+ )
1289
+ }
1290
+
1291
+ // Assign pause/resume now that doRender and runtime are available.
1292
+ // Update runtimeContextValue in-place so useApp()/useRuntime() sees the latest values.
1293
+ if (!headless) {
1294
+ runtimeContextValue.pause = () => {
1295
+ renderPaused = true
1296
+ }
1297
+ runtimeContextValue.resume = () => {
1298
+ renderPaused = false
1299
+ // Reset diff state so next render outputs a full frame.
1300
+ // The screen was cleared when entering console mode, so
1301
+ // incremental diffing would produce an incomplete frame.
1302
+ runtime.invalidate()
1303
+ _prevTermBuffer = null
1304
+ // Force full re-render to restore display, but only if we're not
1305
+ // already inside a doRender() call (e.g. when resume() is called
1306
+ // from a React effect cleanup during reconciliation).
1307
+ if (!isRendering) {
1308
+ currentBuffer = doRender()
1309
+ runtime.render(currentBuffer)
1310
+ }
1311
+ // If isRendering is true, the outer doRender()/runtime.render() will
1312
+ // handle the re-render after effects complete, with renderPaused=false.
1313
+ }
1314
+ }
1315
+
1316
+ // Exit promise
1317
+ let exitResolve: () => void
1318
+ let exitResolved = false
1319
+ const exitPromise = new Promise<void>((resolve) => {
1320
+ exitResolve = () => {
1321
+ if (!exitResolved) {
1322
+ exitResolved = true
1323
+ resolve()
1324
+ }
1325
+ }
1326
+ })
1327
+
1328
+ // Now define exit function (needs exitResolve and cleanup)
1329
+ exit = () => {
1330
+ if (shouldExit) return // Already exiting
1331
+ shouldExit = true
1332
+ controller.abort()
1333
+ cleanup()
1334
+ exitResolve()
1335
+ }
1336
+ runtimeContextValue.exit = exit
1337
+
1338
+ // Frame listeners for async iteration
1339
+ let frameResolve: ((buffer: Buffer) => void) | null = null
1340
+ let framesDone = false
1341
+
1342
+ // Notify frame listeners
1343
+ function emitFrame(buf: Buffer) {
1344
+ if (frameResolve) {
1345
+ const resolve = frameResolve
1346
+ frameResolve = null
1347
+ resolve(buf)
1348
+ }
1349
+ }
1350
+
1351
+ // Subscribe to store for re-renders.
1352
+ //
1353
+ // Three cases:
1354
+ // 1. inEventHandler=true (during processEvent/press): ONLY flag pendingRerender.
1355
+ // The caller's flush loop will handle all deferred renders. No microtask.
1356
+ // 2. isRendering=true (during doRender effects): defer via pendingRerender flag.
1357
+ // Queue a microtask to render after the current render completes — but only
1358
+ // if NOT in an event handler (the flush loop handles it).
1359
+ // 3. Neither: render immediately (standalone setState from timeout/interval).
1360
+ storeUnsubscribeFn = store.subscribe(() => {
1361
+ if (shouldExit) return
1362
+ if (_ansiTrace) {
1363
+ const _case = inEventHandler ? "1:event" : isRendering ? "2:rendering" : "3:standalone"
1364
+ const stack = new Error().stack?.split("\n").slice(1, 5).join("\n") ?? ""
1365
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1366
+ require("node:fs").appendFileSync(
1367
+ "/tmp/silvery-trace.log",
1368
+ `=== SUBSCRIPTION (case ${_case}, render #${_renderCount + 1}) ===\n${stack}\n`,
1369
+ )
1370
+ }
1371
+ if (inEventHandler) {
1372
+ // During processEvent/press: just flag, caller's flush loop handles it.
1373
+ pendingRerender = true
1374
+ return
1375
+ }
1376
+ if (isRendering) {
1377
+ // During doRender (outside event handler): defer to microtask.
1378
+ if (!pendingRerender) {
1379
+ pendingRerender = true
1380
+ queueMicrotask(() => {
1381
+ if (!pendingRerender) return
1382
+ pendingRerender = false
1383
+ if (!shouldExit && !isRendering) {
1384
+ if (_perfLog) {
1385
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1386
+ require("node:fs").appendFileSync(
1387
+ "/tmp/silvery-perf.log",
1388
+ `SUBSCRIPTION: deferred microtask render (case 2, render #${_renderCount + 1})\n`,
1389
+ )
1390
+ }
1391
+ isRendering = true
1392
+ try {
1393
+ currentBuffer = doRender()
1394
+ runtime.render(currentBuffer)
1395
+ } finally {
1396
+ isRendering = false
1397
+ }
1398
+ }
1399
+ })
1400
+ }
1401
+ return
1402
+ }
1403
+ if (_perfLog) {
1404
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1405
+ require("node:fs").appendFileSync(
1406
+ "/tmp/silvery-perf.log",
1407
+ `SUBSCRIPTION: immediate render (case 3, render #${_renderCount + 1})\n`,
1408
+ )
1409
+ }
1410
+ isRendering = true
1411
+ try {
1412
+ currentBuffer = doRender()
1413
+ runtime.render(currentBuffer)
1414
+ } finally {
1415
+ isRendering = false
1416
+ }
1417
+ })
1418
+
1419
+ // Create namespaced event streams from all providers
1420
+ function createProviderEventStream(
1421
+ name: string,
1422
+ provider: Provider<unknown, Record<string, unknown>>,
1423
+ ): AsyncIterable<NamespacedEvent> {
1424
+ return map(provider.events(), (event) => ({
1425
+ type: `${name}:${String(event.type)}`,
1426
+ provider: name,
1427
+ event: String(event.type),
1428
+ data: event.data,
1429
+ }))
1430
+ }
1431
+
1432
+ /**
1433
+ * Run a single event's handler (state mutation only, no render).
1434
+ * Returns true if processing should continue, false if app should exit.
1435
+ */
1436
+ function runEventHandler(event: NamespacedEvent): boolean | "flush" {
1437
+ const ctx = createHandlerContext(store, focusManager, container)
1438
+ return invokeEventHandler(event, handlers, ctx, mouseEventState, container)
1439
+ }
1440
+
1441
+ /**
1442
+ * Process a batch of events — run all handlers, then render once.
1443
+ *
1444
+ * This is the key optimization for press-and-hold / auto-repeat keys.
1445
+ * When events arrive faster than renders (e.g., 30/sec auto-repeat vs
1446
+ * 50ms renders), we batch all pending handlers into a single render pass.
1447
+ *
1448
+ * For a batch of 3 'j' presses: handler1 → handler2 → handler3 → render.
1449
+ * The cursor moves 3 positions, but we only pay one render cost.
1450
+ */
1451
+ async function processEventBatch(events: NamespacedEvent[]): Promise<Buffer | null> {
1452
+ if (shouldExit || events.length === 0) return null
1453
+ _renderCount = 0
1454
+ _eventStart = performance.now()
1455
+
1456
+ // Intercept lifecycle keys (Ctrl+Z, Ctrl+C) BEFORE they reach app handlers.
1457
+ // These must be handled at the runtime level, not by individual components.
1458
+ if (!headless) {
1459
+ for (let i = events.length - 1; i >= 0; i--) {
1460
+ const event = events[i]!
1461
+ if (event.type !== "term:key") continue
1462
+ const data = event.data as { input: string; key: Key }
1463
+
1464
+ // Ctrl+Z: suspend (parseKey returns input="z" with key.ctrl=true)
1465
+ if (data.input === "z" && data.key.ctrl && suspendOption) {
1466
+ const prevented = onSuspendHook?.() === false
1467
+ if (!prevented) {
1468
+ // Remove this event from the batch
1469
+ events.splice(i, 1)
1470
+ const state = captureTerminalState({
1471
+ alternateScreen,
1472
+ cursorHidden: true,
1473
+ mouse: mouseEnabled,
1474
+ kitty: kittyEnabled,
1475
+ kittyFlags,
1476
+ bracketedPaste: true,
1477
+ rawMode: true,
1478
+ focusReporting: focusReportingEnabled,
1479
+ })
1480
+ performSuspend(state, stdout, stdin, () => {
1481
+ // After resume, trigger a full re-render
1482
+ runtime.invalidate()
1483
+ onResumeHook?.()
1484
+ })
1485
+ } else {
1486
+ events.splice(i, 1)
1487
+ }
1488
+ }
1489
+
1490
+ // Ctrl+C: exit (parseKey returns input="c" with key.ctrl=true)
1491
+ if (data.input === "c" && data.key.ctrl && exitOnCtrlCOption) {
1492
+ const prevented = onInterruptHook?.() === false
1493
+ if (!prevented) {
1494
+ exit()
1495
+ return null
1496
+ }
1497
+ events.splice(i, 1)
1498
+ }
1499
+ }
1500
+ if (events.length === 0) return null
1501
+ }
1502
+
1503
+ // Suppress subscription renders — the flush loop below handles everything.
1504
+ inEventHandler = true
1505
+ isRendering = true
1506
+
1507
+ // Run all handlers — state mutations batch naturally in Zustand
1508
+ for (const event of events) {
1509
+ // Bridge key/paste/focus events to RuntimeContext listeners (useInput consumers)
1510
+ if (event.type === "term:key") {
1511
+ const { input, key: parsedKey } = event.data as { input: string; key: Key }
1512
+ for (const listener of runtimeInputListeners) {
1513
+ listener(input, parsedKey)
1514
+ }
1515
+ } else if (event.type === "term:paste") {
1516
+ const { text } = event.data as { text: string }
1517
+ for (const listener of runtimePasteListeners) {
1518
+ listener(text)
1519
+ }
1520
+ } else if (event.type === "term:focus") {
1521
+ const { focused } = event.data as { focused: boolean }
1522
+ for (const listener of runtimeFocusListeners) {
1523
+ listener(focused)
1524
+ }
1525
+ }
1526
+
1527
+ // If a listener called exit() (e.g., useInput handler returned "exit"),
1528
+ // stop processing events immediately — don't render or flush.
1529
+ if (shouldExit) {
1530
+ inEventHandler = false
1531
+ return null
1532
+ }
1533
+
1534
+ const result = runEventHandler(event)
1535
+ if (result === false) {
1536
+ isRendering = false
1537
+ inEventHandler = false
1538
+ exit()
1539
+ return null
1540
+ }
1541
+
1542
+ // Render barrier: if handler requested flush, render now before next event.
1543
+ // This ensures newly mounted components (e.g., InlineEditField) have their
1544
+ // refs set up before the next event handler runs.
1545
+ //
1546
+ // IMPORTANT: runtime.render() must be called here to keep the runtime's
1547
+ // prevBuffer in sync with _prevTermBuffer. Without this, the post-batch
1548
+ // doRender's dirty-row tracking (relative to _prevTermBuffer) would be
1549
+ // stale relative to runtime.prevBuffer, causing diffBuffers() to skip
1550
+ // all rows and produce an empty diff (0 bytes output).
1551
+ if (result === "flush") {
1552
+ pendingRerender = false
1553
+ currentBuffer = doRender()
1554
+ runtime.render(currentBuffer)
1555
+ // Flush effects so mounted components can set up refs
1556
+ await Promise.resolve()
1557
+ if (pendingRerender) {
1558
+ pendingRerender = false
1559
+ currentBuffer = doRender()
1560
+ runtime.render(currentBuffer)
1561
+ }
1562
+ }
1563
+ }
1564
+
1565
+ // Clear deferred renders from handlers' setState calls — the explicit
1566
+ // doRender below picks up all state changes in one pass.
1567
+ pendingRerender = false
1568
+
1569
+ // Explicit render — batches all handler state changes + flushes effects
1570
+ try {
1571
+ currentBuffer = doRender()
1572
+ } finally {
1573
+ isRendering = false
1574
+ }
1575
+
1576
+ // Flush deferred re-renders from effects.
1577
+ // React's passive effects (useEffect) are scheduled during doRender
1578
+ // but flushed at the START of the next doRender (flushPassiveEffects).
1579
+ // The await drains the microtask queue so React's internally-queued
1580
+ // effect flush runs. Since inEventHandler=true, any setState from
1581
+ // effects just sets pendingRerender (no microtask render).
1582
+ let flushCount = 0
1583
+ const maxFlushes = 5
1584
+ while (flushCount < maxFlushes) {
1585
+ await Promise.resolve() // Drain microtask queue → passive effects flush
1586
+ if (!pendingRerender) break
1587
+ pendingRerender = false
1588
+ isRendering = true
1589
+ try {
1590
+ currentBuffer = doRender()
1591
+ } finally {
1592
+ isRendering = false
1593
+ }
1594
+ flushCount++
1595
+ }
1596
+
1597
+ // The content phase's dirty rows are relative to _prevTermBuffer (pipeline's
1598
+ // prev buffer). But runtime.render() diffs against its own prevBuffer, which
1599
+ // may differ when: (a) multiple doRender calls shifted _prevTermBuffer ahead,
1600
+ // or (b) the Z chord timeout causes the zoom render to arrive as a deferred
1601
+ // event where intermediate renders have updated _prevTermBuffer.
1602
+ // Always mark all rows dirty to ensure runtime.render() does a full diff.
1603
+ // The cost is negligible (diffBuffers still skips identical rows via
1604
+ // rowMetadataEquals/rowCharsEquals pre-check), but correctness is guaranteed.
1605
+ currentBuffer._buffer.markAllRowsDirty()
1606
+
1607
+ inEventHandler = false
1608
+ const runtimeStart = performance.now()
1609
+ runtime.render(currentBuffer)
1610
+ const runtimeMs = performance.now() - runtimeStart
1611
+ if (_perfLog) {
1612
+ const totalMs = performance.now() - _eventStart
1613
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1614
+ require("node:fs").appendFileSync(
1615
+ "/tmp/silvery-perf.log",
1616
+ `EVENT batch(${events.length} ${events[0]?.type}): ${totalMs.toFixed(1)}ms total, ${_renderCount} doRender() calls, runtime.render=${runtimeMs.toFixed(1)}ms\n---\n`,
1617
+ )
1618
+ }
1619
+ return currentBuffer
1620
+ }
1621
+
1622
+ // Start event loop
1623
+ //
1624
+ // Event coalescing: when events arrive faster than renders, we batch
1625
+ // consecutive handler calls into a single render pass. This prevents
1626
+ // the "event backlog" problem where auto-repeat keys queue up faster
1627
+ // than they can be rendered (e.g., 30/sec auto-repeat vs 50ms renders).
1628
+ //
1629
+ // Strategy: collect events into a shared queue, run all pending handlers,
1630
+ // render once. This means pressing and holding 'j' processes 2-3 cursor
1631
+ // moves per render instead of 1, keeping up with auto-repeat.
1632
+ const eventQueue: NamespacedEvent[] = []
1633
+ let eventQueueResolve: (() => void) | null = null
1634
+
1635
+ const eventLoop = async () => {
1636
+ // Merge all provider event streams
1637
+ const providerEventStreams = Object.entries(providers).map(([name, provider]) =>
1638
+ createProviderEventStream(name, provider),
1639
+ )
1640
+
1641
+ const allEvents = merge(...providerEventStreams)
1642
+
1643
+ // Pump events from async iterable into the shared queue
1644
+ const pumpEvents = async () => {
1645
+ try {
1646
+ for await (const event of takeUntil(allEvents, signal)) {
1647
+ eventQueue.push(event)
1648
+ if (eventQueueResolve) {
1649
+ const resolve = eventQueueResolve
1650
+ eventQueueResolve = null
1651
+ resolve()
1652
+ }
1653
+ if (shouldExit) break
1654
+ }
1655
+ } finally {
1656
+ // Signal end of events
1657
+ if (eventQueueResolve) {
1658
+ const resolve = eventQueueResolve
1659
+ eventQueueResolve = null
1660
+ resolve()
1661
+ }
1662
+ }
1663
+ }
1664
+
1665
+ // Start pump in background — this synchronously runs the term-provider
1666
+ // generator body, which attaches the stdin data listener. After this call,
1667
+ // stdin is being consumed, so terminal responses won't leak as raw text.
1668
+ pumpEvents().catch(console.error)
1669
+
1670
+ // Enable focus reporting NOW — after stdin listener is attached.
1671
+ // Must be deferred from the init phase because the terminal's immediate
1672
+ // CSI I/O response would leak before the input parser was ready.
1673
+ if (focusReportingOption && !focusReportingEnabled) {
1674
+ enableFocusReporting((s) => stdout.write(s))
1675
+ focusReportingEnabled = true
1676
+ }
1677
+
1678
+ try {
1679
+ while (!shouldExit && !signal.aborted) {
1680
+ // Wait for at least one event
1681
+ if (eventQueue.length === 0) {
1682
+ await new Promise<void>((resolve) => {
1683
+ eventQueueResolve = resolve
1684
+ signal.addEventListener("abort", () => resolve(), { once: true })
1685
+ })
1686
+ }
1687
+
1688
+ if (shouldExit || signal.aborted) break
1689
+ if (eventQueue.length === 0) continue
1690
+
1691
+ // Yield to microtask queue so the pump can push any additional
1692
+ // pending events before we drain. Without this, the first event
1693
+ // after idle always processes solo (1-event batch), even when
1694
+ // auto-repeat has queued multiple events in the term provider.
1695
+ await Promise.resolve()
1696
+
1697
+ // Process all pending events — run handlers without rendering
1698
+ const buf = await processEventBatch(eventQueue.splice(0))
1699
+ if (buf) emitFrame(buf)
1700
+ }
1701
+ } finally {
1702
+ // Mark frames as done and notify waiters
1703
+ framesDone = true
1704
+ if (frameResolve) {
1705
+ const resolve = frameResolve
1706
+ frameResolve = null
1707
+ // Signal completion — resolve with a sentinel that next() will detect
1708
+ resolve(null as unknown as Buffer)
1709
+ }
1710
+ // Cleanup and resolve exit promise
1711
+ cleanup()
1712
+ exitResolve()
1713
+ }
1714
+ }
1715
+
1716
+ // Start loop in background
1717
+ eventLoop().catch(console.error)
1718
+
1719
+ // Return handle with async iteration
1720
+ const handle: AppHandle<S & I> = {
1721
+ get text() {
1722
+ return currentBuffer.text
1723
+ },
1724
+ get store() {
1725
+ return store
1726
+ },
1727
+ waitUntilExit() {
1728
+ return exitPromise
1729
+ },
1730
+ unmount() {
1731
+ exit()
1732
+ },
1733
+ [Symbol.dispose]() {
1734
+ exit()
1735
+ },
1736
+ async press(rawKey: string) {
1737
+ // Convert named keys to ANSI bytes (Kitty protocol when enabled)
1738
+ const ansiKey = useKittyMode ? keyToKittyAnsi(rawKey) : keyToAnsi(rawKey)
1739
+ const [input, parsedKey] = parseKey(ansiKey)
1740
+
1741
+ // Intercept lifecycle keys (Ctrl+C) — same as processEventBatch but for
1742
+ // headless/press() path. parseKey returns input="c" with key.ctrl=true
1743
+ // for Ctrl+C (not the raw "\x03" byte).
1744
+ if (input === "c" && parsedKey.ctrl && exitOnCtrlCOption) {
1745
+ const prevented = onInterruptHook?.() === false
1746
+ if (!prevented) {
1747
+ exit()
1748
+ return
1749
+ }
1750
+ }
1751
+
1752
+ // Bridge to RuntimeContext listeners (useInput consumers)
1753
+ for (const listener of runtimeInputListeners) {
1754
+ listener(input, parsedKey)
1755
+ }
1756
+
1757
+ // Suppress subscription renders — flush loop below handles everything.
1758
+ inEventHandler = true
1759
+ isRendering = true
1760
+
1761
+ // Focus system: dispatch key event and handle default navigation
1762
+ const focusResult = handleFocusNavigation(input, parsedKey, focusManager, container)
1763
+ if (focusResult === "consumed") {
1764
+ pendingRerender = false
1765
+ isRendering = false
1766
+ inEventHandler = false
1767
+ doRender()
1768
+ await Promise.resolve()
1769
+ return
1770
+ }
1771
+
1772
+ // Dispatch to app handlers (namespaced + legacy)
1773
+ const handlerCtx = createHandlerContext(store, focusManager, container)
1774
+ if (dispatchKeyToHandlers(input, parsedKey, handlers, handlerCtx) === "exit") {
1775
+ isRendering = false
1776
+ inEventHandler = false
1777
+ exit()
1778
+ return
1779
+ }
1780
+
1781
+ // Clear deferred renders — explicit render below batches all changes
1782
+ pendingRerender = false
1783
+
1784
+ // Trigger re-render (batches handler state changes + flushes effects)
1785
+ try {
1786
+ currentBuffer = doRender()
1787
+ } finally {
1788
+ isRendering = false
1789
+ }
1790
+ // Flush deferred re-renders from effects.
1791
+ // await drains microtask queue → React passive effects flush.
1792
+ // Since inEventHandler=true, setState from effects just flags
1793
+ // pendingRerender (no microtask render).
1794
+ let flushCount = 0
1795
+ const maxFlushes = 5
1796
+ while (flushCount < maxFlushes) {
1797
+ await Promise.resolve()
1798
+ if (!pendingRerender) break
1799
+ pendingRerender = false
1800
+ isRendering = true
1801
+ try {
1802
+ currentBuffer = doRender()
1803
+ } finally {
1804
+ isRendering = false
1805
+ }
1806
+ flushCount++
1807
+ }
1808
+ inEventHandler = false
1809
+ runtime.render(currentBuffer)
1810
+ },
1811
+
1812
+ [Symbol.asyncIterator](): AsyncIterator<Buffer> {
1813
+ return {
1814
+ async next(): Promise<IteratorResult<Buffer>> {
1815
+ if (framesDone || shouldExit) {
1816
+ return { done: true, value: undefined as unknown as Buffer }
1817
+ }
1818
+
1819
+ // Wait for next frame from event loop
1820
+ const buf = await new Promise<Buffer>((resolve) => {
1821
+ // If already done, resolve immediately
1822
+ if (framesDone || shouldExit) {
1823
+ resolve(null as unknown as Buffer)
1824
+ return
1825
+ }
1826
+ frameResolve = resolve
1827
+ })
1828
+
1829
+ // null sentinel means done
1830
+ if (!buf) {
1831
+ return { done: true, value: undefined as unknown as Buffer }
1832
+ }
1833
+
1834
+ return { done: false, value: buf }
1835
+ },
1836
+ async return(): Promise<IteratorResult<Buffer>> {
1837
+ exit()
1838
+ return { done: true, value: undefined as unknown as Buffer }
1839
+ },
1840
+ }
1841
+ },
1842
+ }
1843
+
1844
+ return handle
1845
+ }