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