Skip to main content

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 -> `""`. 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;