@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,2593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4: Output Phase
|
|
3
|
+
*
|
|
4
|
+
* Diff two buffers and produce minimal ANSI output.
|
|
5
|
+
*
|
|
6
|
+
* Debug: Set SILVERY_DEBUG_OUTPUT=1 to log diff changes and ANSI sequences.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type Style,
|
|
11
|
+
type TerminalBuffer,
|
|
12
|
+
type UnderlineStyle,
|
|
13
|
+
VISIBLE_SPACE_ATTR_MASK,
|
|
14
|
+
colorEquals,
|
|
15
|
+
createMutableCell,
|
|
16
|
+
hasActiveAttrs,
|
|
17
|
+
isDefaultBg,
|
|
18
|
+
styleEquals,
|
|
19
|
+
} from "../buffer"
|
|
20
|
+
import { fgColorCode, bgColorCode } from "../ansi/sgr-codes"
|
|
21
|
+
import type { CursorState } from "@silvery/react/hooks/useCursor"
|
|
22
|
+
import { IncrementalRenderMismatchError } from "../errors"
|
|
23
|
+
import { textSized } from "../text-sizing"
|
|
24
|
+
import { graphemeWidth, isTextSizingEnabled } from "../unicode"
|
|
25
|
+
import type { CellChange } from "./types"
|
|
26
|
+
|
|
27
|
+
const DEBUG_OUTPUT = !!process.env.SILVERY_DEBUG_OUTPUT
|
|
28
|
+
const FULL_RENDER = !!process.env.SILVERY_FULL_RENDER
|
|
29
|
+
const DEBUG_CAPTURE = !!process.env.SILVERY_DEBUG_CAPTURE
|
|
30
|
+
const CAPTURE_RAW = !!process.env.SILVERY_CAPTURE_RAW
|
|
31
|
+
let _debugFrameCount = 0
|
|
32
|
+
let _captureRawFrameCount = 0
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Terminal Capability Flags (suppress unsupported SGR codes)
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
import type { TerminalCaps } from "../terminal-caps"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @deprecated Use createOutputPhase(caps) instead. This is a no-op.
|
|
42
|
+
*/
|
|
43
|
+
export function setOutputCaps(
|
|
44
|
+
_caps: Partial<Pick<TerminalCaps, "underlineStyles" | "underlineColor" | "colorLevel">>,
|
|
45
|
+
): void {
|
|
46
|
+
// No-op: use createOutputPhase(caps) instead
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Output Phase Factory (per-term instance, no globals)
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
/** Output-phase capabilities type. */
|
|
54
|
+
export type OutputCaps = Pick<TerminalCaps, "underlineStyles" | "underlineColor" | "colorLevel">
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Output Context (per-instance state, replaces module-level globals)
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-instance output context containing terminal capabilities, measurer,
|
|
62
|
+
* and caches. Threaded through internal functions to eliminate module-level
|
|
63
|
+
* mutable state. Caches are per-context because SGR output depends on caps.
|
|
64
|
+
*/
|
|
65
|
+
interface OutputContext {
|
|
66
|
+
readonly caps: OutputCaps
|
|
67
|
+
readonly measurer: OutputMeasurer | null
|
|
68
|
+
readonly sgrCache: Map<string, string>
|
|
69
|
+
readonly transitionCache: Map<string, string>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Default context used by bare outputPhase() calls (full capability support, no measurer). */
|
|
73
|
+
const defaultContext: OutputContext = {
|
|
74
|
+
caps: {
|
|
75
|
+
underlineStyles: true,
|
|
76
|
+
underlineColor: true,
|
|
77
|
+
colorLevel: "truecolor",
|
|
78
|
+
},
|
|
79
|
+
measurer: null,
|
|
80
|
+
sgrCache: new Map(),
|
|
81
|
+
transitionCache: new Map(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Output phase function signature. */
|
|
85
|
+
export interface OutputPhaseFn {
|
|
86
|
+
(
|
|
87
|
+
prev: TerminalBuffer | null,
|
|
88
|
+
next: TerminalBuffer,
|
|
89
|
+
mode?: "fullscreen" | "inline",
|
|
90
|
+
scrollbackOffset?: number,
|
|
91
|
+
termRows?: number,
|
|
92
|
+
cursorPos?: CursorState | null,
|
|
93
|
+
): string
|
|
94
|
+
/** Reset inline cursor state. Used by useScrollback to clear cursor tracking on resize. */
|
|
95
|
+
resetInlineState?: () => void
|
|
96
|
+
/** Get the current inline cursor row (relative to render region start). -1 if unknown. */
|
|
97
|
+
getInlineCursorRow?: () => number
|
|
98
|
+
/** Promote frozen content to scrollback. Called by useScrollback to queue
|
|
99
|
+
* frozen content for the next render — the output phase writes frozen + live
|
|
100
|
+
* content in a single target.write() to avoid flicker. */
|
|
101
|
+
promoteScrollback?: (frozenContent: string, frozenLineCount: number) => void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Output Phase Measurer (module-local, avoids dual-module-loading issues)
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// bun can load the same .ts file via symlink + real path as separate module
|
|
108
|
+
// instances. This means `_scopedMeasurer` set by `runWithMeasurer()` in
|
|
109
|
+
// pipeline/index.ts's instance of unicode.ts is invisible to output-phase.ts's
|
|
110
|
+
// instance. We avoid this by closing over the measurer in createOutputPhase()
|
|
111
|
+
// and setting a module-local variable that all output-phase functions read.
|
|
112
|
+
|
|
113
|
+
interface OutputMeasurer {
|
|
114
|
+
graphemeWidth(grapheme: string): number
|
|
115
|
+
readonly textSizingEnabled: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get grapheme width using the output context measurer (falls back to unicode.ts import). */
|
|
119
|
+
function outputGraphemeWidth(g: string, ctx: OutputContext): number {
|
|
120
|
+
return ctx.measurer ? ctx.measurer.graphemeWidth(g) : graphemeWidth(g)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Check if text sizing is enabled using the output context measurer. */
|
|
124
|
+
function outputTextSizingEnabled(ctx: OutputContext): boolean {
|
|
125
|
+
return ctx.measurer ? ctx.measurer.textSizingEnabled : isTextSizingEnabled()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a scoped output phase that uses specific terminal capabilities.
|
|
130
|
+
*
|
|
131
|
+
* @param caps - Terminal capabilities for SGR code generation
|
|
132
|
+
* @param measurer - Width measurer for graphemeWidth/textSizingEnabled (avoids dual-module-loading issues)
|
|
133
|
+
*/
|
|
134
|
+
export function createOutputPhase(caps: Partial<OutputCaps>, measurer?: OutputMeasurer): OutputPhaseFn {
|
|
135
|
+
// Instance-scoped context — caps, measurer, and caches are all per-instance.
|
|
136
|
+
// No module-level globals are read or modified.
|
|
137
|
+
const ctx: OutputContext = {
|
|
138
|
+
caps: {
|
|
139
|
+
underlineStyles: caps.underlineStyles ?? true,
|
|
140
|
+
underlineColor: caps.underlineColor ?? true,
|
|
141
|
+
colorLevel: caps.colorLevel ?? "truecolor",
|
|
142
|
+
},
|
|
143
|
+
measurer: measurer ?? null,
|
|
144
|
+
sgrCache: new Map(),
|
|
145
|
+
transitionCache: new Map(),
|
|
146
|
+
}
|
|
147
|
+
// Instance-scoped inline cursor state — persists across frames for incremental rendering.
|
|
148
|
+
// Each createOutputPhase() call gets its own state, eliminating module-level globals.
|
|
149
|
+
const inlineState = createInlineCursorState()
|
|
150
|
+
|
|
151
|
+
// Instance-scoped accumulated ANSI state for SILVERY_STRICT_ACCUMULATE verification.
|
|
152
|
+
// Tracks per-instance verification state rather than using module-level globals.
|
|
153
|
+
const accState = {
|
|
154
|
+
accumulatedAnsi: "",
|
|
155
|
+
accumulateWidth: 0,
|
|
156
|
+
accumulateHeight: 0,
|
|
157
|
+
accumulateFrameCount: 0,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Pending scrollback promotion — queued by useScrollback, consumed by the next render.
|
|
161
|
+
let pendingPromotion: { frozenContent: string; frozenLineCount: number } | null = null
|
|
162
|
+
|
|
163
|
+
const fn: OutputPhaseFn = function scopedOutputPhase(
|
|
164
|
+
prev: TerminalBuffer | null,
|
|
165
|
+
next: TerminalBuffer,
|
|
166
|
+
mode: "fullscreen" | "inline" = "fullscreen",
|
|
167
|
+
scrollbackOffset = 0,
|
|
168
|
+
termRows?: number,
|
|
169
|
+
cursorPos?: CursorState | null,
|
|
170
|
+
): string {
|
|
171
|
+
// Handle scrollback promotion: write frozen content + live content in one pass.
|
|
172
|
+
if (pendingPromotion && mode === "inline") {
|
|
173
|
+
const promo = pendingPromotion
|
|
174
|
+
pendingPromotion = null
|
|
175
|
+
return handleScrollbackPromotion(
|
|
176
|
+
inlineState,
|
|
177
|
+
promo.frozenContent,
|
|
178
|
+
promo.frozenLineCount,
|
|
179
|
+
next,
|
|
180
|
+
termRows,
|
|
181
|
+
cursorPos,
|
|
182
|
+
ctx,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
return outputPhase(prev, next, mode, scrollbackOffset, termRows, cursorPos, inlineState, ctx, accState)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fn.resetInlineState = () => {
|
|
189
|
+
Object.assign(inlineState, createInlineCursorState())
|
|
190
|
+
inlineState.forceFirstRender = true
|
|
191
|
+
// Clear any queued promotion — the resize handler re-emits all frozen items
|
|
192
|
+
// directly, so any pending promotion from the freeze effect is redundant.
|
|
193
|
+
// Without this, freeze+resize in the same frame causes duplicate frozen content.
|
|
194
|
+
pendingPromotion = null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn.getInlineCursorRow = () => inlineState.prevCursorRow
|
|
198
|
+
|
|
199
|
+
fn.promoteScrollback = (frozenContent: string, frozenLineCount: number) => {
|
|
200
|
+
if (pendingPromotion) {
|
|
201
|
+
pendingPromotion.frozenContent += frozenContent
|
|
202
|
+
pendingPromotion.frozenLineCount += frozenLineCount
|
|
203
|
+
} else {
|
|
204
|
+
pendingPromotion = { frozenContent, frozenLineCount }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return fn
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Handle scrollback promotion: write frozen content + live content in a single output string.
|
|
213
|
+
*
|
|
214
|
+
* Instead of useScrollback writing directly to stdout (which blanks the screen and
|
|
215
|
+
* causes flicker), this function builds one output string that:
|
|
216
|
+
* 1. Moves cursor to the render region start (no clearing)
|
|
217
|
+
* 2. Writes frozen content (each line overwrites in-place via \x1b[K])
|
|
218
|
+
* 3. Writes live content via bufferToAnsi (also with per-line \x1b[K])
|
|
219
|
+
* 4. Erases any leftover lines from the previous frame
|
|
220
|
+
* 5. Positions the hardware cursor
|
|
221
|
+
*
|
|
222
|
+
* Result: a single target.write() with no blanking — no flicker.
|
|
223
|
+
*/
|
|
224
|
+
function handleScrollbackPromotion(
|
|
225
|
+
state: InlineCursorState,
|
|
226
|
+
frozenContent: string,
|
|
227
|
+
frozenLineCount: number,
|
|
228
|
+
next: TerminalBuffer,
|
|
229
|
+
termRows: number | undefined,
|
|
230
|
+
cursorPos: CursorState | null | undefined,
|
|
231
|
+
ctx: OutputContext,
|
|
232
|
+
): string {
|
|
233
|
+
// 1. Move cursor to render region start
|
|
234
|
+
let output = ""
|
|
235
|
+
if (state.prevCursorRow > 0) {
|
|
236
|
+
output += `\x1b[${state.prevCursorRow}A`
|
|
237
|
+
}
|
|
238
|
+
output += "\r" // column 0, NO \x1b[J clear
|
|
239
|
+
|
|
240
|
+
// 2. Write frozen content (overwrites old content in-place, OSC markers included)
|
|
241
|
+
output += frozenContent
|
|
242
|
+
|
|
243
|
+
// 3. Write live content via bufferToAnsi (each line has \x1b[K — no blanking)
|
|
244
|
+
const nextContentLines = findLastContentLine(next) + 1
|
|
245
|
+
const maxOutputLines = termRows != null ? Math.min(nextContentLines, termRows) : nextContentLines
|
|
246
|
+
output += bufferToAnsi(next, "inline", ctx, maxOutputLines)
|
|
247
|
+
|
|
248
|
+
// Total lines on-screen: frozen + live. The terminal may scroll if this exceeds
|
|
249
|
+
// termRows, naturally pushing frozen lines into scrollback. No padding needed —
|
|
250
|
+
// we track ALL on-screen lines so the next render can overwrite them cleanly.
|
|
251
|
+
const totalOnScreen = frozenLineCount + maxOutputLines
|
|
252
|
+
|
|
253
|
+
// 4. Erase leftover lines at bottom (if content shrank)
|
|
254
|
+
const oldTotalLines = state.prevOutputLines
|
|
255
|
+
const nextLastLine = totalOnScreen - 1
|
|
256
|
+
const terminalScroll = termRows != null ? Math.max(0, totalOnScreen - termRows) : 0
|
|
257
|
+
const lastOccupied = Math.max(oldTotalLines - 1 - terminalScroll, 0)
|
|
258
|
+
if (lastOccupied > nextLastLine) {
|
|
259
|
+
for (let y = nextLastLine + 1; y <= lastOccupied; y++) {
|
|
260
|
+
output += "\n\r\x1b[K"
|
|
261
|
+
}
|
|
262
|
+
const up = lastOccupied - nextLastLine
|
|
263
|
+
if (up > 0) output += `\x1b[${up}A`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 5. Cursor suffix (hardware cursor positioning)
|
|
267
|
+
// Cursor is at the end of live content (row totalOnScreen - 1 relative to
|
|
268
|
+
// render region start). inlineCursorSuffix moves it to the useCursor position
|
|
269
|
+
// within the live content area.
|
|
270
|
+
output += inlineCursorSuffix(cursorPos ?? null, next, termRows)
|
|
271
|
+
|
|
272
|
+
// 6. Update tracking for subsequent incremental renders.
|
|
273
|
+
// Track cursor position and output lines relative to the LIVE content only.
|
|
274
|
+
// Frozen content has been written as raw ANSI above the live content and is
|
|
275
|
+
// now "owned" by the terminal — it stays on screen until natural scrolling
|
|
276
|
+
// pushes it into terminal scrollback. The next render only needs to cursor-up
|
|
277
|
+
// to the start of the live content area, not past the frozen content.
|
|
278
|
+
//
|
|
279
|
+
// This is critical for real terminals where pre-existing content (shell prompt,
|
|
280
|
+
// direnv output) sits above the app. If prevCursorRow included frozenLineCount,
|
|
281
|
+
// the cursor-up would overshoot into the shell prompt area, clearing it.
|
|
282
|
+
let startLine = 0
|
|
283
|
+
if (termRows != null && nextContentLines > termRows) startLine = nextContentLines - termRows
|
|
284
|
+
state.prevBuffer = next
|
|
285
|
+
|
|
286
|
+
// Cursor row within the LIVE content area only (not including frozen lines).
|
|
287
|
+
// inlineCursorSuffix already positioned the cursor within the live content.
|
|
288
|
+
if (cursorPos?.visible) {
|
|
289
|
+
const visibleRow = cursorPos.y - startLine
|
|
290
|
+
state.prevCursorRow = visibleRow >= 0 && visibleRow < maxOutputLines ? visibleRow : maxOutputLines - 1
|
|
291
|
+
} else {
|
|
292
|
+
state.prevCursorRow = maxOutputLines - 1
|
|
293
|
+
}
|
|
294
|
+
state.prevOutputLines = maxOutputLines
|
|
295
|
+
|
|
296
|
+
return output
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// These use getters so they can be set after module load (e.g., in test files).
|
|
300
|
+
// SILVERY_STRICT enables buffer + output checks (per-frame).
|
|
301
|
+
// SILVERY_STRICT_OUTPUT=0 explicitly disables output checking even when SILVERY_STRICT is set.
|
|
302
|
+
// SILVERY_STRICT_ACCUMULATE is separate — it replays ALL frames (O(N²)) and is opt-in only.
|
|
303
|
+
function isStrictOutput(): boolean {
|
|
304
|
+
const outputEnv = process.env.SILVERY_STRICT_OUTPUT
|
|
305
|
+
if (outputEnv === "0" || outputEnv === "false") return false
|
|
306
|
+
return !!outputEnv || !!process.env.SILVERY_STRICT
|
|
307
|
+
}
|
|
308
|
+
function isStrictAccumulate(): boolean {
|
|
309
|
+
return !!process.env.SILVERY_STRICT_ACCUMULATE
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Per-instance state for SILVERY_STRICT_ACCUMULATE verification. */
|
|
313
|
+
interface AccumulateState {
|
|
314
|
+
accumulatedAnsi: string
|
|
315
|
+
accumulateWidth: number
|
|
316
|
+
accumulateHeight: number
|
|
317
|
+
accumulateFrameCount: number
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Default accumulate state used by bare outputPhase() calls. */
|
|
321
|
+
const defaultAccState: AccumulateState = {
|
|
322
|
+
accumulatedAnsi: "",
|
|
323
|
+
accumulateWidth: 0,
|
|
324
|
+
accumulateHeight: 0,
|
|
325
|
+
accumulateFrameCount: 0,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// Inline Mode: Inter-frame Cursor Tracking (instance-scoped)
|
|
330
|
+
// ============================================================================
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Mutable state for inline mode inter-frame cursor tracking.
|
|
334
|
+
* Captured in the createOutputPhase() closure — no module-level globals.
|
|
335
|
+
*/
|
|
336
|
+
interface InlineCursorState {
|
|
337
|
+
/** Row within render region after last inline frame's cursor suffix. -1 = unknown. */
|
|
338
|
+
prevCursorRow: number
|
|
339
|
+
/** Total output lines rendered in last frame. Used to clear old content on resize. */
|
|
340
|
+
prevOutputLines: number
|
|
341
|
+
/** Previous frame's buffer — used for incremental rendering when runtime invalidates (resize). */
|
|
342
|
+
prevBuffer: TerminalBuffer | null
|
|
343
|
+
/** When true, the next inline render treats prev as null (first-render path).
|
|
344
|
+
* Set by resetInlineState() after useScrollback clears and re-emits frozen items. */
|
|
345
|
+
forceFirstRender: boolean
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Create fresh inline cursor state (unknown position → first call falls back to full render). */
|
|
349
|
+
function createInlineCursorState(): InlineCursorState {
|
|
350
|
+
return {
|
|
351
|
+
prevCursorRow: -1,
|
|
352
|
+
prevOutputLines: 0,
|
|
353
|
+
prevBuffer: null,
|
|
354
|
+
forceFirstRender: false,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Update cursor tracking after an inline render frame.
|
|
360
|
+
* Records where the terminal cursor ends up after inlineCursorSuffix().
|
|
361
|
+
*/
|
|
362
|
+
function updateInlineCursorRow(
|
|
363
|
+
state: InlineCursorState,
|
|
364
|
+
cursorPos: CursorState | null | undefined,
|
|
365
|
+
maxOutputLines: number,
|
|
366
|
+
startLine: number,
|
|
367
|
+
): void {
|
|
368
|
+
if (cursorPos?.visible) {
|
|
369
|
+
const visibleRow = cursorPos.y - startLine
|
|
370
|
+
state.prevCursorRow = visibleRow >= 0 && visibleRow < maxOutputLines ? visibleRow : maxOutputLines - 1
|
|
371
|
+
} else {
|
|
372
|
+
// Cursor hidden: cursor stays at end of last content line
|
|
373
|
+
state.prevCursorRow = maxOutputLines - 1
|
|
374
|
+
}
|
|
375
|
+
state.prevOutputLines = maxOutputLines
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Wrap a cell character in OSC 66 if text sizing is enabled and the cell is
|
|
380
|
+
* wide. OSC 66 tells the terminal to render the character in exactly `width`
|
|
381
|
+
* cells, matching the layout engine's measurement.
|
|
382
|
+
*
|
|
383
|
+
* Previously this only wrapped specific categories (PUA, text-presentation
|
|
384
|
+
* emoji, flag emoji) — a whack-a-mole approach that missed new categories
|
|
385
|
+
* as Unicode evolved. Now wraps ALL wide chars unconditionally: if the buffer
|
|
386
|
+
* says width 2, the terminal is told width 2. CJK chars don't strictly need
|
|
387
|
+
* it (terminals agree on their width), but the ~8-byte overhead per wide char
|
|
388
|
+
* is negligible and eliminates any future width disagreement.
|
|
389
|
+
*/
|
|
390
|
+
function wrapTextSizing(char: string, wide: boolean, ctx: OutputContext): string {
|
|
391
|
+
if (!wide || !outputTextSizingEnabled(ctx)) return char
|
|
392
|
+
return textSized(char, 2)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============================================================================
|
|
396
|
+
// Style Interning + SGR Cache
|
|
397
|
+
// ============================================================================
|
|
398
|
+
|
|
399
|
+
// SGR caches are now per-OutputContext (see OutputContext interface).
|
|
400
|
+
// This is correct because SGR output depends on caps (underlineStyles,
|
|
401
|
+
// underlineColor), so caches populated under one caps configuration
|
|
402
|
+
// would produce wrong results under a different one.
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Serialize a Style into a cache key string.
|
|
406
|
+
* Fast path: most styles are simple (256-color or null fg/bg, no true color).
|
|
407
|
+
*/
|
|
408
|
+
function styleToKey(style: Style): string {
|
|
409
|
+
const fg = style.fg
|
|
410
|
+
const bg = style.bg
|
|
411
|
+
const attrs = style.attrs
|
|
412
|
+
|
|
413
|
+
// Fast path: common case of simple colors + few attrs
|
|
414
|
+
let key = ""
|
|
415
|
+
|
|
416
|
+
// fg
|
|
417
|
+
if (fg === null) {
|
|
418
|
+
key = "n"
|
|
419
|
+
} else if (typeof fg === "number") {
|
|
420
|
+
key = `${fg}`
|
|
421
|
+
} else {
|
|
422
|
+
key = `r${fg.r},${fg.g},${fg.b}`
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
key += "|"
|
|
426
|
+
|
|
427
|
+
// bg
|
|
428
|
+
if (bg === null) {
|
|
429
|
+
key += "n"
|
|
430
|
+
} else if (typeof bg === "number") {
|
|
431
|
+
key += `${bg}`
|
|
432
|
+
} else {
|
|
433
|
+
key += `r${bg.r},${bg.g},${bg.b}`
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// attrs packed as bitmask for speed
|
|
437
|
+
let attrBits = 0
|
|
438
|
+
if (attrs.bold) attrBits |= 1
|
|
439
|
+
if (attrs.dim) attrBits |= 2
|
|
440
|
+
if (attrs.italic) attrBits |= 4
|
|
441
|
+
if (attrs.underline) attrBits |= 8
|
|
442
|
+
if (attrs.inverse) attrBits |= 16
|
|
443
|
+
if (attrs.strikethrough) attrBits |= 32
|
|
444
|
+
if (attrs.blink) attrBits |= 64
|
|
445
|
+
if (attrs.hidden) attrBits |= 128
|
|
446
|
+
|
|
447
|
+
key += `|${attrBits}`
|
|
448
|
+
|
|
449
|
+
// Underline style (rare)
|
|
450
|
+
if (attrs.underlineStyle) {
|
|
451
|
+
key += `|u${attrs.underlineStyle}`
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Underline color (rare)
|
|
455
|
+
const ul = style.underlineColor
|
|
456
|
+
if (ul !== null && ul !== undefined) {
|
|
457
|
+
if (typeof ul === "number") {
|
|
458
|
+
key += `|l${ul}`
|
|
459
|
+
} else {
|
|
460
|
+
key += `|lr${ul.r},${ul.g},${ul.b}`
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Hyperlink URL (rare)
|
|
465
|
+
if (style.hyperlink) {
|
|
466
|
+
key += `|h${style.hyperlink}`
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return key
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get the SGR escape string for a style, using the intern cache.
|
|
474
|
+
* Cache hit: O(1) Map lookup + key serialization.
|
|
475
|
+
* Cache miss: builds the SGR string and caches it.
|
|
476
|
+
*/
|
|
477
|
+
function cachedStyleToAnsi(style: Style, ctx: OutputContext): string {
|
|
478
|
+
const key = styleToKey(style)
|
|
479
|
+
let sgr = ctx.sgrCache.get(key)
|
|
480
|
+
if (sgr !== undefined) return sgr
|
|
481
|
+
sgr = styleToAnsi(style, ctx)
|
|
482
|
+
ctx.sgrCache.set(key, sgr)
|
|
483
|
+
if (ctx.sgrCache.size > 1000) ctx.sgrCache.clear()
|
|
484
|
+
return sgr
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Compute the minimal SGR transition between two styles.
|
|
489
|
+
*
|
|
490
|
+
* When oldStyle is null (first cell or after reset), falls through to
|
|
491
|
+
* full SGR generation via cachedStyleToAnsi. Otherwise, diffs attribute
|
|
492
|
+
* by attribute and emits only changed SGR codes. Caches the result for
|
|
493
|
+
* each (oldKey, newKey) pair.
|
|
494
|
+
*/
|
|
495
|
+
function styleTransition(oldStyle: Style | null, newStyle: Style, ctx: OutputContext): string {
|
|
496
|
+
// First cell or after reset — full generation
|
|
497
|
+
if (!oldStyle) return cachedStyleToAnsi(newStyle, ctx)
|
|
498
|
+
|
|
499
|
+
// Same style — nothing to emit
|
|
500
|
+
if (styleEquals(oldStyle, newStyle)) return ""
|
|
501
|
+
|
|
502
|
+
// Check transition cache
|
|
503
|
+
const oldKey = styleToKey(oldStyle)
|
|
504
|
+
const newKey = styleToKey(newStyle)
|
|
505
|
+
const cacheKey = `${oldKey}\x00${newKey}`
|
|
506
|
+
const cached = ctx.transitionCache.get(cacheKey)
|
|
507
|
+
if (cached !== undefined) return cached
|
|
508
|
+
|
|
509
|
+
// Build minimal diff
|
|
510
|
+
const codes: string[] = []
|
|
511
|
+
|
|
512
|
+
// Check attributes that can only be "turned off" via reset or specific off-codes.
|
|
513
|
+
// If an attribute was on and is now off, we need either the off-code or a full reset.
|
|
514
|
+
const oa = oldStyle.attrs
|
|
515
|
+
const na = newStyle.attrs
|
|
516
|
+
|
|
517
|
+
// Bold and dim share SGR 22 as their off-code, so handle them together
|
|
518
|
+
// to avoid emitting duplicate codes.
|
|
519
|
+
const boldChanged = Boolean(oa.bold) !== Boolean(na.bold)
|
|
520
|
+
const dimChanged = Boolean(oa.dim) !== Boolean(na.dim)
|
|
521
|
+
if (boldChanged || dimChanged) {
|
|
522
|
+
const boldOff = boldChanged && !na.bold
|
|
523
|
+
const dimOff = dimChanged && !na.dim
|
|
524
|
+
if (boldOff || dimOff) {
|
|
525
|
+
// SGR 22 resets both bold and dim
|
|
526
|
+
codes.push("22")
|
|
527
|
+
// Re-enable whichever should stay on
|
|
528
|
+
if (na.bold) codes.push("1")
|
|
529
|
+
if (na.dim) codes.push("2")
|
|
530
|
+
} else {
|
|
531
|
+
// Only turning attributes on
|
|
532
|
+
if (boldChanged && na.bold) codes.push("1")
|
|
533
|
+
if (dimChanged && na.dim) codes.push("2")
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (Boolean(oa.italic) !== Boolean(na.italic)) {
|
|
537
|
+
codes.push(na.italic ? "3" : "23")
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Underline: compare both underline flag and underlineStyle
|
|
541
|
+
const oldUl = Boolean(oa.underline)
|
|
542
|
+
const newUl = Boolean(na.underline)
|
|
543
|
+
const oldUlStyle = oa.underlineStyle ?? false
|
|
544
|
+
const newUlStyle = na.underlineStyle ?? false
|
|
545
|
+
if (oldUl !== newUl || oldUlStyle !== newUlStyle) {
|
|
546
|
+
if (!ctx.caps.underlineStyles) {
|
|
547
|
+
// Terminal doesn't support SGR 4:x — fall back to simple SGR 4/24
|
|
548
|
+
codes.push(newUl || na.underlineStyle ? "4" : "24")
|
|
549
|
+
} else {
|
|
550
|
+
const sgrSub = underlineStyleToSgr(na.underlineStyle)
|
|
551
|
+
if (sgrSub !== null && sgrSub !== 0) {
|
|
552
|
+
codes.push(`4:${sgrSub}`)
|
|
553
|
+
} else if (newUl) {
|
|
554
|
+
codes.push("4")
|
|
555
|
+
} else {
|
|
556
|
+
codes.push("24")
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (Boolean(oa.inverse) !== Boolean(na.inverse)) {
|
|
562
|
+
codes.push(na.inverse ? "7" : "27")
|
|
563
|
+
}
|
|
564
|
+
if (Boolean(oa.hidden) !== Boolean(na.hidden)) {
|
|
565
|
+
codes.push(na.hidden ? "8" : "28")
|
|
566
|
+
}
|
|
567
|
+
if (Boolean(oa.strikethrough) !== Boolean(na.strikethrough)) {
|
|
568
|
+
codes.push(na.strikethrough ? "9" : "29")
|
|
569
|
+
}
|
|
570
|
+
if (Boolean(oa.blink) !== Boolean(na.blink)) {
|
|
571
|
+
codes.push(na.blink ? "5" : "25")
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Foreground color
|
|
575
|
+
if (!colorEquals(oldStyle.fg, newStyle.fg)) {
|
|
576
|
+
if (newStyle.fg === null) {
|
|
577
|
+
codes.push("39")
|
|
578
|
+
} else {
|
|
579
|
+
codes.push(fgColorCode(newStyle.fg))
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Background color
|
|
584
|
+
if (!colorEquals(oldStyle.bg, newStyle.bg)) {
|
|
585
|
+
if (newStyle.bg === null) {
|
|
586
|
+
codes.push("49")
|
|
587
|
+
} else {
|
|
588
|
+
codes.push(bgColorCode(newStyle.bg))
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Underline color (SGR 58/59) — skip for terminals that don't support it
|
|
593
|
+
if (ctx.caps.underlineColor && !colorEquals(oldStyle.underlineColor, newStyle.underlineColor)) {
|
|
594
|
+
if (newStyle.underlineColor === null || newStyle.underlineColor === undefined) {
|
|
595
|
+
// SGR 59 resets underline color
|
|
596
|
+
codes.push("59")
|
|
597
|
+
} else if (typeof newStyle.underlineColor === "number") {
|
|
598
|
+
codes.push(`58;5;${newStyle.underlineColor}`)
|
|
599
|
+
} else {
|
|
600
|
+
codes.push(`58;2;${newStyle.underlineColor.r};${newStyle.underlineColor.g};${newStyle.underlineColor.b}`)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Hyperlink (OSC 8) is handled separately in the render loops, not here.
|
|
605
|
+
|
|
606
|
+
let result: string
|
|
607
|
+
if (codes.length === 0) {
|
|
608
|
+
// Styles differ but no SGR codes emitted (e.g., hyperlink-only change).
|
|
609
|
+
// Fall back to full generation to be safe.
|
|
610
|
+
result = cachedStyleToAnsi(newStyle, ctx)
|
|
611
|
+
} else {
|
|
612
|
+
result = `\x1b[${codes.join(";")}m`
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
ctx.transitionCache.set(cacheKey, result)
|
|
616
|
+
if (ctx.transitionCache.size > 1000) ctx.transitionCache.clear()
|
|
617
|
+
return result
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Map underline style to SGR 4:x subparameter.
|
|
622
|
+
*/
|
|
623
|
+
function underlineStyleToSgr(style: UnderlineStyle | undefined): number | null {
|
|
624
|
+
switch (style) {
|
|
625
|
+
case false:
|
|
626
|
+
return 0 // SGR 4:0 = no underline
|
|
627
|
+
case "single":
|
|
628
|
+
return 1 // SGR 4:1 = single underline
|
|
629
|
+
case "double":
|
|
630
|
+
return 2 // SGR 4:2 = double underline
|
|
631
|
+
case "curly":
|
|
632
|
+
return 3 // SGR 4:3 = curly underline
|
|
633
|
+
case "dotted":
|
|
634
|
+
return 4 // SGR 4:4 = dotted underline
|
|
635
|
+
case "dashed":
|
|
636
|
+
return 5 // SGR 4:5 = dashed underline
|
|
637
|
+
default:
|
|
638
|
+
return null // Use simple SGR 4 or no underline
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Diff two buffers and produce minimal ANSI output.
|
|
644
|
+
*
|
|
645
|
+
* @param prev Previous buffer (null on first render)
|
|
646
|
+
* @param next Current buffer
|
|
647
|
+
* @param mode Render mode: fullscreen or inline
|
|
648
|
+
* @param scrollbackOffset Lines written to stdout between renders (inline mode)
|
|
649
|
+
* @param termRows Terminal height in rows (inline mode) — caps output to prevent
|
|
650
|
+
* scrollback corruption when content exceeds terminal height
|
|
651
|
+
* @returns ANSI escape sequence string
|
|
652
|
+
*/
|
|
653
|
+
export function outputPhase(
|
|
654
|
+
prev: TerminalBuffer | null,
|
|
655
|
+
next: TerminalBuffer,
|
|
656
|
+
mode: "fullscreen" | "inline" = "fullscreen",
|
|
657
|
+
scrollbackOffset = 0,
|
|
658
|
+
termRows?: number,
|
|
659
|
+
cursorPos?: CursorState | null,
|
|
660
|
+
_inlineState?: InlineCursorState,
|
|
661
|
+
_ctx?: OutputContext,
|
|
662
|
+
_accState?: AccumulateState,
|
|
663
|
+
): string {
|
|
664
|
+
// Bare outputPhase() calls use a fresh cursor state each time.
|
|
665
|
+
// prevCursorRow = -1 means incremental rendering always falls back to full render.
|
|
666
|
+
// Instance-scoped state (via createOutputPhase) enables incremental across frames.
|
|
667
|
+
const inlineState = _inlineState ?? createInlineCursorState()
|
|
668
|
+
const ctx = _ctx ?? defaultContext
|
|
669
|
+
const accState = _accState ?? defaultAccState
|
|
670
|
+
|
|
671
|
+
// After resetInlineState (e.g., useScrollback cleared and re-emitted frozen items),
|
|
672
|
+
// treat the next render as a first render. The cursor is at a known position
|
|
673
|
+
// (right after the re-emitted frozen items) and prev is stale.
|
|
674
|
+
if (mode === "inline" && inlineState.forceFirstRender) {
|
|
675
|
+
inlineState.forceFirstRender = false
|
|
676
|
+
prev = null // may already be null (runtime.invalidate on resize), consume flag regardless
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// First render: output entire buffer
|
|
680
|
+
if (!prev) {
|
|
681
|
+
// Inline mode resize optimization: if the runtime invalidated prevBuffer (resize)
|
|
682
|
+
// but we have a stored buffer with matching dimensions, use incremental rendering
|
|
683
|
+
// instead of clear+full render. This avoids wiping content when the buffer is unchanged
|
|
684
|
+
// (e.g., content narrower than both old and new terminal widths).
|
|
685
|
+
if (mode === "inline" && inlineState.prevBuffer && inlineState.prevCursorRow >= 0) {
|
|
686
|
+
const stored = inlineState.prevBuffer
|
|
687
|
+
if (stored.width === next.width && stored.height === next.height) {
|
|
688
|
+
// Dimensions match — use incremental rendering (skip clear entirely)
|
|
689
|
+
inlineState.prevBuffer = next
|
|
690
|
+
return inlineIncrementalRender(inlineState, stored, next, scrollbackOffset, termRows, cursorPos, ctx)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Cap output to terminal height to prevent scroll desync.
|
|
695
|
+
// In inline mode: prevents scrollback corruption (cursor-up clamped at row 0).
|
|
696
|
+
// In fullscreen mode: prevents terminal scroll that desynchronizes prevBuffer
|
|
697
|
+
// from actual terminal state, causing ghost pixels on subsequent incremental renders.
|
|
698
|
+
const firstOutput = bufferToAnsi(next, mode, ctx, termRows)
|
|
699
|
+
// For inline first render, append cursor positioning and initialize tracking
|
|
700
|
+
if (mode === "inline") {
|
|
701
|
+
const firstContentLines = findLastContentLine(next) + 1
|
|
702
|
+
const firstMaxOutput = termRows != null ? Math.min(firstContentLines, termRows) : firstContentLines
|
|
703
|
+
let firstStartLine = 0
|
|
704
|
+
if (termRows != null && firstContentLines > termRows) firstStartLine = firstContentLines - termRows
|
|
705
|
+
|
|
706
|
+
// Resize: clear the entire visible screen and re-render.
|
|
707
|
+
// Terminal reflow is unpredictable — lines wrap differently based on content,
|
|
708
|
+
// unicode, wrap points. Rather than guessing cursor-up distances, overshoot:
|
|
709
|
+
// ESC[nA is clamped at row 0 of the visible screen (can't touch scrollback),
|
|
710
|
+
// so using termRows is safe. Frozen scrollback content above is preserved.
|
|
711
|
+
let prefix = ""
|
|
712
|
+
if (inlineState.prevCursorRow >= 0) {
|
|
713
|
+
const clearDistance = termRows ?? Math.max(inlineState.prevCursorRow, inlineState.prevOutputLines - 1)
|
|
714
|
+
if (clearDistance > 0) {
|
|
715
|
+
prefix += `\x1b[${clearDistance}A`
|
|
716
|
+
}
|
|
717
|
+
prefix += "\r\x1b[J" // column 0, clear from cursor to end of screen
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
inlineState.prevBuffer = next
|
|
721
|
+
updateInlineCursorRow(inlineState, cursorPos, firstMaxOutput, firstStartLine)
|
|
722
|
+
return prefix + firstOutput + inlineCursorSuffix(cursorPos ?? null, next, termRows)
|
|
723
|
+
}
|
|
724
|
+
if (isStrictAccumulate()) {
|
|
725
|
+
accState.accumulatedAnsi = firstOutput
|
|
726
|
+
accState.accumulateWidth = next.width
|
|
727
|
+
accState.accumulateHeight = next.height
|
|
728
|
+
accState.accumulateFrameCount = 0
|
|
729
|
+
}
|
|
730
|
+
if (CAPTURE_RAW) {
|
|
731
|
+
try {
|
|
732
|
+
const fs = require("fs")
|
|
733
|
+
_captureRawFrameCount = 0
|
|
734
|
+
// Write initial render with frame separator
|
|
735
|
+
fs.writeFileSync("/tmp/silvery-raw.ansi", firstOutput)
|
|
736
|
+
fs.writeFileSync(
|
|
737
|
+
"/tmp/silvery-raw-frames.jsonl",
|
|
738
|
+
JSON.stringify({
|
|
739
|
+
frame: 0,
|
|
740
|
+
type: "full",
|
|
741
|
+
bytes: firstOutput.length,
|
|
742
|
+
width: next.width,
|
|
743
|
+
height: next.height,
|
|
744
|
+
}) + "\n",
|
|
745
|
+
)
|
|
746
|
+
} catch {}
|
|
747
|
+
}
|
|
748
|
+
return firstOutput
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Inline mode: use incremental rendering when safe, fall back to full render.
|
|
752
|
+
if (mode === "inline") {
|
|
753
|
+
inlineState.prevBuffer = next
|
|
754
|
+
return inlineIncrementalRender(inlineState, prev, next, scrollbackOffset, termRows, cursorPos, ctx)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// SILVERY_FULL_RENDER: bypass incremental diff, always render full buffer.
|
|
758
|
+
// Use to diagnose garbled rendering — if FULL_RENDER fixes it, the bug
|
|
759
|
+
// is in changesToAnsi (diff → ANSI serialization).
|
|
760
|
+
if (FULL_RENDER) {
|
|
761
|
+
return bufferToAnsi(next, mode, ctx, termRows)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Fullscreen mode: diff and emit only changes
|
|
765
|
+
const { pool, count: rawCount } = diffBuffers(prev, next)
|
|
766
|
+
|
|
767
|
+
// Filter out changes beyond terminal height to prevent CUP targeting rows
|
|
768
|
+
// past the terminal, which causes scrolling and prevBuffer desync.
|
|
769
|
+
let count = rawCount
|
|
770
|
+
if (termRows != null) {
|
|
771
|
+
let writeIdx = 0
|
|
772
|
+
for (let i = 0; i < rawCount; i++) {
|
|
773
|
+
if (pool[i]!.y < termRows) {
|
|
774
|
+
pool[writeIdx++] = pool[i]!
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
count = writeIdx
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (DEBUG_OUTPUT) {
|
|
781
|
+
// eslint-disable-next-line no-console
|
|
782
|
+
console.error(`[SILVERY_DEBUG_OUTPUT] diffBuffers: ${count} changes${rawCount !== count ? ` (${rawCount - count} clamped beyond termRows)` : ""}`)
|
|
783
|
+
const debugLimit = Math.min(count, 10)
|
|
784
|
+
for (let i = 0; i < debugLimit; i++) {
|
|
785
|
+
const change = pool[i]!
|
|
786
|
+
// eslint-disable-next-line no-console
|
|
787
|
+
console.error(` (${change.x},${change.y}): "${change.cell.char}"`)
|
|
788
|
+
}
|
|
789
|
+
if (count > 10) {
|
|
790
|
+
// eslint-disable-next-line no-console
|
|
791
|
+
console.error(` ... and ${count - 10} more`)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (count === 0) {
|
|
796
|
+
return "" // No changes
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Wide characters are handled atomically in changesToAnsi():
|
|
800
|
+
// - Wide char main cells emit the character and advance cursor by 2
|
|
801
|
+
// - Continuation cells are skipped (handled with their main cell)
|
|
802
|
+
// - Orphaned continuation cells (main cell unchanged) trigger a
|
|
803
|
+
// re-emit of the main cell from the buffer
|
|
804
|
+
const { output: incrOutput } = changesToAnsi(pool, count, mode, ctx, next)
|
|
805
|
+
|
|
806
|
+
// Log output sizes when debug or strict-accumulate is enabled
|
|
807
|
+
if (DEBUG_OUTPUT || isStrictAccumulate()) {
|
|
808
|
+
const bytes = Buffer.byteLength(incrOutput)
|
|
809
|
+
try {
|
|
810
|
+
const fs = require("fs")
|
|
811
|
+
fs.appendFileSync("/tmp/silvery-sizes.log", `changesToAnsi: ${count} changes, ${bytes} bytes\n`)
|
|
812
|
+
} catch {}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Debug capture: write both incremental and fresh ANSI to files for comparison.
|
|
816
|
+
// Usage: SILVERY_DEBUG_CAPTURE=1 bun km view --repo imports/asana launch-academy
|
|
817
|
+
if (DEBUG_CAPTURE) {
|
|
818
|
+
_debugFrameCount++
|
|
819
|
+
try {
|
|
820
|
+
const fs = require("fs")
|
|
821
|
+
const freshOutput = bufferToAnsi(next, mode, ctx)
|
|
822
|
+
const freshPrev = prev ? bufferToAnsi(prev, mode, ctx) : ""
|
|
823
|
+
// Replay incremental on top of fresh prev
|
|
824
|
+
const w = Math.max(prev?.width ?? next.width, next.width)
|
|
825
|
+
const h = Math.max(prev?.height ?? next.height, next.height)
|
|
826
|
+
const screenIncr = replayAnsiWithStyles(w, h, freshPrev + incrOutput, ctx)
|
|
827
|
+
const screenFresh = replayAnsiWithStyles(w, h, freshOutput, ctx)
|
|
828
|
+
// Find first mismatch
|
|
829
|
+
let mismatchInfo = ""
|
|
830
|
+
for (let y = 0; y < h && !mismatchInfo; y++) {
|
|
831
|
+
for (let x = 0; x < w && !mismatchInfo; x++) {
|
|
832
|
+
const ic = screenIncr[y]?.[x]
|
|
833
|
+
const fc = screenFresh[y]?.[x]
|
|
834
|
+
if (ic && fc && (ic.char !== fc.char || !sgrColorEquals(ic.fg, fc.fg) || !sgrColorEquals(ic.bg, fc.bg))) {
|
|
835
|
+
mismatchInfo = `MISMATCH at (${x},${y}): incr='${ic.char}' fresh='${fc.char}' incrFg=${formatColor(ic.fg)} freshFg=${formatColor(fc.fg)} incrBg=${formatColor(ic.bg)} freshBg=${formatColor(fc.bg)}`
|
|
836
|
+
// Show row context
|
|
837
|
+
const incrRow = screenIncr[y]!.map((c) => c.char).join("")
|
|
838
|
+
const freshRow = screenFresh[y]!.map((c) => c.char).join("")
|
|
839
|
+
mismatchInfo += `\n incr row ${y}: ${incrRow.slice(Math.max(0, x - 20), x + 40)}\n fresh row ${y}: ${freshRow.slice(Math.max(0, x - 20), x + 40)}`
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const status = mismatchInfo || "MATCH"
|
|
844
|
+
fs.appendFileSync("/tmp/silvery-capture.log", `Frame ${_debugFrameCount}: ${count} changes, ${status}\n`)
|
|
845
|
+
if (mismatchInfo) {
|
|
846
|
+
fs.writeFileSync(`/tmp/silvery-incr-${_debugFrameCount}.ansi`, freshPrev + incrOutput)
|
|
847
|
+
fs.writeFileSync(`/tmp/silvery-fresh-${_debugFrameCount}.ansi`, freshOutput)
|
|
848
|
+
fs.appendFileSync(
|
|
849
|
+
"/tmp/silvery-capture.log",
|
|
850
|
+
` Saved ANSI files: /tmp/silvery-incr-${_debugFrameCount}.ansi and /tmp/silvery-fresh-${_debugFrameCount}.ansi\n`,
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
} catch (e) {
|
|
854
|
+
try {
|
|
855
|
+
require("fs").appendFileSync("/tmp/silvery-capture.log", `Frame ${_debugFrameCount}: ERROR ${e}\n`)
|
|
856
|
+
} catch {}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// SILVERY_STRICT_OUTPUT: verify that the incremental ANSI output produces the
|
|
861
|
+
// same visible terminal state as a fresh render. Catches bugs in changesToAnsi
|
|
862
|
+
// that SILVERY_STRICT (buffer-level check) cannot detect.
|
|
863
|
+
if (isStrictOutput()) {
|
|
864
|
+
verifyOutputEquivalence(prev, next, incrOutput, mode, ctx)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// SILVERY_STRICT_ACCUMULATE: verify that the accumulated output from ALL frames
|
|
868
|
+
// produces the same terminal state as a fresh render of the current buffer.
|
|
869
|
+
// Catches compounding errors that per-frame verification misses.
|
|
870
|
+
if (isStrictAccumulate()) {
|
|
871
|
+
accState.accumulatedAnsi += incrOutput
|
|
872
|
+
accState.accumulateFrameCount++
|
|
873
|
+
verifyAccumulatedOutput(next, mode, ctx, accState)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (CAPTURE_RAW) {
|
|
877
|
+
try {
|
|
878
|
+
const fs = require("fs")
|
|
879
|
+
_captureRawFrameCount++
|
|
880
|
+
// Append incremental output to cumulative ANSI file
|
|
881
|
+
fs.appendFileSync("/tmp/silvery-raw.ansi", incrOutput)
|
|
882
|
+
// Also save the fresh render of this frame for comparison
|
|
883
|
+
const freshOutput = bufferToAnsi(next, mode, ctx)
|
|
884
|
+
fs.writeFileSync(`/tmp/silvery-raw-fresh-${_captureRawFrameCount}.ansi`, freshOutput)
|
|
885
|
+
fs.appendFileSync(
|
|
886
|
+
"/tmp/silvery-raw-frames.jsonl",
|
|
887
|
+
JSON.stringify({
|
|
888
|
+
frame: _captureRawFrameCount,
|
|
889
|
+
type: "incremental",
|
|
890
|
+
changes: count,
|
|
891
|
+
bytes: incrOutput.length,
|
|
892
|
+
freshBytes: freshOutput.length,
|
|
893
|
+
width: next.width,
|
|
894
|
+
height: next.height,
|
|
895
|
+
}) + "\n",
|
|
896
|
+
)
|
|
897
|
+
} catch {}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return incrOutput
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Check if a line has any non-space content or styling.
|
|
905
|
+
* A row with only spaces but with background color or other styling
|
|
906
|
+
* (bold, inverse, underline, etc.) is visually meaningful.
|
|
907
|
+
*/
|
|
908
|
+
function lineHasContent(buffer: TerminalBuffer, y: number): boolean {
|
|
909
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
910
|
+
const ch = buffer.getCellChar(x, y)
|
|
911
|
+
if (ch !== " " && ch !== "") return true
|
|
912
|
+
// Styled blank cells are visually meaningful:
|
|
913
|
+
// - background color (colored spacer rows)
|
|
914
|
+
// - inverse (visible block of fg color)
|
|
915
|
+
// - underline (visible line under space)
|
|
916
|
+
// - strikethrough (visible line through space)
|
|
917
|
+
const bg = buffer.getCellBg(x, y)
|
|
918
|
+
if (bg !== null) return true
|
|
919
|
+
if (buffer.getCellAttrs(x, y) & VISIBLE_SPACE_ATTR_MASK) return true
|
|
920
|
+
}
|
|
921
|
+
return false
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Find the last line with content in the buffer.
|
|
926
|
+
*/
|
|
927
|
+
function findLastContentLine(buffer: TerminalBuffer): number {
|
|
928
|
+
for (let y = buffer.height - 1; y >= 0; y--) {
|
|
929
|
+
if (lineHasContent(buffer, y)) {
|
|
930
|
+
return y
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return 0 // At least render first line
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Compute the ANSI suffix that positions the real terminal cursor for inline mode.
|
|
938
|
+
*
|
|
939
|
+
* After inline rendering, the terminal cursor sits at the end of the last
|
|
940
|
+
* content line. If a component used useCursor(), we move the cursor to
|
|
941
|
+
* that position (relative to the rendered output). Otherwise we just show
|
|
942
|
+
* the cursor at its current position.
|
|
943
|
+
*
|
|
944
|
+
* @param cursorPos The cursor state from useCursor() (or null if none)
|
|
945
|
+
* @param buffer The rendered buffer
|
|
946
|
+
* @param termRows Terminal height cap (may limit visible rows)
|
|
947
|
+
*/
|
|
948
|
+
function inlineCursorSuffix(
|
|
949
|
+
cursorPos: CursorState | null | undefined,
|
|
950
|
+
buffer: TerminalBuffer,
|
|
951
|
+
termRows?: number,
|
|
952
|
+
): string {
|
|
953
|
+
if (!cursorPos?.visible) {
|
|
954
|
+
// No active cursor — hide it
|
|
955
|
+
return "\x1b[?25l"
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Determine the visible row range (same logic as bufferToAnsi for inline)
|
|
959
|
+
const lastContentLine = findLastContentLine(buffer)
|
|
960
|
+
const maxLine = lastContentLine
|
|
961
|
+
let startLine = 0
|
|
962
|
+
const maxOutputLines = termRows != null ? Math.min(lastContentLine + 1, termRows) : lastContentLine + 1
|
|
963
|
+
if (termRows != null && maxLine >= termRows) {
|
|
964
|
+
startLine = maxLine - termRows + 1
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Convert absolute buffer cursor position to visible row index
|
|
968
|
+
const visibleRow = cursorPos.y - startLine
|
|
969
|
+
if (visibleRow < 0 || visibleRow >= maxOutputLines) {
|
|
970
|
+
// Cursor is outside the visible area (scrolled off) — hide it
|
|
971
|
+
return "\x1b[?25l"
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// After rendering, the terminal cursor is at the end of the last output line.
|
|
975
|
+
// The last output line is at visible row (maxOutputLines - 1).
|
|
976
|
+
const currentRow = maxOutputLines - 1
|
|
977
|
+
const rowDelta = currentRow - visibleRow
|
|
978
|
+
|
|
979
|
+
let suffix = ""
|
|
980
|
+
// Move up to the correct row
|
|
981
|
+
if (rowDelta > 0) {
|
|
982
|
+
suffix += `\x1b[${rowDelta}A`
|
|
983
|
+
}
|
|
984
|
+
// Move to column 0, then right to the correct column
|
|
985
|
+
suffix += "\r"
|
|
986
|
+
if (cursorPos.x > 0) {
|
|
987
|
+
suffix += `\x1b[${cursorPos.x}C`
|
|
988
|
+
}
|
|
989
|
+
// Show cursor
|
|
990
|
+
suffix += "\x1b[?25h"
|
|
991
|
+
return suffix
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Incremental rendering for inline mode.
|
|
996
|
+
*
|
|
997
|
+
* When conditions are safe (no external writes, dimensions unchanged),
|
|
998
|
+
* diffs prev/next buffers and emits only changed cells using relative
|
|
999
|
+
* cursor positioning. Falls back to full render otherwise.
|
|
1000
|
+
*
|
|
1001
|
+
* This reduces output from ~5,848 bytes (full re-render at 50 items)
|
|
1002
|
+
* to ~50-100 bytes per keystroke, matching fullscreen efficiency.
|
|
1003
|
+
*/
|
|
1004
|
+
function inlineIncrementalRender(
|
|
1005
|
+
state: InlineCursorState,
|
|
1006
|
+
prev: TerminalBuffer,
|
|
1007
|
+
next: TerminalBuffer,
|
|
1008
|
+
scrollbackOffset: number,
|
|
1009
|
+
termRows?: number,
|
|
1010
|
+
cursorPos?: CursorState | null,
|
|
1011
|
+
ctx: OutputContext = defaultContext,
|
|
1012
|
+
): string {
|
|
1013
|
+
// Guard: fall back to full render for complex cases
|
|
1014
|
+
if (scrollbackOffset > 0 || prev.width !== next.width || prev.height !== next.height || state.prevCursorRow < 0) {
|
|
1015
|
+
return inlineFullRender(state, prev, next, scrollbackOffset, termRows, cursorPos, ctx)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const nextContentLines = findLastContentLine(next) + 1
|
|
1019
|
+
const prevContentLines = findLastContentLine(prev) + 1
|
|
1020
|
+
|
|
1021
|
+
// Compute visible ranges for both prev and next content
|
|
1022
|
+
const prevMaxOutputLines = termRows != null ? Math.min(prevContentLines, termRows) : prevContentLines
|
|
1023
|
+
const maxOutputLines = termRows != null ? Math.min(nextContentLines, termRows) : nextContentLines
|
|
1024
|
+
let prevStartLine = 0
|
|
1025
|
+
if (termRows != null && prevContentLines > termRows) {
|
|
1026
|
+
prevStartLine = prevContentLines - termRows
|
|
1027
|
+
}
|
|
1028
|
+
let startLine = 0
|
|
1029
|
+
if (termRows != null && nextContentLines > termRows) {
|
|
1030
|
+
startLine = nextContentLines - termRows
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// When the visible window shifts (content exceeds termRows and startLine changes),
|
|
1034
|
+
// the entire visible region is different — fall back to full render.
|
|
1035
|
+
if (startLine !== prevStartLine) {
|
|
1036
|
+
return inlineFullRender(state, prev, next, scrollbackOffset, termRows, cursorPos, ctx)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Diff buffers
|
|
1040
|
+
const { pool, count } = diffBuffers(prev, next)
|
|
1041
|
+
if (count === 0 && nextContentLines === prevContentLines) {
|
|
1042
|
+
// No buffer changes, but cursor position may have changed.
|
|
1043
|
+
// Emit cursor suffix to update the terminal cursor.
|
|
1044
|
+
const suffix = inlineCursorSuffix(cursorPos ?? null, next, termRows)
|
|
1045
|
+
updateInlineCursorRow(state, cursorPos, maxOutputLines, startLine)
|
|
1046
|
+
return suffix
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Move cursor from tracked row to row 0 of render region
|
|
1050
|
+
let output = ""
|
|
1051
|
+
if (state.prevCursorRow > 0) {
|
|
1052
|
+
output += `\x1b[${state.prevCursorRow}A`
|
|
1053
|
+
}
|
|
1054
|
+
output += "\r"
|
|
1055
|
+
output += "\x1b[?25l" // hide cursor during update
|
|
1056
|
+
|
|
1057
|
+
// Emit changes with relative positioning.
|
|
1058
|
+
// Use the larger of prev/next output lines so changesToAnsi processes all
|
|
1059
|
+
// visible cells including rows that shrank (need clearing) or grew (need writing).
|
|
1060
|
+
const effectiveOutputLines = Math.max(prevMaxOutputLines, maxOutputLines)
|
|
1061
|
+
const changes = changesToAnsi(pool, count, "inline", ctx, next, startLine, effectiveOutputLines)
|
|
1062
|
+
output += changes.output
|
|
1063
|
+
|
|
1064
|
+
// After changesToAnsi, cursor is at changes.finalY (render-relative).
|
|
1065
|
+
// We need to position cursor at the effective bottom row, then handle
|
|
1066
|
+
// growth/shrinkage, and end at (maxOutputLines - 1) for inlineCursorSuffix.
|
|
1067
|
+
const finalY = changes.finalY
|
|
1068
|
+
const prevBottomRow = prevMaxOutputLines - 1
|
|
1069
|
+
const bottomRow = maxOutputLines - 1
|
|
1070
|
+
|
|
1071
|
+
if (maxOutputLines > prevMaxOutputLines) {
|
|
1072
|
+
// Content grew: rows beyond the previous bottom don't exist on the terminal.
|
|
1073
|
+
// CUD (\x1b[nB) is clamped at the terminal edge and won't create new lines.
|
|
1074
|
+
// Use \r\n for each new row to ensure the terminal extends naturally.
|
|
1075
|
+
//
|
|
1076
|
+
// changesToAnsi may have already moved past prevBottomRow using \r\n
|
|
1077
|
+
// (creating new terminal lines in the process). Only add \r\n for
|
|
1078
|
+
// rows beyond what changesToAnsi already reached.
|
|
1079
|
+
const fromRow = finalY >= 0 ? finalY : 0
|
|
1080
|
+
if (fromRow >= bottomRow) {
|
|
1081
|
+
// changesToAnsi already reached or passed the new bottom — nothing to do
|
|
1082
|
+
} else if (fromRow >= prevBottomRow) {
|
|
1083
|
+
// Cursor already past old bottom (changesToAnsi extended the terminal).
|
|
1084
|
+
// Only need \r\n for remaining rows.
|
|
1085
|
+
const remainingRows = bottomRow - fromRow
|
|
1086
|
+
for (let i = 0; i < remainingRows; i++) {
|
|
1087
|
+
output += "\r\n"
|
|
1088
|
+
}
|
|
1089
|
+
} else {
|
|
1090
|
+
// Cursor is still within the old content area.
|
|
1091
|
+
// First, move to the old bottom row using CUD (safe, rows exist on terminal)
|
|
1092
|
+
if (fromRow < prevBottomRow) {
|
|
1093
|
+
const dy = prevBottomRow - fromRow
|
|
1094
|
+
output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
|
|
1095
|
+
}
|
|
1096
|
+
// Then extend to new bottom with \r\n for each new row
|
|
1097
|
+
const newRows = bottomRow - prevBottomRow
|
|
1098
|
+
for (let i = 0; i < newRows; i++) {
|
|
1099
|
+
output += "\r\n"
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
} else if (maxOutputLines < prevMaxOutputLines) {
|
|
1103
|
+
// Content shrank: erase orphan lines below the new content.
|
|
1104
|
+
// changesToAnsi already wrote empty cells to clear the old content,
|
|
1105
|
+
// but we need \x1b[K to clear any residual characters.
|
|
1106
|
+
// The cursor is at finalY after changesToAnsi — move to the new bottom first.
|
|
1107
|
+
const fromRow = finalY >= 0 ? finalY : 0
|
|
1108
|
+
if (fromRow < bottomRow) {
|
|
1109
|
+
const dy = bottomRow - fromRow
|
|
1110
|
+
output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
|
|
1111
|
+
} else if (fromRow > bottomRow) {
|
|
1112
|
+
// Cursor is past the new bottom (was erasing orphan rows) — move back up
|
|
1113
|
+
output += `\x1b[${fromRow - bottomRow}A`
|
|
1114
|
+
}
|
|
1115
|
+
// Now at new bottom row (bottomRow). Erase orphan lines below by moving
|
|
1116
|
+
// down one line at a time, erasing each. This leaves cursor at the last
|
|
1117
|
+
// orphan row (prevMaxOutputLines - 1).
|
|
1118
|
+
const orphanCount = prevMaxOutputLines - maxOutputLines
|
|
1119
|
+
for (let y = 0; y < orphanCount; y++) {
|
|
1120
|
+
output += "\n\r\x1b[K"
|
|
1121
|
+
}
|
|
1122
|
+
// Move back up from last orphan row to new bottom row
|
|
1123
|
+
if (orphanCount > 0) output += `\x1b[${orphanCount}A`
|
|
1124
|
+
} else {
|
|
1125
|
+
// Same height: move to bottom row if not already there
|
|
1126
|
+
if (finalY >= 0 && finalY < bottomRow) {
|
|
1127
|
+
const dy = bottomRow - finalY
|
|
1128
|
+
output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
output += inlineCursorSuffix(cursorPos ?? null, next, termRows)
|
|
1133
|
+
|
|
1134
|
+
// Update tracking
|
|
1135
|
+
updateInlineCursorRow(state, cursorPos, maxOutputLines, startLine)
|
|
1136
|
+
|
|
1137
|
+
return output
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Full re-render for inline mode.
|
|
1142
|
+
*
|
|
1143
|
+
* Moves cursor to the start of the render region, writes the entire
|
|
1144
|
+
* buffer fresh, and erases any leftover lines from the previous render.
|
|
1145
|
+
*
|
|
1146
|
+
* When content exceeds terminal height, output is capped to termRows lines.
|
|
1147
|
+
* Lines beyond the terminal can't be managed (cursor-up is clamped at row 0),
|
|
1148
|
+
* so we truncate to prevent scrollback corruption.
|
|
1149
|
+
*/
|
|
1150
|
+
function inlineFullRender(
|
|
1151
|
+
state: InlineCursorState,
|
|
1152
|
+
prev: TerminalBuffer,
|
|
1153
|
+
next: TerminalBuffer,
|
|
1154
|
+
scrollbackOffset: number,
|
|
1155
|
+
termRows?: number,
|
|
1156
|
+
cursorPos?: CursorState | null,
|
|
1157
|
+
ctx: OutputContext = defaultContext,
|
|
1158
|
+
): string {
|
|
1159
|
+
const nextContentLines = findLastContentLine(next) + 1
|
|
1160
|
+
|
|
1161
|
+
// Use tracked state from the previous frame for cursor position and output height.
|
|
1162
|
+
// state.prevCursorRow tracks where the terminal cursor actually is (row within render
|
|
1163
|
+
// region), and state.prevOutputLines tracks how many lines the previous frame rendered.
|
|
1164
|
+
// Re-deriving from the prev buffer can disagree (e.g., when cursor was positioned at a
|
|
1165
|
+
// visible row via useCursor, not at the bottom), causing the cursor-up to overshoot
|
|
1166
|
+
// and leaving orphan lines below the content ("inline-bleed").
|
|
1167
|
+
// Fall back to prev-buffer derivation only when cursor tracking is uninitialized.
|
|
1168
|
+
let prevOutputLines: number
|
|
1169
|
+
let cursorRowInRegion: number
|
|
1170
|
+
if (state.prevCursorRow >= 0) {
|
|
1171
|
+
prevOutputLines = state.prevOutputLines
|
|
1172
|
+
cursorRowInRegion = state.prevCursorRow
|
|
1173
|
+
} else {
|
|
1174
|
+
const prevContentLines = findLastContentLine(prev) + 1
|
|
1175
|
+
prevOutputLines = termRows != null ? Math.min(prevContentLines, termRows) : prevContentLines
|
|
1176
|
+
cursorRowInRegion = prevOutputLines - 1
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// How far the cursor is below the start of the render region:
|
|
1180
|
+
// tracked cursor row + any lines written to stdout between renders.
|
|
1181
|
+
// Cap to termRows-1: terminal clamps cursor-up at row 0.
|
|
1182
|
+
const rawCursorOffset = cursorRowInRegion + scrollbackOffset
|
|
1183
|
+
const cursorOffset = termRows != null && !isStrictOutput() ? Math.min(rawCursorOffset, termRows - 1) : rawCursorOffset
|
|
1184
|
+
|
|
1185
|
+
// Cap output at terminal height to prevent scrollback corruption.
|
|
1186
|
+
// Content taller than the terminal pushes lines into scrollback where
|
|
1187
|
+
// they can never be overwritten (cursor-up is clamped at terminal row 0).
|
|
1188
|
+
const maxOutputLines = termRows != null ? Math.min(nextContentLines, termRows) : nextContentLines
|
|
1189
|
+
|
|
1190
|
+
// Quick check: if nothing changed and no scrollback displacement, skip
|
|
1191
|
+
if (scrollbackOffset === 0) {
|
|
1192
|
+
const { count } = diffBuffers(prev, next)
|
|
1193
|
+
if (count === 0) return ""
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Move cursor up to the start of the render region
|
|
1197
|
+
let prefix = ""
|
|
1198
|
+
if (cursorOffset > 0) {
|
|
1199
|
+
prefix = `\x1b[${cursorOffset}A\r`
|
|
1200
|
+
}
|
|
1201
|
+
// bufferToAnsi handles: hide cursor, render content lines with
|
|
1202
|
+
// \x1b[K (clear to EOL) on each line, and reset style at end.
|
|
1203
|
+
let output = prefix + bufferToAnsi(next, "inline", ctx, maxOutputLines)
|
|
1204
|
+
|
|
1205
|
+
// Erase leftover lines if visible area shrank.
|
|
1206
|
+
// Account for terminal scroll: when useScrollback writes frozen items and the
|
|
1207
|
+
// cursor overflows the terminal, the terminal scrolls up. The scroll amount
|
|
1208
|
+
// equals how far rawCursorOffset exceeds termRows-1 (the terminal's last row).
|
|
1209
|
+
// That many old render lines were pushed into scrollback and no longer need
|
|
1210
|
+
// erasing. The remaining visible old render lines end at lastOccupiedLine.
|
|
1211
|
+
// Lines beyond that contain frozen items — those must NOT be erased.
|
|
1212
|
+
// Note: computed from terminal geometry, independent of strict-output cursor bypass.
|
|
1213
|
+
const terminalScroll = termRows != null ? Math.max(0, rawCursorOffset - (termRows - 1)) : 0
|
|
1214
|
+
const lastOccupiedLine = Math.max(prevOutputLines - 1 - terminalScroll, 0)
|
|
1215
|
+
const nextLastLine = maxOutputLines - 1
|
|
1216
|
+
if (lastOccupiedLine > nextLastLine) {
|
|
1217
|
+
for (let y = nextLastLine + 1; y <= lastOccupiedLine; y++) {
|
|
1218
|
+
output += "\n\r\x1b[K"
|
|
1219
|
+
}
|
|
1220
|
+
const up = lastOccupiedLine - nextLastLine
|
|
1221
|
+
if (up > 0) output += `\x1b[${up}A`
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Position the real terminal cursor and show it.
|
|
1225
|
+
// If a component called useCursor(), place the cursor there.
|
|
1226
|
+
// Otherwise, just show it at the current position (end of content).
|
|
1227
|
+
output += inlineCursorSuffix(cursorPos ?? null, next, termRows)
|
|
1228
|
+
|
|
1229
|
+
// Update cursor tracking for incremental rendering on next frame
|
|
1230
|
+
let startLine = 0
|
|
1231
|
+
if (termRows != null && nextContentLines > termRows) startLine = nextContentLines - termRows
|
|
1232
|
+
updateInlineCursorRow(state, cursorPos, maxOutputLines, startLine)
|
|
1233
|
+
|
|
1234
|
+
return output
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Convert entire buffer to ANSI string.
|
|
1239
|
+
*
|
|
1240
|
+
* @param maxRows Optional cap on number of rows to output (inline mode).
|
|
1241
|
+
* When content exceeds terminal height, this prevents scrollback corruption.
|
|
1242
|
+
*/
|
|
1243
|
+
function bufferToAnsi(
|
|
1244
|
+
buffer: TerminalBuffer,
|
|
1245
|
+
mode: "fullscreen" | "inline" = "fullscreen",
|
|
1246
|
+
ctx: OutputContext = defaultContext,
|
|
1247
|
+
maxRows?: number,
|
|
1248
|
+
): string {
|
|
1249
|
+
let output = ""
|
|
1250
|
+
let currentStyle: Style | null = null
|
|
1251
|
+
let currentHyperlink: string | undefined
|
|
1252
|
+
|
|
1253
|
+
// Cap output to prevent rendering beyond terminal height.
|
|
1254
|
+
// Inline mode: render up to last content line; if taller than terminal, show bottom
|
|
1255
|
+
// (footer and latest content stay visible in scrollback).
|
|
1256
|
+
// Fullscreen mode: always start from top; cap at terminal height to prevent scroll
|
|
1257
|
+
// that desynchronizes prevBuffer from actual terminal state.
|
|
1258
|
+
let maxLine = mode === "inline" ? findLastContentLine(buffer) : buffer.height - 1
|
|
1259
|
+
let startLine = 0
|
|
1260
|
+
if (maxRows != null && maxLine >= maxRows) {
|
|
1261
|
+
if (mode === "fullscreen") {
|
|
1262
|
+
maxLine = maxRows - 1 // cap at terminal height, always from top
|
|
1263
|
+
} else {
|
|
1264
|
+
startLine = maxLine - maxRows + 1 // show bottom of content
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Move cursor to start position based on mode
|
|
1269
|
+
if (mode === "fullscreen") {
|
|
1270
|
+
// Fullscreen: Move cursor to home position (top-left)
|
|
1271
|
+
output += "\x1b[H"
|
|
1272
|
+
} else {
|
|
1273
|
+
// Inline: Hide cursor, start from current position
|
|
1274
|
+
output += "\x1b[?25l"
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Reusable objects to avoid per-cell allocation in the inner loop
|
|
1278
|
+
const cell = createMutableCell()
|
|
1279
|
+
const cellStyle: Style = {
|
|
1280
|
+
fg: null,
|
|
1281
|
+
bg: null,
|
|
1282
|
+
underlineColor: null,
|
|
1283
|
+
attrs: {},
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
for (let y = startLine; y <= maxLine; y++) {
|
|
1287
|
+
// Move to start of line
|
|
1288
|
+
if (y > startLine || mode === "inline") {
|
|
1289
|
+
output += "\r"
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Render the line content
|
|
1293
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
1294
|
+
buffer.readCellInto(x, y, cell)
|
|
1295
|
+
|
|
1296
|
+
// No continuation skip here. Valid continuation cells are never reached
|
|
1297
|
+
// because `if (cell.wide) x++` below jumps past them. Orphaned
|
|
1298
|
+
// continuation cells (wide char overwritten by region clear) must NOT
|
|
1299
|
+
// be skipped — they write a space to keep the VT cursor in sync.
|
|
1300
|
+
|
|
1301
|
+
// Handle OSC 8 hyperlink transitions (separate from SGR style)
|
|
1302
|
+
const cellHyperlink = cell.hyperlink
|
|
1303
|
+
if (cellHyperlink !== currentHyperlink) {
|
|
1304
|
+
if (currentHyperlink) {
|
|
1305
|
+
output += "\x1b]8;;\x1b\\" // Close previous hyperlink
|
|
1306
|
+
}
|
|
1307
|
+
if (cellHyperlink) {
|
|
1308
|
+
output += `\x1b]8;;${cellHyperlink}\x1b\\` // Open new hyperlink
|
|
1309
|
+
}
|
|
1310
|
+
currentHyperlink = cellHyperlink
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Build style from cell and check if changed.
|
|
1314
|
+
// readCellInto mutates cell.attrs in place, so we must snapshot attrs
|
|
1315
|
+
// only when the style actually changes (which is rare -- most adjacent
|
|
1316
|
+
// cells share the same style). This avoids per-cell object allocation.
|
|
1317
|
+
cellStyle.fg = cell.fg
|
|
1318
|
+
cellStyle.bg = cell.bg
|
|
1319
|
+
cellStyle.underlineColor = cell.underlineColor
|
|
1320
|
+
cellStyle.attrs = cell.attrs
|
|
1321
|
+
if (!styleEquals(currentStyle, cellStyle)) {
|
|
1322
|
+
// Snapshot: copy attrs so currentStyle isn't invalidated by next readCellInto
|
|
1323
|
+
const saved: Style = {
|
|
1324
|
+
fg: cell.fg,
|
|
1325
|
+
bg: cell.bg,
|
|
1326
|
+
underlineColor: cell.underlineColor,
|
|
1327
|
+
attrs: { ...cell.attrs },
|
|
1328
|
+
}
|
|
1329
|
+
output += styleTransition(currentStyle, saved, ctx)
|
|
1330
|
+
currentStyle = saved
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Write character — empty-string chars are treated as space to ensure
|
|
1334
|
+
// the terminal cursor advances (an empty string writes nothing, causing
|
|
1335
|
+
// all subsequent characters on the row to shift left by one column).
|
|
1336
|
+
const char = cell.char || " "
|
|
1337
|
+
output += wrapTextSizing(char, cell.wide, ctx)
|
|
1338
|
+
|
|
1339
|
+
// Wide characters occupy 2 columns in the terminal. Skip the next cell
|
|
1340
|
+
// position since the terminal cursor already advanced by 2. We skip
|
|
1341
|
+
// unconditionally (not relying on the next cell's continuation flag)
|
|
1342
|
+
// because the buffer may have a corrupted continuation cell — e.g., when
|
|
1343
|
+
// an adjacent container's region clear overwrites the continuation.
|
|
1344
|
+
// Without this, the non-continuation cell at x+1 would also be written,
|
|
1345
|
+
// causing every subsequent character on the row to shift right by 1.
|
|
1346
|
+
if (cell.wide) {
|
|
1347
|
+
x++
|
|
1348
|
+
// Cursor re-sync: some terminals treat multi-codepoint wide chars
|
|
1349
|
+
// (flag emoji like 🇨🇦) as two width-1 chars instead of one width-2
|
|
1350
|
+
// char. This causes the terminal cursor to be 1 column behind where
|
|
1351
|
+
// we expect. Emit an explicit cursor position to re-sync, mirroring
|
|
1352
|
+
// the re-sync in changesToAnsi. Cost: ~8 bytes per wide char; wide
|
|
1353
|
+
// chars are rare so overhead is negligible.
|
|
1354
|
+
// After x++, x points to the continuation cell. The next character
|
|
1355
|
+
// to write is at x+1 (after the loop increment), so position the
|
|
1356
|
+
// cursor at 0-indexed column x+1 = 1-indexed column x+2.
|
|
1357
|
+
if (mode === "fullscreen") {
|
|
1358
|
+
output += `\x1b[${y - startLine + 1};${x + 2}H`
|
|
1359
|
+
} else {
|
|
1360
|
+
// Inline: \r resets to column 0, CUF moves to expected position.
|
|
1361
|
+
// Reset bg first to prevent bleed across traversed cells.
|
|
1362
|
+
if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
|
|
1363
|
+
output += "\x1b[0m"
|
|
1364
|
+
currentStyle = null
|
|
1365
|
+
}
|
|
1366
|
+
const nextCol = x + 1
|
|
1367
|
+
output += "\r"
|
|
1368
|
+
if (nextCol > 0) output += nextCol === 1 ? "\x1b[C" : `\x1b[${nextCol}C`
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Close any open hyperlink at end of row
|
|
1374
|
+
if (currentHyperlink) {
|
|
1375
|
+
output += "\x1b]8;;\x1b\\"
|
|
1376
|
+
currentHyperlink = undefined
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Reset style before clear-to-end and newline to prevent background
|
|
1380
|
+
// color from filling the right margin or bleeding into the next line.
|
|
1381
|
+
// \x1b[K] uses current SGR attributes for the erased area.
|
|
1382
|
+
if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
|
|
1383
|
+
output += "\x1b[0m"
|
|
1384
|
+
currentStyle = null
|
|
1385
|
+
}
|
|
1386
|
+
// Clear to end of line (removes any leftover content from previous render)
|
|
1387
|
+
output += "\x1b[K"
|
|
1388
|
+
|
|
1389
|
+
// Move to next line (except for last line)
|
|
1390
|
+
if (y < maxLine) {
|
|
1391
|
+
// In inline mode, use \r\n instead of bare \n to cancel DECAWM
|
|
1392
|
+
// pending-wrap state. When the line fills exactly `cols` characters,
|
|
1393
|
+
// the cursor enters pending-wrap at position cols. A bare \n in that
|
|
1394
|
+
// state causes a double line advance in some terminals (Ghostty, iTerm2).
|
|
1395
|
+
// The \r first moves to column 0 (canceling pending-wrap), then \n
|
|
1396
|
+
// advances one row cleanly.
|
|
1397
|
+
output += mode === "inline" ? "\r\n" : "\n"
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Close any open hyperlink at end
|
|
1402
|
+
if (currentHyperlink) {
|
|
1403
|
+
output += "\x1b]8;;\x1b\\"
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Reset style at end
|
|
1407
|
+
output += "\x1b[0m"
|
|
1408
|
+
|
|
1409
|
+
return output
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// ============================================================================
|
|
1413
|
+
// Pre-allocated diff pool
|
|
1414
|
+
// ============================================================================
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Create a fresh CellChange with empty cell data.
|
|
1418
|
+
* Used to populate the pre-allocated pool.
|
|
1419
|
+
*/
|
|
1420
|
+
function createEmptyCellChange(): CellChange {
|
|
1421
|
+
return {
|
|
1422
|
+
x: 0,
|
|
1423
|
+
y: 0,
|
|
1424
|
+
cell: {
|
|
1425
|
+
char: " ",
|
|
1426
|
+
fg: null,
|
|
1427
|
+
bg: null,
|
|
1428
|
+
underlineColor: null,
|
|
1429
|
+
attrs: {},
|
|
1430
|
+
wide: false,
|
|
1431
|
+
continuation: false,
|
|
1432
|
+
},
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Pre-allocated pool of CellChange objects, reused across frames. */
|
|
1437
|
+
const diffPool: CellChange[] = []
|
|
1438
|
+
|
|
1439
|
+
/** Current pool capacity. */
|
|
1440
|
+
let diffPoolCapacity = 0
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Ensure the diff pool has at least `capacity` entries.
|
|
1444
|
+
* Grows the pool if needed; never shrinks.
|
|
1445
|
+
*/
|
|
1446
|
+
function ensureDiffPoolCapacity(capacity: number): void {
|
|
1447
|
+
if (capacity <= diffPoolCapacity) return
|
|
1448
|
+
for (let i = diffPoolCapacity; i < capacity; i++) {
|
|
1449
|
+
diffPool.push(createEmptyCellChange())
|
|
1450
|
+
}
|
|
1451
|
+
diffPoolCapacity = capacity
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Write cell data from a buffer into a pre-allocated CellChange entry.
|
|
1456
|
+
* Uses readCellInto for zero-allocation reads.
|
|
1457
|
+
*/
|
|
1458
|
+
function writeCellChange(change: CellChange, x: number, y: number, buffer: TerminalBuffer): void {
|
|
1459
|
+
change.x = x
|
|
1460
|
+
change.y = y
|
|
1461
|
+
buffer.readCellInto(x, y, change.cell)
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Write empty cell data into a pre-allocated CellChange entry.
|
|
1466
|
+
* Used for shrink regions where cells need to be cleared.
|
|
1467
|
+
*/
|
|
1468
|
+
function writeEmptyCellChange(change: CellChange, x: number, y: number): void {
|
|
1469
|
+
change.x = x
|
|
1470
|
+
change.y = y
|
|
1471
|
+
const cell = change.cell
|
|
1472
|
+
cell.char = " "
|
|
1473
|
+
cell.fg = null
|
|
1474
|
+
cell.bg = null
|
|
1475
|
+
cell.underlineColor = null
|
|
1476
|
+
// Reset attrs fields
|
|
1477
|
+
const attrs = cell.attrs
|
|
1478
|
+
attrs.bold = undefined
|
|
1479
|
+
attrs.dim = undefined
|
|
1480
|
+
attrs.italic = undefined
|
|
1481
|
+
attrs.underline = undefined
|
|
1482
|
+
attrs.underlineStyle = undefined
|
|
1483
|
+
attrs.blink = undefined
|
|
1484
|
+
attrs.inverse = undefined
|
|
1485
|
+
attrs.hidden = undefined
|
|
1486
|
+
attrs.strikethrough = undefined
|
|
1487
|
+
cell.wide = false
|
|
1488
|
+
cell.continuation = false
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Diff result: pool reference + count (avoids per-frame array allocation).
|
|
1493
|
+
*/
|
|
1494
|
+
interface DiffResult {
|
|
1495
|
+
pool: CellChange[]
|
|
1496
|
+
count: number
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/** Reusable diff result object (avoids allocating a new one per frame). */
|
|
1500
|
+
const diffResult: DiffResult = { pool: diffPool, count: 0 }
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Diff two buffers and return changes via pre-allocated pool.
|
|
1504
|
+
*
|
|
1505
|
+
* Optimization: Uses a pre-allocated pool of CellChange objects to avoid
|
|
1506
|
+
* allocating new objects per changed cell. Uses readCellInto for
|
|
1507
|
+
* zero-allocation cell reads. The pool grows as needed but is reused
|
|
1508
|
+
* between frames. Returns a pool+count pair instead of slicing the array.
|
|
1509
|
+
*/
|
|
1510
|
+
function diffBuffers(prev: TerminalBuffer, next: TerminalBuffer): DiffResult {
|
|
1511
|
+
// Ensure pool is large enough for worst case (all cells changed).
|
|
1512
|
+
// Wide→narrow transitions emit an extra change for the continuation cell,
|
|
1513
|
+
// so worst case is 1.5x (every other cell could be a wide→narrow transition).
|
|
1514
|
+
const cells = Math.max(prev.width, next.width) * Math.max(prev.height, next.height)
|
|
1515
|
+
const maxChanges = cells + (cells >> 1) // 1.5x
|
|
1516
|
+
ensureDiffPoolCapacity(maxChanges)
|
|
1517
|
+
|
|
1518
|
+
let changeCount = 0
|
|
1519
|
+
|
|
1520
|
+
// Dimension mismatch means we need to re-render everything visible
|
|
1521
|
+
const height = Math.min(prev.height, next.height)
|
|
1522
|
+
const width = Math.min(prev.width, next.width)
|
|
1523
|
+
|
|
1524
|
+
// Use dirty row bounding box to narrow the scan range.
|
|
1525
|
+
// If no rows are dirty, minDirtyRow is -1 and the loop body is skipped.
|
|
1526
|
+
const startRow = next.minDirtyRow === -1 ? 0 : next.minDirtyRow
|
|
1527
|
+
const endRow = next.maxDirtyRow === -1 ? -1 : Math.min(next.maxDirtyRow, height - 1)
|
|
1528
|
+
|
|
1529
|
+
for (let y = startRow; y <= endRow; y++) {
|
|
1530
|
+
// Skip individual clean rows within the bounding box
|
|
1531
|
+
if (!next.isRowDirty(y)) continue
|
|
1532
|
+
|
|
1533
|
+
// Fast row-level pre-check: if all packed metadata, chars, AND Map-based
|
|
1534
|
+
// extras (true colors, underline colors, hyperlinks) match, skip per-cell
|
|
1535
|
+
// comparison entirely. This catches rows marked dirty by fill() or
|
|
1536
|
+
// scrollRegion() that didn't actually change content.
|
|
1537
|
+
// NOTE: rowExtrasEquals is essential — rowMetadataEquals only checks packed
|
|
1538
|
+
// flags (e.g., "has true color fg"), not the actual RGB values in the Maps.
|
|
1539
|
+
if (next.rowMetadataEquals(y, prev) && next.rowCharsEquals(y, prev) && next.rowExtrasEquals(y, prev)) continue
|
|
1540
|
+
|
|
1541
|
+
for (let x = 0; x < width; x++) {
|
|
1542
|
+
// Use buffer's optimized cellEquals which compares packed metadata first
|
|
1543
|
+
if (!next.cellEquals(x, y, prev)) {
|
|
1544
|
+
writeCellChange(diffPool[changeCount]!, x, y, next)
|
|
1545
|
+
changeCount++
|
|
1546
|
+
|
|
1547
|
+
// Wide char transition: when prev had a wide char and next doesn't,
|
|
1548
|
+
// we must also emit the continuation position (x+1) as a change.
|
|
1549
|
+
// The terminal's state at x+1 contains the second half of the wide
|
|
1550
|
+
// char, but the buffer may show x+1 as "unchanged" (both prev and
|
|
1551
|
+
// next are ' '). Without this explicit change, changesToAnsi skips
|
|
1552
|
+
// x+1 and the terminal retains the wide char remnant, causing
|
|
1553
|
+
// cursor drift.
|
|
1554
|
+
if (x + 1 < width && prev.isCellWide(x, y) && !next.isCellWide(x, y)) {
|
|
1555
|
+
writeCellChange(diffPool[changeCount]!, x + 1, y, next)
|
|
1556
|
+
changeCount++
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// Handle size growth: add all cells in new areas.
|
|
1563
|
+
// Width growth covers the right strip (x >= prev.width) for ALL rows.
|
|
1564
|
+
// Height growth covers the bottom strip (y >= prev.height) but only up to
|
|
1565
|
+
// prev.width to avoid double-counting the corner with width growth.
|
|
1566
|
+
const widthGrew = next.width > prev.width
|
|
1567
|
+
if (widthGrew) {
|
|
1568
|
+
for (let y = 0; y < next.height; y++) {
|
|
1569
|
+
for (let x = prev.width; x < next.width; x++) {
|
|
1570
|
+
writeCellChange(diffPool[changeCount]!, x, y, next)
|
|
1571
|
+
changeCount++
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (next.height > prev.height) {
|
|
1576
|
+
// When width also grew, only iterate x=0..prev.width (the rest was
|
|
1577
|
+
// already covered by width growth above). Otherwise iterate full width.
|
|
1578
|
+
const xEnd = widthGrew ? prev.width : next.width
|
|
1579
|
+
for (let y = prev.height; y < next.height; y++) {
|
|
1580
|
+
for (let x = 0; x < xEnd; x++) {
|
|
1581
|
+
writeCellChange(diffPool[changeCount]!, x, y, next)
|
|
1582
|
+
changeCount++
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Handle size shrink: clear cells in old-but-not-new areas.
|
|
1588
|
+
// Width shrink covers x >= next.width for the shared height.
|
|
1589
|
+
// Height shrink covers y >= next.height but only up to next.width when
|
|
1590
|
+
// width also shrank, to avoid double-counting the corner.
|
|
1591
|
+
const widthShrank = prev.width > next.width
|
|
1592
|
+
if (widthShrank) {
|
|
1593
|
+
for (let y = 0; y < height; y++) {
|
|
1594
|
+
for (let x = next.width; x < prev.width; x++) {
|
|
1595
|
+
writeEmptyCellChange(diffPool[changeCount]!, x, y)
|
|
1596
|
+
changeCount++
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (prev.height > next.height) {
|
|
1601
|
+
// When width also shrank, the corner (x >= next.width, y >= next.height)
|
|
1602
|
+
// was NOT covered by width shrink (which only iterates y < height =
|
|
1603
|
+
// min(prev.height, next.height) = next.height). So iterate full prev.width.
|
|
1604
|
+
for (let y = next.height; y < prev.height; y++) {
|
|
1605
|
+
for (let x = 0; x < prev.width; x++) {
|
|
1606
|
+
writeEmptyCellChange(diffPool[changeCount]!, x, y)
|
|
1607
|
+
changeCount++
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (changeCount > maxChanges) {
|
|
1613
|
+
throw new Error(
|
|
1614
|
+
`diffBuffers: changeCount ${changeCount} exceeds pool capacity ${maxChanges} ` +
|
|
1615
|
+
`(prev ${prev.width}x${prev.height}, next ${next.width}x${next.height})`,
|
|
1616
|
+
)
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
diffResult.pool = diffPool
|
|
1620
|
+
diffResult.count = changeCount
|
|
1621
|
+
return diffResult
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** Result from changesToAnsi: ANSI output string and final cursor position. */
|
|
1625
|
+
interface ChangesResult {
|
|
1626
|
+
output: string
|
|
1627
|
+
/** Final render-relative cursor Y after emitting changes (-1 if no changes emitted). */
|
|
1628
|
+
finalY: number
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/** Pre-allocated style object reused across changesToAnsi calls. */
|
|
1632
|
+
const reusableCellStyle: Style = {
|
|
1633
|
+
fg: null,
|
|
1634
|
+
bg: null,
|
|
1635
|
+
underlineColor: null,
|
|
1636
|
+
attrs: {},
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Pre-allocated cell for looking up wide char main cells from the buffer
|
|
1641
|
+
* when an orphaned continuation cell is encountered in changesToAnsi.
|
|
1642
|
+
*/
|
|
1643
|
+
const wideCharLookupCell = createMutableCell()
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Sort a sub-range of the pool by position for optimal cursor movement.
|
|
1647
|
+
* Uses a simple in-place sort on pool[0..count).
|
|
1648
|
+
*/
|
|
1649
|
+
function sortPoolByPosition(pool: CellChange[], count: number): void {
|
|
1650
|
+
// Insertion sort is efficient for the typical case (mostly sorted or small count)
|
|
1651
|
+
for (let i = 1; i < count; i++) {
|
|
1652
|
+
const item = pool[i]!
|
|
1653
|
+
const iy = item.y
|
|
1654
|
+
const ix = item.x
|
|
1655
|
+
let j = i - 1
|
|
1656
|
+
while (j >= 0 && (pool[j]!.y > iy || (pool[j]!.y === iy && pool[j]!.x > ix))) {
|
|
1657
|
+
pool[j + 1] = pool[j]!
|
|
1658
|
+
j--
|
|
1659
|
+
}
|
|
1660
|
+
pool[j + 1] = item
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Convert cell changes to optimized ANSI output.
|
|
1666
|
+
*
|
|
1667
|
+
* Wide characters are handled atomically: the main cell (wide:true) and its
|
|
1668
|
+
* continuation cell are treated as a single unit. When the main cell is in
|
|
1669
|
+
* the pool, it's emitted and the cursor advances by 2. When only the
|
|
1670
|
+
* continuation cell changed (e.g., bg color), the main cell is read from
|
|
1671
|
+
* the buffer and emitted to cover both columns.
|
|
1672
|
+
*
|
|
1673
|
+
* @param pool Pre-allocated pool of CellChange objects
|
|
1674
|
+
* @param count Number of valid entries in the pool
|
|
1675
|
+
* @param mode Render mode: "fullscreen" uses absolute positioning,
|
|
1676
|
+
* "inline" uses relative cursor movement
|
|
1677
|
+
* @param buffer The current buffer, used to look up main cells for orphaned
|
|
1678
|
+
* continuation cells (optional for backward compatibility)
|
|
1679
|
+
* @param startLine For inline mode: first visible buffer row (for termRows capping)
|
|
1680
|
+
* @param maxOutputLines For inline mode: number of visible rows
|
|
1681
|
+
*/
|
|
1682
|
+
function changesToAnsi(
|
|
1683
|
+
pool: CellChange[],
|
|
1684
|
+
count: number,
|
|
1685
|
+
mode: "fullscreen" | "inline" = "fullscreen",
|
|
1686
|
+
ctx: OutputContext = defaultContext,
|
|
1687
|
+
buffer?: TerminalBuffer,
|
|
1688
|
+
startLine = 0,
|
|
1689
|
+
maxOutputLines = Infinity,
|
|
1690
|
+
): ChangesResult {
|
|
1691
|
+
if (count === 0) return { output: "", finalY: -1 }
|
|
1692
|
+
|
|
1693
|
+
// Sort by position for optimal cursor movement (in-place, no allocation)
|
|
1694
|
+
sortPoolByPosition(pool, count)
|
|
1695
|
+
|
|
1696
|
+
let output = ""
|
|
1697
|
+
let currentStyle: Style | null = null
|
|
1698
|
+
let currentHyperlink: string | undefined
|
|
1699
|
+
const isInline = mode === "inline"
|
|
1700
|
+
const endLine = startLine + maxOutputLines // exclusive upper bound for inline filtering
|
|
1701
|
+
let finalY = -1
|
|
1702
|
+
let cursorX = -1
|
|
1703
|
+
let cursorY = -1
|
|
1704
|
+
let prevY = -1
|
|
1705
|
+
// Track the last emitted cell position to detect when a continuation
|
|
1706
|
+
// cell's main cell was already emitted in this pass.
|
|
1707
|
+
let lastEmittedX = -1
|
|
1708
|
+
let lastEmittedY = -1
|
|
1709
|
+
|
|
1710
|
+
for (let i = 0; i < count; i++) {
|
|
1711
|
+
const change = pool[i]!
|
|
1712
|
+
let x = change.x
|
|
1713
|
+
const y = change.y
|
|
1714
|
+
let cell = change.cell
|
|
1715
|
+
|
|
1716
|
+
// In inline mode, skip changes outside the visible range
|
|
1717
|
+
if (isInline && (y < startLine || y >= endLine)) continue
|
|
1718
|
+
|
|
1719
|
+
// Handle continuation cells: these are the second column of a wide
|
|
1720
|
+
// character. If their main cell (x-1) was already emitted in this
|
|
1721
|
+
// pass, skip. Otherwise, look up and emit the main cell from the
|
|
1722
|
+
// buffer so the wide char covers both columns.
|
|
1723
|
+
if (cell.continuation) {
|
|
1724
|
+
// Main cell was already emitted — skip
|
|
1725
|
+
if (lastEmittedX === x - 1 && lastEmittedY === y) continue
|
|
1726
|
+
|
|
1727
|
+
// Orphaned continuation cell: main cell didn't change but this
|
|
1728
|
+
// cell's style did. Read the main cell from the buffer and emit it.
|
|
1729
|
+
if (buffer && x > 0) {
|
|
1730
|
+
x = x - 1
|
|
1731
|
+
buffer.readCellInto(x, y, wideCharLookupCell)
|
|
1732
|
+
cell = wideCharLookupCell
|
|
1733
|
+
// If the looked-up cell is itself a continuation (shouldn't happen
|
|
1734
|
+
// with valid buffers) or not wide, fall back to skipping
|
|
1735
|
+
if (cell.continuation || !cell.wide) continue
|
|
1736
|
+
} else {
|
|
1737
|
+
continue
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// For inline mode, use render-region-relative row indices
|
|
1742
|
+
const renderY = isInline ? y - startLine : y
|
|
1743
|
+
|
|
1744
|
+
// Close hyperlink on row change (hyperlinks must not span across rows)
|
|
1745
|
+
if (y !== prevY && currentHyperlink) {
|
|
1746
|
+
output += "\x1b]8;;\x1b\\"
|
|
1747
|
+
currentHyperlink = undefined
|
|
1748
|
+
}
|
|
1749
|
+
prevY = y
|
|
1750
|
+
|
|
1751
|
+
// Move cursor if needed (cursor must be exactly at target position)
|
|
1752
|
+
if (renderY !== cursorY || x !== cursorX) {
|
|
1753
|
+
// Use \r\n optimization only if cursor is initialized AND we're moving
|
|
1754
|
+
// to the next line at column 0. Don't use it when cursorY is -1
|
|
1755
|
+
// (uninitialized) because that would incorrectly emit a newline at start.
|
|
1756
|
+
// Bug km-x7ih: This was causing the first row to appear at the bottom.
|
|
1757
|
+
if (cursorY >= 0 && renderY === cursorY + 1 && x === 0) {
|
|
1758
|
+
// Next line at column 0, use newline (more efficient)
|
|
1759
|
+
// Reset style before newline to prevent background color bleeding
|
|
1760
|
+
if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
|
|
1761
|
+
output += "\x1b[0m"
|
|
1762
|
+
currentStyle = null
|
|
1763
|
+
}
|
|
1764
|
+
output += "\r\n"
|
|
1765
|
+
} else if (cursorY >= 0 && renderY === cursorY && x > cursorX) {
|
|
1766
|
+
// Same row, forward: use CUF (Cursor Forward) for small jumps.
|
|
1767
|
+
// Reset bg before CUF to prevent background color bleeding into
|
|
1768
|
+
// skipped cells. Some terminals (e.g., Ghostty) may apply the
|
|
1769
|
+
// current bg to cells traversed by CUF, causing visual artifacts
|
|
1770
|
+
// when bg transitions from undefined→color (km-tui.col-header-dup).
|
|
1771
|
+
if (currentStyle && currentStyle.bg !== null) {
|
|
1772
|
+
output += "\x1b[0m"
|
|
1773
|
+
currentStyle = null
|
|
1774
|
+
}
|
|
1775
|
+
const dx = x - cursorX
|
|
1776
|
+
output += dx === 1 ? "\x1b[C" : `\x1b[${dx}C`
|
|
1777
|
+
} else if (cursorY >= 0 && renderY > cursorY && x === 0) {
|
|
1778
|
+
// Same column (0), down N rows: use \r + CUD
|
|
1779
|
+
const dy = renderY - cursorY
|
|
1780
|
+
if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
|
|
1781
|
+
output += "\x1b[0m"
|
|
1782
|
+
currentStyle = null
|
|
1783
|
+
}
|
|
1784
|
+
output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
|
|
1785
|
+
} else if (isInline) {
|
|
1786
|
+
// Inline mode: relative positioning (no absolute row numbers)
|
|
1787
|
+
if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
|
|
1788
|
+
output += "\x1b[0m"
|
|
1789
|
+
currentStyle = null
|
|
1790
|
+
}
|
|
1791
|
+
// When cursorY === -1 (first change in incremental render),
|
|
1792
|
+
// the cursor is at row 0 (set by inlineIncrementalRender prefix).
|
|
1793
|
+
const fromRow = cursorY >= 0 ? cursorY : 0
|
|
1794
|
+
if (renderY > fromRow) {
|
|
1795
|
+
output += `\x1b[${renderY - fromRow}B\r`
|
|
1796
|
+
} else if (renderY < fromRow) {
|
|
1797
|
+
output += `\x1b[${fromRow - renderY}A\r`
|
|
1798
|
+
} else {
|
|
1799
|
+
output += "\r"
|
|
1800
|
+
}
|
|
1801
|
+
if (x > 0) output += x === 1 ? "\x1b[C" : `\x1b[${x}C`
|
|
1802
|
+
} else {
|
|
1803
|
+
// Fullscreen: absolute position (1-indexed)
|
|
1804
|
+
output += `\x1b[${renderY + 1};${x + 1}H`
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Handle OSC 8 hyperlink transitions (separate from SGR style)
|
|
1809
|
+
const cellHyperlink = cell.hyperlink
|
|
1810
|
+
if (cellHyperlink !== currentHyperlink) {
|
|
1811
|
+
if (currentHyperlink) {
|
|
1812
|
+
output += "\x1b]8;;\x1b\\" // Close previous hyperlink
|
|
1813
|
+
}
|
|
1814
|
+
if (cellHyperlink) {
|
|
1815
|
+
output += `\x1b]8;;${cellHyperlink}\x1b\\` // Open new hyperlink
|
|
1816
|
+
}
|
|
1817
|
+
currentHyperlink = cellHyperlink
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Update style if changed (reuse pre-allocated style object)
|
|
1821
|
+
reusableCellStyle.fg = cell.fg
|
|
1822
|
+
reusableCellStyle.bg = cell.bg
|
|
1823
|
+
reusableCellStyle.underlineColor = cell.underlineColor
|
|
1824
|
+
reusableCellStyle.attrs = cell.attrs
|
|
1825
|
+
if (!styleEquals(currentStyle, reusableCellStyle)) {
|
|
1826
|
+
// Snapshot: copy attrs so currentStyle isn't invalidated by next iteration
|
|
1827
|
+
const prevStyle = currentStyle
|
|
1828
|
+
currentStyle = {
|
|
1829
|
+
fg: cell.fg,
|
|
1830
|
+
bg: cell.bg,
|
|
1831
|
+
underlineColor: cell.underlineColor,
|
|
1832
|
+
attrs: { ...cell.attrs },
|
|
1833
|
+
}
|
|
1834
|
+
output += styleTransition(prevStyle, currentStyle, ctx)
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Write character — empty-string chars are treated as space to ensure
|
|
1838
|
+
// the terminal cursor advances and cursorX tracking stays correct.
|
|
1839
|
+
const char = cell.char || " "
|
|
1840
|
+
output += wrapTextSizing(char, cell.wide, ctx)
|
|
1841
|
+
cursorX = x + (cell.wide ? 2 : 1)
|
|
1842
|
+
cursorY = renderY
|
|
1843
|
+
lastEmittedX = x
|
|
1844
|
+
lastEmittedY = y
|
|
1845
|
+
|
|
1846
|
+
// Wide char cursor re-sync: terminals may advance the cursor by 1
|
|
1847
|
+
// instead of 2 for certain emoji (flag sequences, text-presentation
|
|
1848
|
+
// emoji without OSC 66 support). In bufferToAnsi (full render) this
|
|
1849
|
+
// only causes a consistent per-row shift since every cell is written
|
|
1850
|
+
// sequentially. But in changesToAnsi, contiguous runs rely on cursor
|
|
1851
|
+
// auto-advance, so a width mismatch causes progressive drift —
|
|
1852
|
+
// characters appear at wrong positions, mixing old and new content.
|
|
1853
|
+
// Fix: emit an explicit cursor position after each wide char to
|
|
1854
|
+
// re-sync the terminal cursor with our tracking. Cost: ~8 bytes per
|
|
1855
|
+
// wide char (CUP in fullscreen, \r+CUF in inline). Wide chars are
|
|
1856
|
+
// rare so the overhead is negligible.
|
|
1857
|
+
if (cell.wide) {
|
|
1858
|
+
if (isInline) {
|
|
1859
|
+
// Inline: \r resets to column 0, CUF moves to expected position.
|
|
1860
|
+
// Reset bg first to prevent bleed across traversed cells.
|
|
1861
|
+
if (currentStyle && currentStyle.bg !== null) {
|
|
1862
|
+
output += "\x1b[0m"
|
|
1863
|
+
currentStyle = null
|
|
1864
|
+
}
|
|
1865
|
+
output += "\r"
|
|
1866
|
+
if (cursorX > 0) output += cursorX === 1 ? "\x1b[C" : `\x1b[${cursorX}C`
|
|
1867
|
+
} else {
|
|
1868
|
+
// Fullscreen: CUP (absolute position) — no style reset needed.
|
|
1869
|
+
output += `\x1b[${cursorY + 1};${cursorX + 1}H`
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
finalY = cursorY
|
|
1875
|
+
|
|
1876
|
+
// Close any open hyperlink
|
|
1877
|
+
if (currentHyperlink) {
|
|
1878
|
+
output += "\x1b]8;;\x1b\\"
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Reset style at end
|
|
1882
|
+
if (currentStyle) {
|
|
1883
|
+
output += "\x1b[0m"
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
return { output, finalY }
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// =============================================================================
|
|
1890
|
+
// Color code helpers (imported from ansi/sgr-codes.ts)
|
|
1891
|
+
// =============================================================================
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* Convert style to ANSI escape sequence (chalk-compatible format).
|
|
1895
|
+
*
|
|
1896
|
+
* Emits only non-default attributes with no reset prefix. Called when there
|
|
1897
|
+
* is no previous style context (first cell or after all attributes are off),
|
|
1898
|
+
* so the terminal is already in reset state.
|
|
1899
|
+
*
|
|
1900
|
+
* Uses native 4-bit codes for basic colors (0-7), 256-color for extended,
|
|
1901
|
+
* and true-color for RGB. Each attribute gets its own \x1b[Xm sequence to
|
|
1902
|
+
* match chalk's output format.
|
|
1903
|
+
*
|
|
1904
|
+
* Emits SGR codes including:
|
|
1905
|
+
* - Basic colors (30-37, 40-47)
|
|
1906
|
+
* - 256-color (38;5;N, 48;5;N)
|
|
1907
|
+
* - True color (38;2;r;g;b, 48;2;r;g;b)
|
|
1908
|
+
* - Underline styles (4:x where x = 0-5)
|
|
1909
|
+
* - Underline color (58;5;N or 58;2;r;g;b)
|
|
1910
|
+
* - Inverse uses SGR 7 so terminals swap fg/bg correctly (including default colors)
|
|
1911
|
+
*/
|
|
1912
|
+
function styleToAnsi(style: Style, ctx: OutputContext = defaultContext): string {
|
|
1913
|
+
const fg = style.fg
|
|
1914
|
+
const bg = style.bg
|
|
1915
|
+
|
|
1916
|
+
// Build individual escape sequences (chalk-compatible: one \x1b[Xm per attribute)
|
|
1917
|
+
let result = ""
|
|
1918
|
+
|
|
1919
|
+
// Foreground color
|
|
1920
|
+
if (fg !== null) {
|
|
1921
|
+
result += `\x1b[${fgColorCode(fg)}m`
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// Background color (DEFAULT_BG sentinel = terminal default, skip)
|
|
1925
|
+
if (bg !== null && !isDefaultBg(bg)) {
|
|
1926
|
+
result += `\x1b[${bgColorCode(bg)}m`
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Attributes
|
|
1930
|
+
if (style.attrs.bold) result += "\x1b[1m"
|
|
1931
|
+
if (style.attrs.dim) result += "\x1b[2m"
|
|
1932
|
+
if (style.attrs.italic) result += "\x1b[3m"
|
|
1933
|
+
|
|
1934
|
+
// Underline: use SGR 4:x if style specified, otherwise simple SGR 4
|
|
1935
|
+
if (!ctx.caps.underlineStyles) {
|
|
1936
|
+
// Terminal doesn't support SGR 4:x — use simple SGR 4
|
|
1937
|
+
if (style.attrs.underline || style.attrs.underlineStyle) result += "\x1b[4m"
|
|
1938
|
+
} else {
|
|
1939
|
+
const underlineStyle = style.attrs.underlineStyle
|
|
1940
|
+
const sgrSubparam = underlineStyleToSgr(underlineStyle)
|
|
1941
|
+
if (sgrSubparam !== null && sgrSubparam !== 0) {
|
|
1942
|
+
result += `\x1b[4:${sgrSubparam}m`
|
|
1943
|
+
} else if (style.attrs.underline) {
|
|
1944
|
+
result += "\x1b[4m"
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// Use SGR 7 for inverse — lets the terminal correctly swap fg/bg
|
|
1949
|
+
// (including default terminal colors that have no explicit ANSI code)
|
|
1950
|
+
if (style.attrs.blink) result += "\x1b[5m"
|
|
1951
|
+
if (style.attrs.inverse) result += "\x1b[7m"
|
|
1952
|
+
if (style.attrs.hidden) result += "\x1b[8m"
|
|
1953
|
+
if (style.attrs.strikethrough) result += "\x1b[9m"
|
|
1954
|
+
|
|
1955
|
+
// Append underline color if specified (SGR 58) — skip for limited terminals
|
|
1956
|
+
if (ctx.caps.underlineColor && style.underlineColor !== null && style.underlineColor !== undefined) {
|
|
1957
|
+
if (typeof style.underlineColor === "number") {
|
|
1958
|
+
result += `\x1b[58;5;${style.underlineColor}m`
|
|
1959
|
+
} else {
|
|
1960
|
+
result += `\x1b[58;2;${style.underlineColor.r};${style.underlineColor.g};${style.underlineColor.b}m`
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
return result
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// =============================================================================
|
|
1968
|
+
// SILVERY_STRICT_OUTPUT: ANSI output verification via virtual terminal replay
|
|
1969
|
+
// =============================================================================
|
|
1970
|
+
|
|
1971
|
+
// ============================================================================
|
|
1972
|
+
// Style-Aware ANSI Replay
|
|
1973
|
+
// ============================================================================
|
|
1974
|
+
|
|
1975
|
+
/** SGR state tracked during ANSI replay. */
|
|
1976
|
+
interface SgrState {
|
|
1977
|
+
fg: number | { r: number; g: number; b: number } | null
|
|
1978
|
+
bg: number | { r: number; g: number; b: number } | null
|
|
1979
|
+
bold: boolean
|
|
1980
|
+
dim: boolean
|
|
1981
|
+
italic: boolean
|
|
1982
|
+
underline: boolean
|
|
1983
|
+
blink: boolean
|
|
1984
|
+
inverse: boolean
|
|
1985
|
+
hidden: boolean
|
|
1986
|
+
strikethrough: boolean
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/** A cell in the style-aware virtual terminal. */
|
|
1990
|
+
interface StyledCell {
|
|
1991
|
+
char: string
|
|
1992
|
+
fg: number | { r: number; g: number; b: number } | null
|
|
1993
|
+
bg: number | { r: number; g: number; b: number } | null
|
|
1994
|
+
bold: boolean
|
|
1995
|
+
dim: boolean
|
|
1996
|
+
italic: boolean
|
|
1997
|
+
underline: boolean
|
|
1998
|
+
blink: boolean
|
|
1999
|
+
inverse: boolean
|
|
2000
|
+
hidden: boolean
|
|
2001
|
+
strikethrough: boolean
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function createDefaultSgr(): SgrState {
|
|
2005
|
+
return {
|
|
2006
|
+
fg: null,
|
|
2007
|
+
bg: null,
|
|
2008
|
+
bold: false,
|
|
2009
|
+
dim: false,
|
|
2010
|
+
italic: false,
|
|
2011
|
+
underline: false,
|
|
2012
|
+
blink: false,
|
|
2013
|
+
inverse: false,
|
|
2014
|
+
hidden: false,
|
|
2015
|
+
strikethrough: false,
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function createDefaultStyledCell(): StyledCell {
|
|
2020
|
+
return {
|
|
2021
|
+
char: " ",
|
|
2022
|
+
fg: null,
|
|
2023
|
+
bg: null,
|
|
2024
|
+
bold: false,
|
|
2025
|
+
dim: false,
|
|
2026
|
+
italic: false,
|
|
2027
|
+
underline: false,
|
|
2028
|
+
blink: false,
|
|
2029
|
+
inverse: false,
|
|
2030
|
+
hidden: false,
|
|
2031
|
+
strikethrough: false,
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/**
|
|
2036
|
+
* Apply SGR parameters to the current state.
|
|
2037
|
+
* Handles all SGR codes used by styleTransition().
|
|
2038
|
+
*/
|
|
2039
|
+
function applySgrParams(params: string, sgr: SgrState): void {
|
|
2040
|
+
if (params === "" || params === "0") {
|
|
2041
|
+
// Reset
|
|
2042
|
+
sgr.fg = null
|
|
2043
|
+
sgr.bg = null
|
|
2044
|
+
sgr.bold = false
|
|
2045
|
+
sgr.dim = false
|
|
2046
|
+
sgr.italic = false
|
|
2047
|
+
sgr.underline = false
|
|
2048
|
+
sgr.blink = false
|
|
2049
|
+
sgr.inverse = false
|
|
2050
|
+
sgr.hidden = false
|
|
2051
|
+
sgr.strikethrough = false
|
|
2052
|
+
return
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const parts = params.split(";")
|
|
2056
|
+
let i = 0
|
|
2057
|
+
while (i < parts.length) {
|
|
2058
|
+
const code = parts[i]!
|
|
2059
|
+
// Handle subparameters (e.g., "4:3" for curly underline)
|
|
2060
|
+
const colonIdx = code.indexOf(":")
|
|
2061
|
+
if (colonIdx >= 0) {
|
|
2062
|
+
const mainCode = parseInt(code.substring(0, colonIdx))
|
|
2063
|
+
if (mainCode === 4) {
|
|
2064
|
+
// Underline style subparameter
|
|
2065
|
+
const sub = parseInt(code.substring(colonIdx + 1))
|
|
2066
|
+
sgr.underline = sub > 0
|
|
2067
|
+
}
|
|
2068
|
+
i++
|
|
2069
|
+
continue
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const n = parseInt(code)
|
|
2073
|
+
if (n === 0) {
|
|
2074
|
+
sgr.fg = null
|
|
2075
|
+
sgr.bg = null
|
|
2076
|
+
sgr.bold = false
|
|
2077
|
+
sgr.dim = false
|
|
2078
|
+
sgr.italic = false
|
|
2079
|
+
sgr.underline = false
|
|
2080
|
+
sgr.blink = false
|
|
2081
|
+
sgr.inverse = false
|
|
2082
|
+
sgr.hidden = false
|
|
2083
|
+
sgr.strikethrough = false
|
|
2084
|
+
} else if (n === 1) {
|
|
2085
|
+
sgr.bold = true
|
|
2086
|
+
} else if (n === 2) {
|
|
2087
|
+
sgr.dim = true
|
|
2088
|
+
} else if (n === 3) {
|
|
2089
|
+
sgr.italic = true
|
|
2090
|
+
} else if (n === 4) {
|
|
2091
|
+
sgr.underline = true
|
|
2092
|
+
} else if (n === 5 || n === 6) {
|
|
2093
|
+
sgr.blink = true
|
|
2094
|
+
} else if (n === 7) {
|
|
2095
|
+
sgr.inverse = true
|
|
2096
|
+
} else if (n === 8) {
|
|
2097
|
+
sgr.hidden = true
|
|
2098
|
+
} else if (n === 9) {
|
|
2099
|
+
sgr.strikethrough = true
|
|
2100
|
+
} else if (n === 22) {
|
|
2101
|
+
sgr.bold = false
|
|
2102
|
+
sgr.dim = false
|
|
2103
|
+
} else if (n === 23) {
|
|
2104
|
+
sgr.italic = false
|
|
2105
|
+
} else if (n === 24) {
|
|
2106
|
+
sgr.underline = false
|
|
2107
|
+
} else if (n === 25) {
|
|
2108
|
+
sgr.blink = false
|
|
2109
|
+
} else if (n === 27) {
|
|
2110
|
+
sgr.inverse = false
|
|
2111
|
+
} else if (n === 28) {
|
|
2112
|
+
sgr.hidden = false
|
|
2113
|
+
} else if (n === 29) {
|
|
2114
|
+
sgr.strikethrough = false
|
|
2115
|
+
} else if (n >= 30 && n <= 37) {
|
|
2116
|
+
sgr.fg = n - 30
|
|
2117
|
+
} else if (n === 38) {
|
|
2118
|
+
// Extended fg color
|
|
2119
|
+
if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
|
|
2120
|
+
sgr.fg = parseInt(parts[i + 2]!)
|
|
2121
|
+
i += 2
|
|
2122
|
+
} else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
|
|
2123
|
+
sgr.fg = {
|
|
2124
|
+
r: parseInt(parts[i + 2]!),
|
|
2125
|
+
g: parseInt(parts[i + 3]!),
|
|
2126
|
+
b: parseInt(parts[i + 4]!),
|
|
2127
|
+
}
|
|
2128
|
+
i += 4
|
|
2129
|
+
}
|
|
2130
|
+
} else if (n === 39) {
|
|
2131
|
+
sgr.fg = null
|
|
2132
|
+
} else if (n >= 40 && n <= 47) {
|
|
2133
|
+
sgr.bg = n - 40
|
|
2134
|
+
} else if (n === 48) {
|
|
2135
|
+
// Extended bg color
|
|
2136
|
+
if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
|
|
2137
|
+
sgr.bg = parseInt(parts[i + 2]!)
|
|
2138
|
+
i += 2
|
|
2139
|
+
} else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
|
|
2140
|
+
sgr.bg = {
|
|
2141
|
+
r: parseInt(parts[i + 2]!),
|
|
2142
|
+
g: parseInt(parts[i + 3]!),
|
|
2143
|
+
b: parseInt(parts[i + 4]!),
|
|
2144
|
+
}
|
|
2145
|
+
i += 4
|
|
2146
|
+
}
|
|
2147
|
+
} else if (n === 49) {
|
|
2148
|
+
sgr.bg = null
|
|
2149
|
+
} else if (n >= 90 && n <= 97) {
|
|
2150
|
+
sgr.fg = n - 90 + 8 // bright colors: 8-15
|
|
2151
|
+
} else if (n >= 100 && n <= 107) {
|
|
2152
|
+
sgr.bg = n - 100 + 8
|
|
2153
|
+
}
|
|
2154
|
+
// 58/59 (underline color) not tracked in cell comparison for now
|
|
2155
|
+
i++
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* Replay ANSI output tracking both characters AND SGR styles.
|
|
2161
|
+
* Returns a 2D grid of StyledCell objects.
|
|
2162
|
+
*/
|
|
2163
|
+
export function replayAnsiWithStyles(
|
|
2164
|
+
width: number,
|
|
2165
|
+
height: number,
|
|
2166
|
+
ansi: string,
|
|
2167
|
+
ctx: OutputContext = defaultContext,
|
|
2168
|
+
): StyledCell[][] {
|
|
2169
|
+
const screen: StyledCell[][] = Array.from({ length: height }, () =>
|
|
2170
|
+
Array.from({ length: width }, () => createDefaultStyledCell()),
|
|
2171
|
+
)
|
|
2172
|
+
let cx = 0
|
|
2173
|
+
let cy = 0
|
|
2174
|
+
const sgr = createDefaultSgr()
|
|
2175
|
+
let i = 0
|
|
2176
|
+
|
|
2177
|
+
while (i < ansi.length) {
|
|
2178
|
+
if (ansi[i] === "\x1b") {
|
|
2179
|
+
if (ansi[i + 1] === "[") {
|
|
2180
|
+
i += 2
|
|
2181
|
+
let params = ""
|
|
2182
|
+
while (
|
|
2183
|
+
i < ansi.length &&
|
|
2184
|
+
((ansi[i]! >= "0" && ansi[i]! <= "9") || ansi[i] === ";" || ansi[i] === "?" || ansi[i] === ":")
|
|
2185
|
+
) {
|
|
2186
|
+
params += ansi[i]
|
|
2187
|
+
i++
|
|
2188
|
+
}
|
|
2189
|
+
const cmd = ansi[i]
|
|
2190
|
+
i++
|
|
2191
|
+
if (cmd === "H") {
|
|
2192
|
+
if (params === "") {
|
|
2193
|
+
cx = 0
|
|
2194
|
+
cy = 0
|
|
2195
|
+
} else {
|
|
2196
|
+
const cmdParts = params.split(";")
|
|
2197
|
+
cy = Math.max(0, (parseInt(cmdParts[0]!) || 1) - 1)
|
|
2198
|
+
cx = Math.max(0, (parseInt(cmdParts[1]!) || 1) - 1)
|
|
2199
|
+
}
|
|
2200
|
+
} else if (cmd === "K") {
|
|
2201
|
+
// Erase to end of line — fills with current bg (or default)
|
|
2202
|
+
for (let x = cx; x < width; x++) {
|
|
2203
|
+
const cell = screen[cy]![x]!
|
|
2204
|
+
cell.char = " "
|
|
2205
|
+
cell.fg = null
|
|
2206
|
+
cell.bg = sgr.bg
|
|
2207
|
+
cell.bold = false
|
|
2208
|
+
cell.dim = false
|
|
2209
|
+
cell.italic = false
|
|
2210
|
+
cell.underline = false
|
|
2211
|
+
cell.blink = false
|
|
2212
|
+
cell.inverse = false
|
|
2213
|
+
cell.hidden = false
|
|
2214
|
+
cell.strikethrough = false
|
|
2215
|
+
}
|
|
2216
|
+
} else if (cmd === "A") {
|
|
2217
|
+
cy = Math.max(0, cy - (parseInt(params) || 1))
|
|
2218
|
+
} else if (cmd === "B") {
|
|
2219
|
+
cy = Math.min(height - 1, cy + (parseInt(params) || 1))
|
|
2220
|
+
} else if (cmd === "C") {
|
|
2221
|
+
cx = Math.min(width - 1, cx + (parseInt(params) || 1))
|
|
2222
|
+
} else if (cmd === "D") {
|
|
2223
|
+
cx = Math.max(0, cx - (parseInt(params) || 1))
|
|
2224
|
+
} else if (cmd === "G") {
|
|
2225
|
+
cx = Math.max(0, (parseInt(params) || 1) - 1)
|
|
2226
|
+
} else if (cmd === "J") {
|
|
2227
|
+
if (params === "2") {
|
|
2228
|
+
for (let y = 0; y < height; y++)
|
|
2229
|
+
for (let x = 0; x < width; x++) {
|
|
2230
|
+
screen[y]![x] = createDefaultStyledCell()
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
} else if (cmd === "m") {
|
|
2234
|
+
// SGR — apply to current state
|
|
2235
|
+
applySgrParams(params, sgr)
|
|
2236
|
+
}
|
|
2237
|
+
// Skip DEC modes (h/l), etc.
|
|
2238
|
+
} else if (ansi[i + 1] === "]") {
|
|
2239
|
+
// OSC: extract payload and check for OSC 66 (text sizing)
|
|
2240
|
+
i += 2
|
|
2241
|
+
let oscPayload = ""
|
|
2242
|
+
while (i < ansi.length) {
|
|
2243
|
+
if (ansi[i] === "\x1b" && ansi[i + 1] === "\\") {
|
|
2244
|
+
i += 2
|
|
2245
|
+
break
|
|
2246
|
+
}
|
|
2247
|
+
if (ansi[i] === "\x07") {
|
|
2248
|
+
i++
|
|
2249
|
+
break
|
|
2250
|
+
}
|
|
2251
|
+
oscPayload += ansi[i]
|
|
2252
|
+
i++
|
|
2253
|
+
}
|
|
2254
|
+
// OSC 66: text sizing — format is "66;w=N;TEXT"
|
|
2255
|
+
// Extract TEXT and process it as a character with the declared width
|
|
2256
|
+
if (oscPayload.startsWith("66;")) {
|
|
2257
|
+
const semiIdx = oscPayload.indexOf(";", 3)
|
|
2258
|
+
if (semiIdx !== -1) {
|
|
2259
|
+
const text = oscPayload.slice(semiIdx + 1)
|
|
2260
|
+
const widthParam = oscPayload.slice(3, semiIdx)
|
|
2261
|
+
const declaredWidth = widthParam.startsWith("w=") ? parseInt(widthParam.slice(2)) || 1 : 1
|
|
2262
|
+
if (cy < height && cx < width) {
|
|
2263
|
+
const cell = screen[cy]![cx]!
|
|
2264
|
+
cell.char = text
|
|
2265
|
+
cell.fg = sgr.fg
|
|
2266
|
+
cell.bg = sgr.bg
|
|
2267
|
+
cell.bold = sgr.bold
|
|
2268
|
+
cell.dim = sgr.dim
|
|
2269
|
+
cell.italic = sgr.italic
|
|
2270
|
+
cell.underline = sgr.underline
|
|
2271
|
+
cell.blink = sgr.blink
|
|
2272
|
+
cell.inverse = sgr.inverse
|
|
2273
|
+
cell.hidden = sgr.hidden
|
|
2274
|
+
cell.strikethrough = sgr.strikethrough
|
|
2275
|
+
if (declaredWidth > 1 && cx + 1 < width) {
|
|
2276
|
+
const cont = screen[cy]![cx + 1]!
|
|
2277
|
+
cont.char = " "
|
|
2278
|
+
cont.fg = null
|
|
2279
|
+
cont.bg = sgr.bg
|
|
2280
|
+
cont.bold = false
|
|
2281
|
+
cont.dim = false
|
|
2282
|
+
cont.italic = false
|
|
2283
|
+
cont.underline = false
|
|
2284
|
+
cont.blink = false
|
|
2285
|
+
cont.inverse = false
|
|
2286
|
+
cont.hidden = false
|
|
2287
|
+
cont.strikethrough = false
|
|
2288
|
+
}
|
|
2289
|
+
cx += declaredWidth
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
// Other OSC sequences (8=hyperlinks, etc.) are skipped
|
|
2294
|
+
} else if (ansi[i + 1] === ">") {
|
|
2295
|
+
i += 2
|
|
2296
|
+
while (i < ansi.length && ansi[i] !== "\x1b") i++
|
|
2297
|
+
} else {
|
|
2298
|
+
i += 2
|
|
2299
|
+
}
|
|
2300
|
+
} else if (ansi[i] === "\r") {
|
|
2301
|
+
cx = 0
|
|
2302
|
+
i++
|
|
2303
|
+
} else if (ansi[i] === "\n") {
|
|
2304
|
+
cy = Math.min(height - 1, cy + 1)
|
|
2305
|
+
i++
|
|
2306
|
+
} else {
|
|
2307
|
+
// Extract a full grapheme cluster (handles surrogate pairs and multi-codepoint sequences
|
|
2308
|
+
// like flag emoji 🇺🇸 which are 2 regional indicator codepoints = 4 UTF-16 code units)
|
|
2309
|
+
const cp = ansi.codePointAt(i)!
|
|
2310
|
+
// Advance past this codepoint (2 code units if surrogate pair, 1 otherwise)
|
|
2311
|
+
const cpLen = cp > 0xffff ? 2 : 1
|
|
2312
|
+
// Collect combining marks and joiners that follow (ZWJ sequences, variation selectors, etc.)
|
|
2313
|
+
let grapheme = String.fromCodePoint(cp)
|
|
2314
|
+
let j = i + cpLen
|
|
2315
|
+
let prevWasZwj = false
|
|
2316
|
+
while (j < ansi.length) {
|
|
2317
|
+
const nextCp = ansi.codePointAt(j)!
|
|
2318
|
+
// Combining marks (U+0300-U+036F, U+20D0-U+20FF, U+FE00-U+FE0F variation selectors),
|
|
2319
|
+
// ZWJ (U+200D), regional indicators following another regional indicator.
|
|
2320
|
+
// After ZWJ, the next codepoint is always consumed (it's the joinee — e.g.,
|
|
2321
|
+
// 🏃♂️ = runner + ZWJ + male sign + VS16: male sign is NOT a combining mark
|
|
2322
|
+
// but must be part of this grapheme cluster).
|
|
2323
|
+
const isCombining =
|
|
2324
|
+
prevWasZwj || // Joinee after ZWJ
|
|
2325
|
+
(nextCp >= 0x0300 && nextCp <= 0x036f) || // Combining Diacritical Marks
|
|
2326
|
+
(nextCp >= 0x20d0 && nextCp <= 0x20ff) || // Combining Diacritical Marks for Symbols
|
|
2327
|
+
(nextCp >= 0xfe00 && nextCp <= 0xfe0f) || // Variation Selectors
|
|
2328
|
+
nextCp === 0xfe0e ||
|
|
2329
|
+
nextCp === 0xfe0f || // Text/Emoji presentation
|
|
2330
|
+
nextCp === 0x200d || // ZWJ
|
|
2331
|
+
(nextCp >= 0xe0100 && nextCp <= 0xe01ef) || // Variation Selectors Supplement
|
|
2332
|
+
// Skin tone modifiers (Fitzpatrick scale)
|
|
2333
|
+
(nextCp >= 0x1f3fb && nextCp <= 0x1f3ff) ||
|
|
2334
|
+
// Regional indicator following a regional indicator (flag sequences)
|
|
2335
|
+
(cp >= 0x1f1e6 && cp <= 0x1f1ff && nextCp >= 0x1f1e6 && nextCp <= 0x1f1ff)
|
|
2336
|
+
if (!isCombining) break
|
|
2337
|
+
prevWasZwj = nextCp === 0x200d
|
|
2338
|
+
const nextLen = nextCp > 0xffff ? 2 : 1
|
|
2339
|
+
grapheme += String.fromCodePoint(nextCp)
|
|
2340
|
+
j += nextLen
|
|
2341
|
+
}
|
|
2342
|
+
if (cy < height && cx < width) {
|
|
2343
|
+
const gw = outputGraphemeWidth(grapheme, ctx)
|
|
2344
|
+
const charWidth = gw || 1
|
|
2345
|
+
|
|
2346
|
+
const cell = screen[cy]![cx]!
|
|
2347
|
+
cell.char = grapheme
|
|
2348
|
+
cell.fg = sgr.fg
|
|
2349
|
+
cell.bg = sgr.bg
|
|
2350
|
+
cell.bold = sgr.bold
|
|
2351
|
+
cell.dim = sgr.dim
|
|
2352
|
+
cell.italic = sgr.italic
|
|
2353
|
+
cell.underline = sgr.underline
|
|
2354
|
+
cell.blink = sgr.blink
|
|
2355
|
+
cell.inverse = sgr.inverse
|
|
2356
|
+
cell.hidden = sgr.hidden
|
|
2357
|
+
cell.strikethrough = sgr.strikethrough
|
|
2358
|
+
|
|
2359
|
+
// Wide character overwrites the next cell (continuation cell)
|
|
2360
|
+
// Real terminals do this automatically — the wide char occupies 2 columns
|
|
2361
|
+
if (charWidth > 1 && cx + 1 < width) {
|
|
2362
|
+
const cont = screen[cy]![cx + 1]!
|
|
2363
|
+
cont.char = " "
|
|
2364
|
+
cont.fg = null
|
|
2365
|
+
cont.bg = sgr.bg
|
|
2366
|
+
cont.bold = false
|
|
2367
|
+
cont.dim = false
|
|
2368
|
+
cont.italic = false
|
|
2369
|
+
cont.underline = false
|
|
2370
|
+
cont.blink = false
|
|
2371
|
+
cont.inverse = false
|
|
2372
|
+
cont.hidden = false
|
|
2373
|
+
cont.strikethrough = false
|
|
2374
|
+
}
|
|
2375
|
+
cx += charWidth
|
|
2376
|
+
}
|
|
2377
|
+
i = j
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
return screen
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/** Format a color value for display. */
|
|
2384
|
+
function formatColor(c: number | { r: number; g: number; b: number } | null): string {
|
|
2385
|
+
if (c === null) return "default"
|
|
2386
|
+
if (typeof c === "number") return `${c}`
|
|
2387
|
+
return `rgb(${c.r},${c.g},${c.b})`
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/**
|
|
2391
|
+
* Verify that applying changesToAnsi output to a previous terminal state
|
|
2392
|
+
* produces the same visible characters AND styles as a fresh render of the
|
|
2393
|
+
* next buffer. Throws on mismatch.
|
|
2394
|
+
*
|
|
2395
|
+
* This catches SGR style bugs that character-only verification misses.
|
|
2396
|
+
*/
|
|
2397
|
+
function verifyOutputEquivalence(
|
|
2398
|
+
prev: TerminalBuffer,
|
|
2399
|
+
next: TerminalBuffer,
|
|
2400
|
+
incrOutput: string,
|
|
2401
|
+
mode: "fullscreen" | "inline",
|
|
2402
|
+
ctx: OutputContext = defaultContext,
|
|
2403
|
+
): void {
|
|
2404
|
+
const w = Math.max(prev.width, next.width)
|
|
2405
|
+
// VT height must accommodate the larger buffer to prevent scrolling artifacts
|
|
2406
|
+
// when prev is taller than next (e.g., items removed from a scrollback list).
|
|
2407
|
+
// We only compare up to next.height rows — excess rows should be cleared.
|
|
2408
|
+
const vtHeight = Math.max(prev.height, next.height)
|
|
2409
|
+
const compareHeight = next.height
|
|
2410
|
+
// DEBUG: log buffer dimensions
|
|
2411
|
+
if (process.env.SILVERY_DEBUG_OUTPUT) {
|
|
2412
|
+
// eslint-disable-next-line no-console
|
|
2413
|
+
console.error(
|
|
2414
|
+
`[VERIFY] prev=${prev.width}x${prev.height} next=${next.width}x${next.height} vtSize=${w}x${vtHeight}`,
|
|
2415
|
+
)
|
|
2416
|
+
}
|
|
2417
|
+
// Replay: fresh prev render + incremental diff applied on top
|
|
2418
|
+
const freshPrev = bufferToAnsi(prev, mode, ctx)
|
|
2419
|
+
if (process.env.SILVERY_DEBUG_OUTPUT) {
|
|
2420
|
+
// eslint-disable-next-line no-console
|
|
2421
|
+
console.error(`[VERIFY] freshPrev len=${freshPrev.length} incrOutput len=${incrOutput.length}`)
|
|
2422
|
+
// Show incrOutput as escaped string
|
|
2423
|
+
const escaped = incrOutput.replace(/\x1b/g, "\\e").replace(/\r/g, "\\r").replace(/\n/g, "\\n")
|
|
2424
|
+
// eslint-disable-next-line no-console
|
|
2425
|
+
console.error(`[VERIFY] incrOutput: ${escaped.slice(0, 500)}`)
|
|
2426
|
+
}
|
|
2427
|
+
const screenIncr = replayAnsiWithStyles(w, vtHeight, freshPrev + incrOutput, ctx)
|
|
2428
|
+
// Replay: fresh render of next buffer
|
|
2429
|
+
const freshNext = bufferToAnsi(next, mode, ctx)
|
|
2430
|
+
const screenFresh = replayAnsiWithStyles(w, vtHeight, freshNext, ctx)
|
|
2431
|
+
|
|
2432
|
+
const _dumpRowWideCells = (buf: TerminalBuffer, row: number): string => {
|
|
2433
|
+
const parts: string[] = []
|
|
2434
|
+
for (let cx = 0; cx < buf.width; cx++) {
|
|
2435
|
+
const c = buf.getCell(cx, row)
|
|
2436
|
+
const cp = c.char
|
|
2437
|
+
? [...c.char].map((ch) => "U+" + (ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")).join(",")
|
|
2438
|
+
: "empty"
|
|
2439
|
+
if (c.wide) parts.push(`W@${cx}:${cp}(gw=${outputGraphemeWidth(c.char, ctx)})`)
|
|
2440
|
+
if (c.continuation) parts.push(`C@${cx}`)
|
|
2441
|
+
// Flag cells where written char width differs from buffer expectation
|
|
2442
|
+
const charToWrite = c.char || " "
|
|
2443
|
+
const vtWidth = outputGraphemeWidth(charToWrite, ctx)
|
|
2444
|
+
const bufWidth = c.wide ? 2 : 1
|
|
2445
|
+
if (!c.continuation && vtWidth !== bufWidth) {
|
|
2446
|
+
parts.push(`MISMATCH@${cx}:${cp}(vtW=${vtWidth},bufW=${bufWidth},tse=${outputTextSizingEnabled(ctx)})`)
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
return parts.join(" ")
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// Compare character by character AND style by style.
|
|
2453
|
+
// Use vtHeight (not compareHeight) to catch stale rows after height shrink.
|
|
2454
|
+
// When prev.height > next.height, stale rows beyond next.height must be
|
|
2455
|
+
// verified as cleared — otherwise incremental output silently diverges.
|
|
2456
|
+
for (let y = 0; y < vtHeight; y++) {
|
|
2457
|
+
for (let x = 0; x < w; x++) {
|
|
2458
|
+
const incr = screenIncr[y]![x]!
|
|
2459
|
+
const fresh = screenFresh[y]![x]!
|
|
2460
|
+
|
|
2461
|
+
// Check character
|
|
2462
|
+
if (incr.char !== fresh.char) {
|
|
2463
|
+
// Build context: show the row from both renders
|
|
2464
|
+
const incrRow = screenIncr[y]!.map((c) => c.char).join("")
|
|
2465
|
+
const freshRow = screenFresh[y]!.map((c) => c.char).join("")
|
|
2466
|
+
// Also show the prev buffer row for diagnosis
|
|
2467
|
+
const prevRow = screenIncr[y]!.map((_, cx) => {
|
|
2468
|
+
const prevCell = prev.getCell(cx, y)
|
|
2469
|
+
return prevCell.char
|
|
2470
|
+
}).join("")
|
|
2471
|
+
// Show what changesToAnsi tried to write at this position
|
|
2472
|
+
const nextCell = next.getCell(x, y)
|
|
2473
|
+
const prevCell = prev.getCell(x, y)
|
|
2474
|
+
// Show detailed column-by-column comparison around the mismatch
|
|
2475
|
+
const contextStart = Math.max(0, x - 5)
|
|
2476
|
+
const contextEnd = Math.min(w, x + 10)
|
|
2477
|
+
const colDetails: string[] = []
|
|
2478
|
+
for (let cx = contextStart; cx < contextEnd; cx++) {
|
|
2479
|
+
const ic = screenIncr[y]![cx]!
|
|
2480
|
+
const fc = screenFresh[y]![cx]!
|
|
2481
|
+
const pc = prev.getCell(cx, y)
|
|
2482
|
+
const nc = next.getCell(cx, y)
|
|
2483
|
+
const marker = cx === x ? " <<<" : ic.char !== fc.char ? " !!!" : ""
|
|
2484
|
+
colDetails.push(
|
|
2485
|
+
` col ${cx}: prev='${pc.char}'(w=${pc.wide},c=${pc.continuation}) next='${nc.char}' incr='${ic.char}' fresh='${fc.char}' wide=${nc.wide} cont=${nc.continuation}${marker}`,
|
|
2486
|
+
)
|
|
2487
|
+
}
|
|
2488
|
+
const msg =
|
|
2489
|
+
`SILVERY_STRICT_OUTPUT char mismatch at (${x},${y}): ` +
|
|
2490
|
+
`incremental='${incr.char}' fresh='${fresh.char}'\n` +
|
|
2491
|
+
` prev buffer cell: char='${prevCell.char}' bg=${prevCell.bg} wide=${prevCell.wide} cont=${prevCell.continuation}\n` +
|
|
2492
|
+
` next buffer cell: char='${nextCell.char}' bg=${nextCell.bg} wide=${nextCell.wide} cont=${nextCell.continuation}\n` +
|
|
2493
|
+
` incr row: ${incrRow}\n` +
|
|
2494
|
+
` fresh row: ${freshRow}\n` +
|
|
2495
|
+
` prev row: ${prevRow}\n` +
|
|
2496
|
+
`Wide/cont cells on row ${y} (next buffer): ${_dumpRowWideCells(next, y)}\n` +
|
|
2497
|
+
`Wide/cont cells on row ${y} (prev buffer): ${_dumpRowWideCells(prev, y)}\n` +
|
|
2498
|
+
`Column detail around mismatch:\n${colDetails.join("\n")}`
|
|
2499
|
+
// eslint-disable-next-line no-console
|
|
2500
|
+
console.error(msg)
|
|
2501
|
+
throw new IncrementalRenderMismatchError(msg)
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// Check styles
|
|
2505
|
+
const diffs: string[] = []
|
|
2506
|
+
if (!sgrColorEquals(incr.fg, fresh.fg)) diffs.push(`fg: ${formatColor(incr.fg)} vs ${formatColor(fresh.fg)}`)
|
|
2507
|
+
if (!sgrColorEquals(incr.bg, fresh.bg)) diffs.push(`bg: ${formatColor(incr.bg)} vs ${formatColor(fresh.bg)}`)
|
|
2508
|
+
if (incr.bold !== fresh.bold) diffs.push(`bold: ${incr.bold} vs ${fresh.bold}`)
|
|
2509
|
+
if (incr.dim !== fresh.dim) diffs.push(`dim: ${incr.dim} vs ${fresh.dim}`)
|
|
2510
|
+
if (incr.italic !== fresh.italic) diffs.push(`italic: ${incr.italic} vs ${fresh.italic}`)
|
|
2511
|
+
if (incr.underline !== fresh.underline) diffs.push(`underline: ${incr.underline} vs ${fresh.underline}`)
|
|
2512
|
+
if (incr.inverse !== fresh.inverse) diffs.push(`inverse: ${incr.inverse} vs ${fresh.inverse}`)
|
|
2513
|
+
if (incr.strikethrough !== fresh.strikethrough)
|
|
2514
|
+
diffs.push(`strikethrough: ${incr.strikethrough} vs ${fresh.strikethrough}`)
|
|
2515
|
+
|
|
2516
|
+
if (diffs.length > 0) {
|
|
2517
|
+
const msg =
|
|
2518
|
+
`SILVERY_STRICT_OUTPUT style mismatch at (${x},${y}) char='${incr.char}': ` +
|
|
2519
|
+
diffs.join(", ") +
|
|
2520
|
+
`\n incremental: fg=${formatColor(incr.fg)} bg=${formatColor(incr.bg)} bold=${incr.bold} dim=${incr.dim}` +
|
|
2521
|
+
`\n fresh: fg=${formatColor(fresh.fg)} bg=${formatColor(fresh.bg)} bold=${fresh.bold} dim=${fresh.dim}`
|
|
2522
|
+
throw new IncrementalRenderMismatchError(msg)
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
/**
|
|
2529
|
+
* Verify that the accumulated output from all frames produces the same
|
|
2530
|
+
* terminal state as a fresh render of the current buffer.
|
|
2531
|
+
* Catches compounding errors across multiple render frames.
|
|
2532
|
+
*/
|
|
2533
|
+
function verifyAccumulatedOutput(
|
|
2534
|
+
currentBuffer: TerminalBuffer,
|
|
2535
|
+
mode: "fullscreen" | "inline",
|
|
2536
|
+
ctx: OutputContext = defaultContext,
|
|
2537
|
+
accState: AccumulateState = defaultAccState,
|
|
2538
|
+
): void {
|
|
2539
|
+
const w = accState.accumulateWidth
|
|
2540
|
+
const h = accState.accumulateHeight
|
|
2541
|
+
// Replay all accumulated output (first render + all incremental updates)
|
|
2542
|
+
const screenAccumulated = replayAnsiWithStyles(w, h, accState.accumulatedAnsi, ctx)
|
|
2543
|
+
// Replay fresh render of current buffer
|
|
2544
|
+
const freshOutput = bufferToAnsi(currentBuffer, mode, ctx)
|
|
2545
|
+
const screenFresh = replayAnsiWithStyles(w, h, freshOutput, ctx)
|
|
2546
|
+
|
|
2547
|
+
for (let y = 0; y < h; y++) {
|
|
2548
|
+
for (let x = 0; x < w; x++) {
|
|
2549
|
+
const accum = screenAccumulated[y]![x]!
|
|
2550
|
+
const fresh = screenFresh[y]![x]!
|
|
2551
|
+
|
|
2552
|
+
if (accum.char !== fresh.char) {
|
|
2553
|
+
const msg =
|
|
2554
|
+
`SILVERY_STRICT_ACCUMULATE char mismatch at (${x},${y}) after ${accState.accumulateFrameCount} frames: ` +
|
|
2555
|
+
`accumulated='${accum.char}' fresh='${fresh.char}'`
|
|
2556
|
+
// eslint-disable-next-line no-console
|
|
2557
|
+
console.error(msg)
|
|
2558
|
+
throw new IncrementalRenderMismatchError(msg)
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
const diffs: string[] = []
|
|
2562
|
+
if (!sgrColorEquals(accum.fg, fresh.fg)) diffs.push(`fg: ${formatColor(accum.fg)} vs ${formatColor(fresh.fg)}`)
|
|
2563
|
+
if (!sgrColorEquals(accum.bg, fresh.bg)) diffs.push(`bg: ${formatColor(accum.bg)} vs ${formatColor(fresh.bg)}`)
|
|
2564
|
+
if (accum.bold !== fresh.bold) diffs.push(`bold: ${accum.bold} vs ${fresh.bold}`)
|
|
2565
|
+
if (accum.dim !== fresh.dim) diffs.push(`dim: ${accum.dim} vs ${fresh.dim}`)
|
|
2566
|
+
if (accum.italic !== fresh.italic) diffs.push(`italic: ${accum.italic} vs ${fresh.italic}`)
|
|
2567
|
+
if (accum.underline !== fresh.underline) diffs.push(`underline: ${accum.underline} vs ${fresh.underline}`)
|
|
2568
|
+
if (accum.inverse !== fresh.inverse) diffs.push(`inverse: ${accum.inverse} vs ${fresh.inverse}`)
|
|
2569
|
+
if (accum.strikethrough !== fresh.strikethrough)
|
|
2570
|
+
diffs.push(`strikethrough: ${accum.strikethrough} vs ${fresh.strikethrough}`)
|
|
2571
|
+
|
|
2572
|
+
if (diffs.length > 0) {
|
|
2573
|
+
const msg =
|
|
2574
|
+
`SILVERY_STRICT_ACCUMULATE style mismatch at (${x},${y}) char='${accum.char}' after ${accState.accumulateFrameCount} frames: ` +
|
|
2575
|
+
diffs.join(", ")
|
|
2576
|
+
// eslint-disable-next-line no-console
|
|
2577
|
+
console.error(msg)
|
|
2578
|
+
throw new IncrementalRenderMismatchError(msg)
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
/** Compare two SGR color values. */
|
|
2585
|
+
function sgrColorEquals(
|
|
2586
|
+
a: number | { r: number; g: number; b: number } | null,
|
|
2587
|
+
b: number | { r: number; g: number; b: number } | null,
|
|
2588
|
+
): boolean {
|
|
2589
|
+
if (a === b) return true
|
|
2590
|
+
if (a === null || b === null) return false
|
|
2591
|
+
if (typeof a === "number" || typeof b === "number") return a === b
|
|
2592
|
+
return a.r === b.r && a.g === b.g && a.b === b.b
|
|
2593
|
+
}
|