@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
|
@@ -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
|
+
}
|