inkferro_rt/frame.rs
1//! Synchronized-output + clear-decision layer above [`LineDiff`].
2//!
3//! Pure port of ink's interactive frame-emit path
4//! (`ink/src/ink.tsx` `renderInteractiveFrame` ~lines 1037-1102,
5//! `shouldClearTerminalForFrame` ~lines 118-152, and the debug branch
6//! ~lines 550-560) plus the DECSET-2026 synchronized-update wrap
7//! (`ink/src/write-synchronized.ts`).
8//!
9//! Like [`LineDiff`](crate::LineDiff), this layer performs **no terminal IO and
10//! no environment reads**: every ambient input ink derives from the stream or
11//! `process.env` (`isTTY`, `viewportRows`, `interactive`, CI detection) is taken
12//! as an explicit parameter, and the layer returns the bytes that ink would have
13//! written rather than writing them.
14//!
15//! # Cursor precondition (inherited from M2-E)
16//!
17//! [`LineDiff::diff`](crate::LineDiff::diff) bytes assume the terminal cursor
18//! sits at the bottom of the previously rendered frame. This layer is that
19//! consumer: it only ever routes incremental diffs through that path after a
20//! full frame (bootstrap, clear, or static erase) has re-homed the cursor to the
21//! bottom of what is on screen, and it keeps [`LineDiff`]'s baseline in lockstep
22//! with the emitted bytes (see [`FrameWriter::write_frame`]). Callers driving a
23//! [`FrameWriter`] inherit the same precondition: the bytes returned must be
24//! written verbatim, in order, with the cursor left where each write leaves it.
25
26use crate::LineDiff;
27use crate::escapes::{HIDE_CURSOR, SHOW_CURSOR, cursor_down, cursor_to, cursor_up};
28
29/// A terminal cursor position, mirroring ink's `CursorPosition`
30/// (`ink/src/cursor-helpers.ts:3-6`, `{x, y}`). `x`/`y` are 0-based cell
31/// coordinates within the rendered frame; the napi boundary maps its `u32`
32/// `CursorPos` onto this `usize` form for the escape arithmetic.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct CursorPos {
35 /// 0-based column. `buildCursorSuffix` emits `cursorTo(x)` (1-based on the
36 /// wire — `escapes::cursor_to` adds the `+1`).
37 pub x: usize,
38 /// 0-based row within the rendered frame's visible lines.
39 pub y: usize,
40}
41
42/// `cursorPositionChanged(a, b)` (`ink/src/cursor-helpers.ts:16-19`):
43/// `a?.x !== b?.x || a?.y !== b?.y`. For `Option<CursorPos>` this is exactly
44/// structural inequality, so it collapses to `a != b`. Spelled out as a named
45/// helper to mirror the oracle and document the `None`-vs-`Some` semantics
46/// (a present cursor differs from an absent one).
47#[must_use]
48fn cursor_position_changed(a: Option<CursorPos>, b: Option<CursorPos>) -> bool {
49 a != b
50}
51
52/// `buildCursorSuffix(visibleLineCount, cursorPosition)`
53/// (`ink/src/cursor-helpers.ts:25-39`). Moves the cursor from the bottom of the
54/// output (col 0, line `visible_line_count`) up to the target row and across to
55/// the target column, then shows it. `None` -> `""` (no cursor).
56///
57/// `move_up = visible_line_count - cursor.y`; a `cursorUp(move_up)` is emitted
58/// only when `move_up > 0` (oracle: `moveUp > 0 ? cursorUp(moveUp) : ''`), then
59/// always `cursorTo(x)` + `SHOW_CURSOR`.
60#[must_use]
61fn build_cursor_suffix(visible_line_count: usize, cursor: Option<CursorPos>) -> String {
62 let Some(cursor) = cursor else {
63 return String::new();
64 };
65 let mut out = String::new();
66 // `visible_line_count - cursor.y` is non-negative for a cursor inside the
67 // frame; saturating_sub mirrors the oracle's `moveUp > 0` guard (a `move_up`
68 // that would be negative yields 0 -> no `cursorUp`, matching `moveUp > 0`).
69 let move_up = visible_line_count.saturating_sub(cursor.y);
70 if move_up > 0 {
71 out.push_str(&cursor_up(move_up));
72 }
73 out.push_str(&cursor_to(cursor.x));
74 out.push_str(SHOW_CURSOR);
75 out
76}
77
78/// `buildReturnToBottom(previousLineCount, previousCursorPosition)`
79/// (`ink/src/cursor-helpers.ts:45-59`): from a previously shown cursor, walk
80/// back DOWN to the bottom of the output then to col 0. `None` -> `""`.
81///
82/// `down = previousLineCount - 1 - previous.y`; `cursorDown(down)` only when
83/// `down > 0`, then always `cursorTo(0)`.
84#[must_use]
85fn build_return_to_bottom(previous_line_count: usize, previous: Option<CursorPos>) -> String {
86 let Some(previous) = previous else {
87 return String::new();
88 };
89 let mut out = String::new();
90 // previousLineCount includes the trailing-newline empty slot, so the bottom
91 // visible row index is previousLineCount - 1. saturating_sub mirrors the
92 // oracle's `down > 0` guard.
93 let down = previous_line_count
94 .saturating_sub(1)
95 .saturating_sub(previous.y);
96 if down > 0 {
97 out.push_str(&cursor_down(down));
98 }
99 out.push_str(&cursor_to(0));
100 out
101}
102
103/// `buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previous)`
104/// (`ink/src/cursor-helpers.ts:90-103`): `""` unless the cursor was shown, else
105/// `HIDE_CURSOR + buildReturnToBottom(...)`. Hides the cursor and re-homes it
106/// before an erase/rewrite.
107#[must_use]
108fn build_return_to_bottom_prefix(
109 cursor_was_shown: bool,
110 previous_line_count: usize,
111 previous: Option<CursorPos>,
112) -> String {
113 if !cursor_was_shown {
114 return String::new();
115 }
116 let mut out = String::from(HIDE_CURSOR);
117 out.push_str(&build_return_to_bottom(previous_line_count, previous));
118 out
119}
120
121/// `buildCursorOnlySequence(input)` (`ink/src/cursor-helpers.ts:73-84`): the
122/// bytes for an output-unchanged, cursor-moved frame —
123/// `hidePrefix + returnToBottom + cursorSuffix`. `hidePrefix` is `HIDE_CURSOR`
124/// iff the cursor was previously shown; `returnToBottom` re-homes from the old
125/// cursor; `cursorSuffix` repositions/shows the new cursor (or `""` when the new
126/// cursor is `None`, i.e. a pure hide).
127#[must_use]
128fn build_cursor_only_sequence(
129 cursor_was_shown: bool,
130 previous_line_count: usize,
131 previous: Option<CursorPos>,
132 visible_line_count: usize,
133 cursor: Option<CursorPos>,
134) -> String {
135 let mut out = String::new();
136 if cursor_was_shown {
137 out.push_str(HIDE_CURSOR);
138 }
139 out.push_str(&build_return_to_bottom(previous_line_count, previous));
140 out.push_str(&build_cursor_suffix(visible_line_count, cursor));
141 out
142}
143
144/// `visibleLineCount(lines, str)` (`ink/src/log-update.ts:28-29`): the number of
145/// visible lines, ignoring the trailing empty element `split('\n')` yields when
146/// `str` ends with `'\n'`.
147#[must_use]
148fn visible_line_count(line_count: usize, str_ends_with_newline: bool) -> usize {
149 if str_ends_with_newline {
150 line_count.saturating_sub(1)
151 } else {
152 line_count
153 }
154}
155
156/// Visible line count of a frame string `s`, applying the same
157/// trailing-newline rule [`visible_line_count`] does. Used by the full-repaint
158/// branches of [`FrameWriter::write_frame`] to derive their changed-line
159/// telemetry from the string they emit (every visible line is rewritten).
160/// Telemetry only — never feeds the emitted bytes. `u32` to match the
161/// `last_changed_lines` field; line counts never approach `u32::MAX`.
162#[must_use]
163fn visible_lines_of(s: &str) -> u32 {
164 visible_line_count(s.split('\n').count(), s.ends_with('\n')) as u32
165}
166
167/// Begin Synchronized Update (DECSET 2026): `ESC [ ? 2026 h`.
168///
169/// Verbatim from `ink/src/write-synchronized.ts` line 4
170/// (`export const bsu = '[?2026h'`).
171#[allow(non_upper_case_globals)]
172pub const bsu: &str = "\u{001B}[?2026h";
173
174/// End Synchronized Update (DECRST 2026): `ESC [ ? 2026 l`.
175///
176/// Verbatim from `ink/src/write-synchronized.ts` line 5
177/// (`export const esu = '[?2026l'`).
178#[allow(non_upper_case_globals)]
179pub const esu: &str = "\u{001B}[?2026l";
180
181/// `ansiEscapes.clearTerminal` for the non-`isOldWindows` (POSIX) branch:
182/// erase screen, erase scrollback, home cursor.
183///
184/// Provenance: oracle capture of `ansi-escapes` `clearTerminal` inside the ink
185/// repo -> `"[2J[3J[H"`. The `isOldWindows()` branch
186/// (`eraseScreen + ESC 0f`) is unreachable on the target platforms.
187const CLEAR_TERMINAL: &str = "\u{001B}[2J\u{001B}[3J\u{001B}[H";
188
189/// Whether synchronized output should wrap a write.
190///
191/// Pure port of `shouldSynchronize` (`ink/src/write-synchronized.ts` lines
192/// 7-16): `isTTY && (interactive ?? !isInCi)`. The `is_in_ci` detection that ink
193/// performs via the `is-in-ci` package is hoisted to a parameter so this crate
194/// stays free of environment reads; pass `interactive = Some(..)` to override it
195/// (mirroring TS's `interactive ?? !isInCi`).
196#[must_use]
197pub fn should_synchronize(is_tty: bool, interactive: Option<bool>, is_in_ci: bool) -> bool {
198 is_tty && interactive.unwrap_or(!is_in_ci)
199}
200
201/// Whether the next frame must perform a full terminal clear rather than an
202/// incremental diff.
203///
204/// Pure port of `shouldClearTerminalForFrame`
205/// (`ink/src/ink.tsx` lines 118-152). Non-TTY output never clears.
206#[must_use]
207pub fn should_clear_terminal_for_frame(
208 is_tty: bool,
209 viewport_rows: usize,
210 prev_height: usize,
211 next_height: usize,
212 is_unmounting: bool,
213) -> bool {
214 if !is_tty {
215 return false;
216 }
217
218 let had_previous_frame = prev_height > 0;
219 let was_fullscreen = prev_height >= viewport_rows;
220 let was_overflowing = prev_height > viewport_rows;
221 let is_overflowing = next_height > viewport_rows;
222 let is_leaving_fullscreen = was_fullscreen && next_height < viewport_rows;
223 let should_clear_on_unmount = is_unmounting && was_fullscreen;
224
225 was_overflowing
226 || (is_overflowing && had_previous_frame)
227 || is_leaving_fullscreen
228 || should_clear_on_unmount
229}
230
231/// Per-frame inputs to [`FrameWriter::write_frame`].
232///
233/// Each field is an explicit parameter for what ink reads from the stream, the
234/// render result, or `process.env`, so the writer performs no IO.
235#[derive(Debug, Clone)]
236pub struct FrameParams<'a> {
237 /// `stdout.isTTY` — gates fullscreen detection, clear decisions, and sync.
238 pub is_tty: bool,
239 /// Terminal height in rows (`getWindowSize(stdout).rows`). Per-call, not
240 /// constructor state: a viewport resize changes it between frames.
241 pub viewport_rows: usize,
242 /// The rendered main output (no trailing newline added yet).
243 pub output: &'a str,
244 /// The rendered output's height in rows (`outputHeight`).
245 pub output_height: usize,
246 /// New `<Static>` output for this frame, or `""` when there is none. A bare
247 /// `"\n"` is treated as no static content (`ink.tsx:548`
248 /// `staticOutput !== '\n'`). Real static output is accumulated into the
249 /// writer's full-static buffer.
250 pub static_output: &'a str,
251 /// Whether this is the final teardown render (`this.isUnmounting`).
252 pub is_unmounting: bool,
253 /// `log.isCursorDirty()` — retained on the params for the napi plumbing and
254 /// the historical steady-gate contract, but the cursor-RENDER gate now keys
255 /// off [`cursor`](Self::cursor) vs the writer's `previous_cursor_position`
256 /// (the oracle's `hasChanges` is `str !== previousOutput || cursorChanged`,
257 /// `log-update.ts:44-53` — `cursorDirty` itself is NOT in that predicate; it
258 /// only selects `activeCursor` upstream). Defaults `false`; on every
259 /// zero-flicker golden path it is `false`, so it never opens a gate.
260 pub cursor_dirty: bool,
261 /// The ACTIVE cursor for this frame — ink's
262 /// `activeCursor = cursorDirty ? cursorPosition : undefined`
263 /// (`log-update.ts:43`), resolved by the caller (napi) so the writer only
264 /// sees the already-gated value. `None` mirrors no active cursor. The writer
265 /// composes the ink-faithful cursor escape bytes (suffix / cursor-only
266 /// sequence / hide) from this against its `previous_cursor_position`. On
267 /// EVERY zero-flicker golden path this is `None`, which makes every new
268 /// cursor branch a provable no-op (`cursor_changed = false`, all cursor
269 /// builders return `""`), so the golden bytes are byte-identical.
270 pub cursor: Option<CursorPos>,
271 /// `interactive` option (`interactive ?? !isInCi` for sync). `None` defers
272 /// to `is_in_ci`.
273 pub interactive: Option<bool>,
274 /// CI detection result (ink's `is-in-ci`), hoisted out of this crate.
275 pub is_in_ci: bool,
276 /// `options.debug` — plain full-frame mode: no diff, clear, or sync wrap.
277 pub debug: bool,
278}
279
280/// Stateful frame emitter owning a [`LineDiff`] plus the small amount of
281/// cross-frame state ink keeps on its `Ink` instance.
282///
283/// Port of the `renderInteractiveFrame` decision tree. Returns the bytes ink
284/// would have written; performs no IO.
285#[derive(Debug, Default, Clone, PartialEq)]
286pub struct FrameWriter {
287 diff: LineDiff,
288 /// Raw `output` of the last frame (`this.lastOutput`). Distinct from the
289 /// [`LineDiff`] baseline, which tracks `output_to_render`: the steady-branch
290 /// gate and the clear-branch write use this raw string, while diff/sync
291 /// operate on `output_to_render`.
292 last_output: String,
293 /// `this.lastOutputHeight` — the previous frame's height, fed to the clear
294 /// decision as `prev_height`.
295 last_output_height: usize,
296 /// `this.lastOutputToRender` — the newline-policy-padded string the last
297 /// frame actually fed to the [`LineDiff`] (`output` when fullscreen, else
298 /// `output + "\n"`; see [`write_frame`](Self::write_frame) step 2). This is
299 /// the string the [`LineDiff`] baseline tracks, so the K3 primitives that
300 /// re-pin or repaint the baseline (`sync_baseline`, `restore_last_output`)
301 /// operate on THIS, not the raw `last_output`. ink records it as
302 /// `this.lastOutputToRender` next to `this.lastOutput`
303 /// (`ink.tsx:555-556`/`567-568`/`616-617`).
304 last_output_to_render: String,
305 /// `this.fullStaticOutput` — every `<Static>` chunk seen so far, replayed in
306 /// the clear branch.
307 full_static_output: String,
308 /// `previousCursorPosition` (`log-update.ts:183`/`362`): the active cursor of
309 /// the LAST frame, used both to gate (`cursorPositionChanged`) and to
310 /// re-home the terminal cursor (`buildReturnToBottom`) before an erase or a
311 /// cursor-only move. `None` until a frame sets an active cursor; reset to
312 /// `None` by `clear`/`reset_diff_state`. Defaults `None`, so on every golden
313 /// path the cursor gate/branches are inert.
314 previous_cursor_position: Option<CursorPos>,
315 /// `cursorWasShown` (`log-update.ts:184`/`363`): whether the last frame had an
316 /// active (shown) cursor. Drives the hide-prefix in `buildReturnToBottomPrefix`
317 /// / `buildCursorOnlySequence` and the `!activeCursor && cursorWasShown` hide
318 /// in `sync`. Defaults `false`; reset to `false` by `clear`/`reset_diff_state`.
319 cursor_was_shown: bool,
320 /// Count of visible lines the LAST [`write_frame`](Self::write_frame)
321 /// rewrote. Pure additive telemetry for downstream pacing (P5.3): it rides
322 /// alongside the byte computation and is NEVER consulted while building the
323 /// emitted transport bytes, so it perturbs no byte. A no-op frame (empty
324 /// write) records 0; a full repaint (clear/bootstrap/debug/non-TTY full
325 /// frame) records the count of visible lines in the rendered frame; an
326 /// incremental diff records the differ's own changed-line count. Read via
327 /// [`last_changed_lines`](Self::last_changed_lines).
328 last_changed_lines: u32,
329}
330
331impl FrameWriter {
332 /// Create an empty writer (nothing rendered yet).
333 #[must_use]
334 pub fn new() -> Self {
335 Self::default()
336 }
337
338 /// Emit the bytes for one interactive frame and advance internal state.
339 ///
340 /// Mirrors `renderInteractiveFrame` exactly:
341 ///
342 /// 1. **debug**: write `full_static_output + output` raw — no diff, clear, or
343 /// BSU/ESU ever (`ink.tsx` 550-560).
344 /// 2. Compute `is_fullscreen = is_tty && output_height >= viewport_rows` and
345 /// `output_to_render = is_fullscreen ? output : output + "\n"`.
346 /// 3. Accumulate `static_output` into `full_static_output`.
347 /// 4. **clear branch** (`should_clear_terminal_for_frame`): write
348 /// `clearTerminal + full_static_output + output` (raw `output`, *not*
349 /// `output_to_render` — `ink.tsx:1066`), then `LineDiff::sync` the
350 /// baseline to `output_to_render`. Always writes, so wrap in BSU/ESU when
351 /// synchronizing.
352 /// 5. **static branch** (`static_output != ""`): `LineDiff::clear` erase +
353 /// `static_output` + `LineDiff::diff(output_to_render)` (a fresh
354 /// bootstrap). Always writes, so wrap when synchronizing.
355 /// 6. **steady branch** (`output != last_output || cursor_dirty`):
356 /// `LineDiff::diff(output_to_render)`, wrapped in BSU/ESU **only when the
357 /// diff is non-empty** (the `willRender` rule: a no-op diff — including
358 /// one whose gate was opened solely by `cursor_dirty` — emits nothing, so
359 /// no empty BSU/ESU pair).
360 /// 7. else emit nothing.
361 ///
362 /// Every branch records `last_output = output` and
363 /// `last_output_height = output_height`.
364 pub fn write_frame(&mut self, params: &FrameParams<'_>) -> Vec<u8> {
365 let FrameParams {
366 is_tty,
367 viewport_rows,
368 output,
369 output_height,
370 static_output,
371 is_unmounting,
372 // `cursor_dirty` is intentionally unused in the cursor-render gate:
373 // the oracle's `hasChanges` keys off `cursorChanged`, not
374 // `cursorDirty` (see the field docs). Bound with `_` so it stays part
375 // of the destructure without an unused-variable warning.
376 cursor_dirty: _,
377 cursor,
378 interactive,
379 is_in_ci,
380 debug,
381 } = *params;
382
383 // `hasStaticOutput = staticOutput && staticOutput !== '\n'`
384 // (`ink.tsx:548`): a bare newline is not real static content. ink's
385 // caller passes `hasStaticOutput ? staticOutput : ''` into
386 // `renderInteractiveFrame` (`ink.tsx:632-635`) and gates the debug
387 // accumulation on the same flag (`ink.tsx:551`), so the guard is applied
388 // here once for both the debug and interactive branches.
389 let has_static_output = !static_output.is_empty() && static_output != "\n";
390
391 // 1. Debug mode: plain full frame, no erase/diff/BSU ever.
392 if debug {
393 let mut out = String::new();
394 if has_static_output {
395 self.full_static_output.push_str(static_output);
396 }
397 out.push_str(&self.full_static_output);
398 out.push_str(output);
399 // Debug mode never feeds the differ, so it has no padded
400 // `output_to_render`; ink records `lastOutputToRender = output` raw in
401 // the debug branch (`ink.tsx:556`). Mirror that.
402 self.record_frame(output, output, output_height);
403 // Full frame: every visible line of `output` is (re)written. Derived
404 // from the raw `output` this branch emits. Telemetry only — byte-inert.
405 self.last_changed_lines = visible_lines_of(output);
406 return out.into_bytes();
407 }
408
409 // 2. Fullscreen detection drives the trailing-newline policy.
410 let is_fullscreen = is_tty && output_height >= viewport_rows;
411 let output_to_render = if is_fullscreen {
412 output.to_owned()
413 } else {
414 format!("{output}\n")
415 };
416 // Line bookkeeping for the cursor transport, computed from the PADDED
417 // string the differ actually sees (`output_to_render`), exactly as ink's
418 // createIncremental does (`nextLines = str.split('\n')`,
419 // `log-update.ts:217`). `output_to_render_line_count` mirrors
420 // `nextLines.length`; `ends_with_newline` mirrors `str.endsWith('\n')` and
421 // feeds `visibleLineCount` (`log-update.ts:28-29`). Using the padded
422 // string is load-bearing: a non-fullscreen frame is padded with a trailing
423 // `\n`, so `visibleLineCount` is `len - 1`, matching the row the differ
424 // leaves the cursor on (otherwise the cursorUp count drifts a row).
425 let ends_with_newline = output_to_render.ends_with('\n');
426 let output_to_render_line_count = output_to_render.split('\n').count();
427
428 // 3. Accumulate static output for the clear-branch replay.
429 if has_static_output {
430 self.full_static_output.push_str(static_output);
431 }
432
433 let sync = should_synchronize(is_tty, interactive, is_in_ci);
434
435 // 4. Clear branch.
436 let should_clear = should_clear_terminal_for_frame(
437 is_tty,
438 viewport_rows,
439 self.last_output_height,
440 output_height,
441 is_unmounting,
442 );
443 if should_clear {
444 let mut body = String::new();
445 body.push_str(CLEAR_TERMINAL);
446 body.push_str(&self.full_static_output);
447 // Raw `output`, intentionally not `output_to_render`: clearTerminal
448 // has already homed the cursor, and ink writes the unpadded frame
449 // here while syncing the diff baseline to the padded one (ink.tsx
450 // 1066/1071). This asymmetry is preserved, not "fixed".
451 body.push_str(output);
452 self.diff.sync(&output_to_render);
453 // Cursor half of the clear path: clearTerminal homed the cursor, so
454 // there is no `returnToBottom` to do — only the new active cursor's
455 // suffix is appended (ink's clear-frame routes through `log.sync`,
456 // `log-update.ts:344-364`, whose cursor half over a freshly-homed
457 // screen reduces to the suffix). When `cursor` is `None` this is `""`
458 // (no `cursorWasShown` to hide either, since the screen was wiped), so
459 // the golden clear path is byte-identical.
460 body.push_str(&build_cursor_suffix(
461 visible_line_count(output_to_render_line_count, ends_with_newline),
462 cursor,
463 ));
464 self.record_frame(output, &output_to_render, output_height);
465 self.record_cursor(cursor);
466 // Clear/bootstrap full repaint: every visible line of the rendered
467 // frame is rewritten. Derived from `output_to_render` (the string the
468 // baseline is synced to). Telemetry only — byte-inert.
469 self.last_changed_lines = visible_lines_of(&output_to_render);
470 return wrap(body, sync);
471 }
472
473 // 5. Static (non-clear) branch: erase main output, write static, then
474 // re-render the frame as a fresh bootstrap diff.
475 if has_static_output {
476 let mut body = String::from_utf8(self.diff.clear()).expect("erase sequences are ascii");
477 body.push_str(static_output);
478 body.push_str(
479 &String::from_utf8(self.diff.diff(&output_to_render))
480 .expect("diff bytes are ascii/utf8"),
481 );
482 // Append the new active cursor's suffix over the freshly bootstrapped
483 // frame (the bootstrap diff leaves the cursor at the bottom). `None`
484 // -> `""`, so the golden static path is byte-identical.
485 body.push_str(&build_cursor_suffix(
486 visible_line_count(output_to_render_line_count, ends_with_newline),
487 cursor,
488 ));
489 self.record_frame(output, &output_to_render, output_height);
490 self.record_cursor(cursor);
491 // The static branch re-renders as a fresh bootstrap diff, so the
492 // differ's own count is the full visible-line repaint. Telemetry only.
493 self.last_changed_lines = self.diff.last_changed_lines();
494 return wrap(body, sync);
495 }
496
497 // 6. Steady branch. Oracle gate = `hasChanges` (`log-update.ts:44-53`):
498 // `output != previousOutput || cursorChanged`. The faithful predicate
499 // is the OUTPUT delta OR the CURSOR-POSITION delta — NOT `cursorDirty`
500 // (which only selects `activeCursor` upstream, in napi). On a golden
501 // path `cursor`/`previous` are both `None`, so `cursor_changed` is
502 // `false` and this collapses to the original `output != last_output`.
503 let cursor_changed = cursor_position_changed(cursor, self.previous_cursor_position);
504 if output != self.last_output || cursor_changed {
505 let visible = visible_line_count(output_to_render_line_count, ends_with_newline);
506
507 // ── createIncremental cursor-only branch (`log-update.ts:221-234`):
508 // output unchanged AND the cursor moved -> emit ONLY the
509 // cursor-only sequence (hidePrefix + returnToBottom + suffix),
510 // returned DIRECTLY (the differ baseline is untouched).
511 if output == self.last_output && cursor_changed {
512 let seq = build_cursor_only_sequence(
513 self.cursor_was_shown,
514 self.previous_line_count(),
515 self.previous_cursor_position,
516 visible,
517 cursor,
518 );
519 self.record_frame(output, &output_to_render, output_height);
520 self.record_cursor(cursor);
521 // Cursor-only move: the differ baseline is untouched, so NO frame
522 // line was rewritten. Telemetry only — byte-inert.
523 self.last_changed_lines = 0;
524 // `seq` is non-empty here (cursor_changed => at least a hide or a
525 // suffix), so there is always something to wrap.
526 return wrap(seq, sync);
527 }
528
529 // ── Output changed: the per-line diff, with the cursor transport
530 // wrapped around it (`log-update.ts:236` returnPrefix + ... +
531 // `:300-301` cursorSuffix). The differ's own bytes assume the
532 // cursor is at the bottom; `returnPrefix` re-homes a previously
533 // shown cursor first, the suffix repositions the new one after.
534 let return_prefix = build_return_to_bottom_prefix(
535 self.cursor_was_shown,
536 self.previous_line_count(),
537 self.previous_cursor_position,
538 );
539 let d = self.diff.diff(&output_to_render);
540 // Incremental diff: the differ's own changed-line count (0 for an
541 // unchanged-output no-op). Telemetry only — byte-inert; recorded for
542 // both the no-op early return and the wrapped-body path below.
543 self.last_changed_lines = self.diff.last_changed_lines();
544 let suffix = build_cursor_suffix(visible, cursor);
545 self.record_frame(output, &output_to_render, output_height);
546 self.record_cursor(cursor);
547 // willRender: when output is unchanged the diff is empty; with no
548 // cursor prefix/suffix (the golden case: cursor_was_shown=false,
549 // cursor=None) the whole body is empty -> emit NOTHING, no empty
550 // BSU/ESU pair. With an active/previous cursor the prefix/suffix make
551 // the body non-empty even on an empty diff.
552 if return_prefix.is_empty() && d.is_empty() && suffix.is_empty() {
553 return Vec::new();
554 }
555 let mut body = return_prefix.into_bytes();
556 body.extend_from_slice(&d);
557 body.extend_from_slice(suffix.as_bytes());
558 return wrap_bytes(body, sync);
559 }
560
561 // 7. Nothing changed and the cursor is unchanged: emit nothing, but still
562 // advance the recorded frame (ink updates lastOutput unconditionally).
563 // `record_cursor` re-pins `previous_cursor_position`/`cursor_was_shown`
564 // to the (unchanged) active cursor, matching ink's trailing assignment.
565 self.record_frame(output, &output_to_render, output_height);
566 self.record_cursor(cursor);
567 // No-op frame: nothing emitted, nothing rewritten. Telemetry only.
568 self.last_changed_lines = 0;
569 Vec::new()
570 }
571
572 /// How many visible lines the LAST [`write_frame`](Self::write_frame)
573 /// rewrote. 0 for a no-op (empty-write) frame; the visible line count for a
574 /// clear/bootstrap/debug full repaint; the differ's changed-line count for
575 /// an incremental diff. Pure additive telemetry — reading it never affects
576 /// the emitted transport bytes. Intended for downstream pacing (P5.3) to
577 /// distinguish a real-change frame from a no-op timer fire.
578 #[must_use]
579 pub fn last_changed_lines(&self) -> u32 {
580 self.last_changed_lines
581 }
582
583 /// Reset the writer to its as-constructed diff state so the next
584 /// [`write_frame`](Self::write_frame) emits a full repaint.
585 ///
586 /// # Mirrors `render.reset()`, NOT `render.clear()`
587 ///
588 /// ink has two state-clearing entry points (`log-update.ts`):
589 ///
590 /// * `render.clear()` (`log-update.ts:312-323`) writes bytes — it emits a
591 /// return-to-bottom prefix + `eraseLines(previousLines.length)` to the
592 /// stream **before** zeroing `previousOutput`/`previousLines`. It is the
593 /// visible erase.
594 /// * `render.reset()` (`log-update.ts:337-342`) emits **nothing** — it only
595 /// zeroes `previousOutput`, `previousLines`, `previousCursorPosition`, and
596 /// `cursorWasShown`.
597 ///
598 /// This accessor mirrors `render.reset()`. The M3-K3 width-shrink invariant
599 /// is the reason: M3-K3 calls `reset_diff_state` FIRST and *then* re-renders.
600 /// The full repaint comes from the subsequent `write_frame` bootstrap branch,
601 /// so this call must emit no bytes — a `clear()`-style erase would double-erase
602 /// (the bootstrap diff already re-homes the cursor and repaints). Hence we
603 /// delegate to [`LineDiff::reset`](crate::LineDiff::reset) (the no-byte reset),
604 /// never `LineDiff::clear` (the erase-emitting one).
605 ///
606 /// Crucially, `LineDiff::reset` alone is **insufficient**: it only zeroes the
607 /// [`LineDiff`] baseline (`previous_output`/`previous_lines`), leaving this
608 /// writer's own `last_output`, `last_output_height`, and `full_static_output`
609 /// stale. A stale `last_output_height` corrupts the clear decision on the next
610 /// frame (the width-shrink path), and a stale `full_static_output` would replay
611 /// dead static content into the clear branch. So we reset the FULL writer state
612 /// to as-constructed: `*self = Self::default()` is provably equal to a freshly
613 /// constructed [`FrameWriter`] and stays correct if a field is added later.
614 pub fn reset_diff_state(&mut self) {
615 *self = Self::default();
616 }
617
618 fn record_frame(&mut self, output: &str, output_to_render: &str, output_height: usize) {
619 self.last_output.clear();
620 self.last_output.push_str(output);
621 self.last_output_to_render.clear();
622 self.last_output_to_render.push_str(output_to_render);
623 self.last_output_height = output_height;
624 }
625
626 /// Re-pin the cursor state after a frame, mirroring ink's trailing
627 /// `previousCursorPosition = activeCursor ? {...activeCursor} : undefined;
628 /// cursorWasShown = activeCursor !== undefined;`
629 /// (`log-update.ts:305-306`/`362-363`). Called by EVERY non-debug branch
630 /// (including the no-op and cursor-only branches) so the next frame's
631 /// `cursorPositionChanged`/`buildReturnToBottom` see the correct prior cursor.
632 fn record_cursor(&mut self, cursor: Option<CursorPos>) {
633 self.previous_cursor_position = cursor;
634 self.cursor_was_shown = cursor.is_some();
635 }
636
637 /// `previousLines.length` for the cursor transport: the number of `\n`-split
638 /// segments of the LAST recorded padded frame (`last_output_to_render`),
639 /// including the trailing-newline empty slot. Mirrors ink's `previousLines`
640 /// (`str.split('\n')` of the prior `previousOutput`). MUST be read BEFORE the
641 /// frame's `record_frame` overwrites `last_output_to_render`.
642 ///
643 /// The empty-baseline case (nothing rendered yet) is unreachable for the
644 /// callers that use this value: they only build a non-empty
645 /// `buildReturnToBottom`/`buildReturnToBottomPrefix` when
646 /// `previous_cursor_position` is `Some`, which never holds on the first frame.
647 fn previous_line_count(&self) -> usize {
648 self.last_output_to_render.split('\n').count()
649 }
650
651 /// `log.clear()` (ink `log-update.ts:312-323`, the incremental variant).
652 ///
653 /// Returns the erase bytes that wipe the previously rendered frame
654 /// (`eraseLines(previousLines.len)`, the pure-path collapse of
655 /// `returnPrefix + eraseLines(...)`) and ZEROES the [`LineDiff`] baseline, so
656 /// the NEXT [`diff`](crate::LineDiff::diff) (via [`restore_last_output`] or a
657 /// fresh [`write_frame`]) repaints the full frame.
658 ///
659 /// PRESERVES `last_output`, `last_output_to_render`, `last_output_height`, and
660 /// `full_static_output`: ink's `log.clear()` touches ONLY log-update's own
661 /// `previousOutput`/`previousLines` (the [`LineDiff`] baseline here), NOT
662 /// `Ink.lastOutput*`. The Ink class zeroes `lastOutput`/`lastOutputToRender`
663 /// SEPARATELY in `resized()` (`ink.tsx:465-466`) when it needs to; the
664 /// interactive `writeToStdout` erase+restore and `instance.clear()` both rely
665 /// on `last_output_to_render` SURVIVING this call so they can repaint / re-pin
666 /// it afterwards. This is the one shared erase-emitting gesture; it is NOT a
667 /// full state reset (contrast [`reset_diff_state`](Self::reset_diff_state)).
668 ///
669 /// Cursor half (ink `log-update.ts:312-323`): `clear()` prepends the
670 /// `buildReturnToBottomPrefix` (hide + return-to-bottom) when the last frame
671 /// had a shown cursor, BEFORE the `eraseLines`, then zeroes
672 /// `previousCursorPosition`/`cursorWasShown`. On the pure path (no cursor ever
673 /// shown — `cursor_was_shown == false`) the prefix is `""`, so the returned
674 /// bytes are byte-identical to the bare `eraseLines` the M2 path emitted, and
675 /// the cursor-state zeroing is a no-op (already `None`/`false`). The
676 /// `LineDiff` baseline is still the sole thing the differ-erase touches.
677 ///
678 /// [`restore_last_output`]: Self::restore_last_output
679 pub fn clear(&mut self) -> Vec<u8> {
680 // The return-to-bottom prefix uses the PREVIOUS frame's line count and
681 // cursor; capture it before the differ erase (which does not touch
682 // `last_output_to_render`, but read it here for clarity/order-safety).
683 let prefix = build_return_to_bottom_prefix(
684 self.cursor_was_shown,
685 self.previous_line_count(),
686 self.previous_cursor_position,
687 );
688 let erase = self.diff.clear();
689 // ink's `render.clear()` zeroes the cursor state alongside the baseline.
690 self.previous_cursor_position = None;
691 self.cursor_was_shown = false;
692 if prefix.is_empty() {
693 return erase;
694 }
695 let mut out = prefix.into_bytes();
696 out.extend_from_slice(&erase);
697 out
698 }
699
700 /// `log.sync(lastOutputToRender || lastOutput + '\n')` (ink `ink.tsx:940`,
701 /// `log-update.ts:344-364` for the pure path).
702 ///
703 /// Re-pins the [`LineDiff`] baseline to the CURRENT on-screen frame
704 /// (`last_output_to_render`) WITHOUT emitting any bytes. After an
705 /// `instance.clear()` erase, this tells the differ "the screen now shows the
706 /// last frame again" so a subsequent UNCHANGED re-render diffs to a no-op
707 /// (ink's comment: "so that unmount's final onRender sees it as unchanged and
708 /// log-update skips it", `ink.tsx:938-939`).
709 ///
710 /// inkferro divergence from ink's literal `lastOutputToRender || lastOutput +
711 /// '\n'`: `last_output_to_render` is ALREADY the padded string every
712 /// non-debug `write_frame` recorded (it equals `output` fullscreen /
713 /// `output + "\n"` otherwise), so the `||` fallback is unreachable here —
714 /// the empty `last_output_to_render` (no frame written yet) is itself the
715 /// correct baseline (an empty screen), and syncing to `""` is a no-op
716 /// against the already-empty baseline.
717 ///
718 /// Cursor half of ink's `sync` (`log-update.ts:349-363`): with no active
719 /// cursor passed in (this re-pin is only used on the `instance.clear()` /
720 /// unmount path, which the preceding [`clear`](Self::clear) already left with
721 /// `cursor_was_shown == false`), `!activeCursor && cursorWasShown` is `false`
722 /// (no hide) and there is no suffix, and the trailing
723 /// `previousCursorPosition = undefined; cursorWasShown = false` re-pin is a
724 /// no-op against the already-zeroed state. So this emits NOTHING, matching the
725 /// M2 [`LineDiff::sync`](crate::LineDiff::sync) contract.
726 pub fn sync_baseline(&mut self) {
727 self.diff.sync(&self.last_output_to_render);
728 }
729
730 /// `restoreLastOutput()` (ink `ink.tsx:499-508`): repaint the last frame from
731 /// the cleared baseline.
732 ///
733 /// Returns `LineDiff::diff(last_output_to_render)`. After a [`clear`](Self::clear)
734 /// has zeroed the baseline, this diff is a fresh bootstrap (the bootstrap
735 /// branch: `previousOutput.is_empty()`), so it re-emits the FULL last frame —
736 /// exactly ink's `this.log(this.lastOutputToRender || this.lastOutput + '\n')`
737 /// after an interactive `console.log` erased the live region. The cursor
738 /// replay ink does first (`log.setCursorPosition(this.cursorPosition)`,
739 /// `ink.tsx:506`) collapses to nothing in the pure path here: this primitive
740 /// takes no active-cursor input, so it re-emits only the frame body (a
741 /// `useCursor` consumer re-asserts its position on the NEXT real render via
742 /// `write_frame`'s cursor path). So this is the byte-faithful pure-path
743 /// restore.
744 ///
745 /// `last_output_to_render` is the padded string, mirroring ink passing
746 /// `lastOutputToRender` (the wrapped form) into `this.log(...)`. With an empty
747 /// `last_output_to_render` (nothing rendered yet) the diff against the empty
748 /// baseline is itself empty — nothing to restore.
749 pub fn restore_last_output(&mut self) -> Vec<u8> {
750 self.diff.diff(&self.last_output_to_render)
751 }
752
753 /// Zero ONLY `last_output` + `last_output_to_render`, mirroring ink's
754 /// `resized()` (`ink.tsx:466-467`): after `this.log.clear()` it sets
755 /// `this.lastOutput = ''; this.lastOutputToRender = '';` so the post-clear
756 /// re-render of the reflowed (possibly byte-IDENTICAL) frame is forced to
757 /// repaint — `output != lastOutput` opens the steady gate.
758 ///
759 /// This is the oracle-faithful replacement for the pre-#41 `setCursor(None)`
760 /// hack the inkferro resize path used to open the gate: with the cursor gate
761 /// now keyed on POSITION change (not `cursor_dirty`), a `set_cursor(None)`
762 /// no longer forces a repaint, so the resize-shrink path zeroes `last_output`
763 /// directly, exactly as ink does.
764 ///
765 /// Deliberately NARROWER than [`reset_diff_state`](Self::reset_diff_state)
766 /// (`*self = Self::default()`): ink's `resized()` touches ONLY `lastOutput`/
767 /// `lastOutputToRender`. It must NOT zero `full_static_output` (would drop
768 /// `<Static>` content) or `last_output_height` (would skew the next frame's
769 /// clear decision). The [`LineDiff`] baseline is left UNTOUCHED here — the
770 /// resize path calls [`clear`](Self::clear) first, which already zeroed it.
771 ///
772 /// # Precondition
773 /// When a cursor may be shown, call [`clear`](Self::clear) FIRST — it zeroes
774 /// `previous_cursor_position`/`cursor_was_shown`. Calling this standalone with a
775 /// live cursor would strand a stale `previous_cursor_position` against an emptied
776 /// `last_output_to_render`, emitting a spurious return-prefix on the next frame.
777 /// ink's `resized()` satisfies this by always calling `clear()` first.
778 ///
779 /// Emits no bytes.
780 pub fn forget_last_output(&mut self) {
781 self.last_output.clear();
782 self.last_output_to_render.clear();
783 }
784
785 /// Zero ONLY `full_static_output`, mirroring ink's `handleStaticChange`
786 /// (`ink.tsx:522-525`): when the `<Static>` node's IDENTITY changes (key
787 /// remount / replacement, detected in the reconciler's `resetAfterCommit`,
788 /// ink `reconciler.ts:167-175`), ink sets `this.fullStaticOutput = ''` so
789 /// the clear-branch replay (`clearTerminal + fullStaticOutput + output`,
790 /// `ink.tsx:1066`) never re-emits a DEAD `<Static>` instance's accumulated
791 /// items. Without this, the writer's accumulator — fed by every non-debug
792 /// [`write_frame`](Self::write_frame) — keeps the dead node's chunks and
793 /// replays them on the next overflow/leaving-fullscreen clear frame.
794 ///
795 /// Deliberately NARROWER than [`reset_diff_state`](Self::reset_diff_state):
796 /// the identity change happens mid-stream with a live on-screen frame, so
797 /// `last_output*`, `last_output_height`, the [`LineDiff`] baseline, and the
798 /// cursor state must all SURVIVE (ink's `handleStaticChange` touches only
799 /// `fullStaticOutput`). New `<Static>` content rendered AFTER the identity
800 /// change re-accumulates normally.
801 ///
802 /// Emits no bytes.
803 pub fn reset_static_output(&mut self) {
804 self.full_static_output.clear();
805 }
806
807 /// Fused interactive `writeToStdout` console-interleave (P1.2 / #1): one
808 /// buffer carrying `bsu? + clear() + data + restoreLastOutput() + esu?`.
809 ///
810 /// Byte-identical to the old 5-write path that composed the same
811 /// `clear()` / `restore_last_output()` primitives sequentially. The
812 /// `clear()` zeros the diff baseline; the `restore_last_output()` then
813 /// bootstraps the full last frame from that cleared baseline — exactly
814 /// what the JS `Ink` class used to orchestrate as separate `.write()` calls.
815 ///
816 /// `data` is the app text (`console.log` payload) encoded as UTF-8 bytes.
817 /// `sync` gates the BSU/ESU DECSET-2026 synchronized-update wrap
818 /// (`shouldSync()` resolved JS-side).
819 ///
820 /// In the nothing-rendered-yet state: `clear()` and `restore_last_output()`
821 /// both return empty (no frame to erase/repaint), so the result is
822 /// `bsu? + data + esu?` — byte-identical to what the old path produced.
823 pub fn compose_console_write(&mut self, data: &[u8], sync: bool) -> Vec<u8> {
824 let clear = self.clear();
825 let restore = self.restore_last_output();
826 let capacity = bsu.len() + clear.len() + data.len() + restore.len() + esu.len();
827 let mut out = Vec::with_capacity(capacity);
828 if sync {
829 out.extend_from_slice(bsu.as_bytes());
830 }
831 out.extend_from_slice(&clear);
832 out.extend_from_slice(data);
833 out.extend_from_slice(&restore);
834 if sync {
835 out.extend_from_slice(esu.as_bytes());
836 }
837 out
838 }
839
840 /// The stdout-side OPENING half of the fused interactive `writeToStderr`
841 /// interleave: `bsu? + clear()`. Paired with
842 /// [`compose_console_suffix`](Self::compose_console_suffix) so the JS path
843 /// is exactly 3 writes: prefix→stdout, data→stderr, suffix→stdout.
844 pub fn compose_console_prefix(&mut self, sync: bool) -> Vec<u8> {
845 let clear = self.clear();
846 if !sync {
847 return clear;
848 }
849 let mut out = Vec::with_capacity(bsu.len() + clear.len());
850 out.extend_from_slice(bsu.as_bytes());
851 out.extend_from_slice(&clear);
852 out
853 }
854
855 /// The stdout-side CLOSING half of the fused interactive `writeToStderr`
856 /// interleave: `restoreLastOutput() + esu?`. MUST follow a matching
857 /// [`compose_console_prefix`](Self::compose_console_prefix) (whose
858 /// `clear()` zeroed the diff baseline) so the restore is the full-frame
859 /// bootstrap.
860 pub fn compose_console_suffix(&mut self, sync: bool) -> Vec<u8> {
861 let restore = self.restore_last_output();
862 if !sync {
863 return restore;
864 }
865 let mut out = Vec::with_capacity(restore.len() + esu.len());
866 out.extend_from_slice(&restore);
867 out.extend_from_slice(esu.as_bytes());
868 out
869 }
870}
871
872/// Wrap a UTF-8 body in BSU/ESU when `sync`, returning bytes.
873fn wrap(body: String, sync: bool) -> Vec<u8> {
874 wrap_bytes(body.into_bytes(), sync)
875}
876
877/// Wrap raw bytes in BSU/ESU when `sync`.
878fn wrap_bytes(body: Vec<u8>, sync: bool) -> Vec<u8> {
879 if !sync {
880 return body;
881 }
882 let mut out = Vec::with_capacity(body.len() + bsu.len() + esu.len());
883 out.extend_from_slice(bsu.as_bytes());
884 out.extend_from_slice(&body);
885 out.extend_from_slice(esu.as_bytes());
886 out
887}
888
889#[cfg(test)]
890#[path = "frame_tests.rs"]
891mod frame_tests;