Skip to main content

ftui_runtime/
terminal_writer.rs

1#![forbid(unsafe_code)]
2
3//! Terminal output coordinator with inline mode support.
4//!
5//! The `TerminalWriter` is the component that makes inline mode work. It:
6//! - Serializes log writes and UI presents (one-writer rule)
7//! - Implements the cursor save/restore contract
8//! - Manages scroll regions (when optimization enabled)
9//! - Ensures single buffered write per operation
10//!
11//! # Screen Modes
12//!
13//! - **Inline Mode**: Preserves terminal scrollback. UI is rendered at the
14//!   bottom, logs scroll normally above. Uses cursor save/restore.
15//!
16//! - **AltScreen Mode**: Uses alternate screen buffer. Full-screen UI,
17//!   no scrollback preservation.
18//!
19//! # Inline Mode Contract
20//!
21//! 1. Cursor is saved before any UI operation
22//! 2. UI region is cleared and redrawn
23//! 3. Cursor is restored after UI operation
24//! 4. Log writes go above the UI region: with an active scroll region they
25//!    accumulate into scrollback; in the overlay fallback exactly one
26//!    width-clamped line is rewritten per call (an unqualified LF would
27//!    scroll the whole screen and displace the UI)
28//! 5. Terminal state is restored on drop
29//!
30//! # Usage
31//!
32//! ```ignore
33//! use ftui_runtime::{TerminalWriter, ScreenMode, UiAnchor};
34//! use ftui_render::buffer::Buffer;
35//! use ftui_core::terminal_capabilities::TerminalCapabilities;
36//!
37//! // Create writer for inline mode with 10-row UI
38//! let mut writer = TerminalWriter::new(
39//!     std::io::stdout(),
40//!     ScreenMode::Inline { ui_height: 10 },
41//!     UiAnchor::Bottom,
42//!     TerminalCapabilities::detect(),
43//! );
44//!
45//! // Write logs (goes to scrollback above UI)
46//! writer.write_log("Starting...\n")?;
47//!
48//! // Present UI
49//! let buffer = Buffer::new(80, 10);
50//! writer.present_ui(&buffer, None, true)?;
51//! ```
52
53use std::io::{self, BufWriter, Write};
54use std::sync::atomic::{AtomicU32, Ordering};
55use web_time::Instant;
56
57/// Global gauge: number of active inline-mode `TerminalWriter` instances.
58///
59/// Incremented when a writer is created in `Inline` or `InlineAuto` mode,
60/// decremented on drop. Read with [`inline_active_widgets`].
61static INLINE_ACTIVE_WIDGETS: AtomicU32 = AtomicU32::new(0);
62
63/// Read the current number of active inline-mode terminal writers.
64pub fn inline_active_widgets() -> u32 {
65    INLINE_ACTIVE_WIDGETS.load(Ordering::Relaxed)
66}
67
68use crate::evidence_sink::EvidenceSink;
69use crate::evidence_telemetry::{DiffDecisionSnapshot, set_diff_snapshot};
70use crate::render_trace::{
71    RenderTraceFrame, RenderTraceRecorder, build_diff_runs_payload, build_full_buffer_payload,
72};
73use ftui_core::inline_mode::{InlineStrategy, sanitize_overlay_log_line};
74#[cfg(test)]
75use ftui_core::terminal_capabilities::ColorDepth;
76use ftui_core::terminal_capabilities::TerminalCapabilities;
77use ftui_core::terminal_session::terminal_output_lock;
78use ftui_render::buffer::{Buffer, DirtySpanConfig, DirtySpanStats};
79use ftui_render::counting_writer::CountingWriter;
80use ftui_render::diff::{BufferDiff, TileDiffConfig, TileDiffFallback, TileDiffStats};
81use ftui_render::diff_strategy::{DiffStrategy, DiffStrategyConfig, DiffStrategySelector};
82use ftui_render::grapheme_pool::GraphemePool;
83use ftui_render::link_registry::LinkRegistry;
84use ftui_render::presenter::Presenter;
85use ftui_render::render_certificate::{
86    RenderCertificate, RenderCertificateInputs, RenderCertificateLevel, evaluate_render_certificate,
87};
88use ftui_render::sanitize::sanitize;
89use tracing::{debug_span, info, info_span, trace, warn};
90
91/// Size of the internal write buffer (64KB).
92#[allow(dead_code)] // Used by Presenter::new; kept here for reference.
93const BUFFER_CAPACITY: usize = 64 * 1024;
94
95/// DEC cursor save (ESC 7) - more portable than CSI s.
96const CURSOR_SAVE: &[u8] = b"\x1b7";
97
98/// DEC cursor restore (ESC 8) - more portable than CSI u.
99const CURSOR_RESTORE: &[u8] = b"\x1b8";
100
101/// Synchronized output begin (DEC 2026).
102const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
103
104/// Synchronized output end (DEC 2026).
105const SYNC_END: &[u8] = b"\x1b[?2026l";
106
107/// Erase entire line (CSI 2 K).
108const ERASE_LINE: &[u8] = b"\x1b[2K";
109/// Reset background to terminal default (CSI 49 m).
110const SGR_BG_DEFAULT: &[u8] = b"\x1b[49m";
111
112/// How often to probe with a real diff when FullRedraw is selected.
113#[allow(dead_code)] // API for future diff strategy integration
114const FULL_REDRAW_PROBE_INTERVAL: u64 = 60;
115
116// CountingWriter is re-used from ftui_render::counting_writer::CountingWriter.
117// The Presenter wraps the writer in CountingWriter<BufWriter<W>>.
118// For byte counting, use reset_counter() and bytes_written() on the counting writer.
119
120fn default_diff_run_id() -> String {
121    format!("diff-{}", std::process::id())
122}
123
124fn diff_strategy_str(strategy: DiffStrategy) -> &'static str {
125    match strategy {
126        DiffStrategy::Full => "full",
127        DiffStrategy::DirtyRows => "dirty",
128        DiffStrategy::FullRedraw => "redraw",
129    }
130}
131
132fn inline_strategy_str(strategy: InlineStrategy) -> &'static str {
133    match strategy {
134        InlineStrategy::ScrollRegion => "scroll_region",
135        InlineStrategy::OverlayRedraw => "overlay_redraw",
136        InlineStrategy::Hybrid => "hybrid",
137    }
138}
139
140fn ui_anchor_str(anchor: UiAnchor) -> &'static str {
141    match anchor {
142        UiAnchor::Bottom => "bottom",
143        UiAnchor::Top => "top",
144    }
145}
146
147#[allow(dead_code)]
148#[inline]
149fn json_escape(value: &str) -> String {
150    let mut out = String::with_capacity(value.len());
151    for ch in value.chars() {
152        match ch {
153            '"' => out.push_str("\\\""),
154            '\\' => out.push_str("\\\\"),
155            '\n' => out.push_str("\\n"),
156            '\r' => out.push_str("\\r"),
157            '\t' => out.push_str("\\t"),
158            c if c.is_control() => {
159                use std::fmt::Write as _;
160                let _ = write!(out, "\\u{:04X}", c as u32);
161            }
162            _ => out.push(ch),
163        }
164    }
165    out
166}
167
168#[allow(dead_code)]
169fn estimate_diff_scan_cost(
170    strategy: DiffStrategy,
171    dirty_rows: usize,
172    width: usize,
173    height: usize,
174    span_stats: &DirtySpanStats,
175    tile_stats: Option<TileDiffStats>,
176) -> (usize, &'static str) {
177    match strategy {
178        DiffStrategy::Full => (width.saturating_mul(height), "full_strategy"),
179        DiffStrategy::FullRedraw => (0, "full_redraw"),
180        DiffStrategy::DirtyRows => {
181            if dirty_rows == 0 {
182                return (0, "no_dirty_rows");
183            }
184            if let Some(tile_stats) = tile_stats
185                && tile_stats.fallback.is_none()
186            {
187                return (tile_stats.scan_cells_estimate, "tile_skip");
188            }
189            let span_cells = span_stats.span_coverage_cells;
190            if span_stats.overflows > 0 {
191                let estimate = if span_cells > 0 {
192                    span_cells
193                } else {
194                    dirty_rows.saturating_mul(width)
195                };
196                return (estimate, "span_overflow");
197            }
198            if span_cells > 0 {
199                (span_cells, "none")
200            } else {
201                (dirty_rows.saturating_mul(width), "no_spans")
202            }
203        }
204    }
205}
206
207fn sanitize_auto_bounds(min_height: u16, max_height: u16) -> (u16, u16) {
208    let min = min_height.max(1);
209    let max = max_height.max(min);
210    (min, max)
211}
212
213/// Screen mode determines whether we use alternate screen or inline mode.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub enum ScreenMode {
216    /// Inline mode preserves scrollback. UI is anchored at bottom/top.
217    Inline {
218        /// Height of the UI region in rows.
219        ui_height: u16,
220    },
221    /// Inline mode with automatic UI height based on rendered content.
222    ///
223    /// The measured height is clamped between `min_height` and `max_height`.
224    InlineAuto {
225        /// Minimum UI height in rows.
226        min_height: u16,
227        /// Maximum UI height in rows.
228        max_height: u16,
229    },
230    /// Alternate screen mode for full-screen applications.
231    #[default]
232    AltScreen,
233}
234
235/// Where the UI region is anchored in inline mode.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub enum UiAnchor {
238    /// UI at bottom of terminal (default for agent harness).
239    #[default]
240    Bottom,
241    /// UI at top of terminal.
242    Top,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246struct InlineRegion {
247    start: u16,
248    height: u16,
249}
250
251struct DiffDecision {
252    #[allow(dead_code)] // reserved for future diff strategy introspection
253    strategy: DiffStrategy,
254    has_diff: bool,
255}
256
257#[derive(Debug, Clone, Copy)]
258#[allow(dead_code)]
259struct EmitStats {
260    diff_cells: usize,
261    diff_runs: usize,
262}
263
264#[derive(Debug, Clone, Copy)]
265#[allow(dead_code)]
266struct FrameEmitStats {
267    diff_strategy: DiffStrategy,
268    diff_cells: usize,
269    diff_runs: usize,
270    ui_height: u16,
271}
272
273#[derive(Debug, Clone, Copy)]
274#[allow(dead_code)]
275pub struct PresentTimings {
276    pub diff_us: u64,
277}
278
279// =============================================================================
280// Runtime Diff Configuration
281// =============================================================================
282
283/// Runtime-level configuration for diff strategy selection.
284///
285/// This wraps [`DiffStrategyConfig`] and adds runtime-specific toggles
286/// for enabling/disabling features and controlling reset policies.
287///
288/// # Example
289///
290/// ```
291/// use ftui_runtime::{RuntimeDiffConfig, DiffStrategyConfig};
292///
293/// // Use defaults (Bayesian selection enabled, dirty-rows enabled)
294/// let config = RuntimeDiffConfig::default();
295///
296/// // Disable Bayesian selection (always use dirty-rows if available)
297/// let config = RuntimeDiffConfig::default()
298///     .with_bayesian_enabled(false);
299///
300/// // Custom cost model
301/// let config = RuntimeDiffConfig::default()
302///     .with_strategy_config(DiffStrategyConfig {
303///         c_emit: 10.0,  // Higher I/O cost
304///         ..Default::default()
305///     });
306/// ```
307#[derive(Debug, Clone)]
308pub struct RuntimeDiffConfig {
309    /// Enable Bayesian strategy selection.
310    ///
311    /// When enabled, the selector uses a Beta posterior over the change rate
312    /// to choose between Full, DirtyRows, and FullRedraw strategies.
313    ///
314    /// When disabled, always uses DirtyRows if dirty tracking is available,
315    /// otherwise Full.
316    ///
317    /// Default: true
318    pub bayesian_enabled: bool,
319
320    /// Enable dirty-row optimization.
321    ///
322    /// When enabled, the DirtyRows strategy is available for selection.
323    /// When disabled, the selector chooses between Full and FullRedraw only.
324    ///
325    /// Default: true
326    pub dirty_rows_enabled: bool,
327
328    /// Emit explicit render certificates and route the DirtyRows strategy
329    /// through the certified diff path (skip-all on zero dirty rows,
330    /// narrow-to-dirty otherwise). Behavior-identical to the uncertified
331    /// dirty path; disabling only removes the certificate evidence and the
332    /// zero-dirty fast path.
333    ///
334    /// Default: true
335    pub certified_skips: bool,
336
337    /// Dirty-span tracking configuration (thresholds + feature flags).
338    ///
339    /// Controls span merging, guard bands, and enable/disable behavior.
340    pub dirty_span_config: DirtySpanConfig,
341
342    /// Tile-based diff skipping configuration (thresholds + feature flags).
343    ///
344    /// Controls SAT tile size, thresholds, and enable/disable behavior.
345    pub tile_diff_config: TileDiffConfig,
346
347    /// Reset posterior on dimension change.
348    ///
349    /// When true, the Bayesian posterior resets to priors when the buffer
350    /// dimensions change (e.g., terminal resize).
351    ///
352    /// Default: true
353    pub reset_on_resize: bool,
354
355    /// Reset posterior on buffer invalidation.
356    ///
357    /// When true, resets to priors when the previous buffer becomes invalid
358    /// (e.g., mode switch, scroll region change).
359    ///
360    /// Default: true
361    pub reset_on_invalidation: bool,
362
363    /// Underlying strategy configuration.
364    ///
365    /// Contains cost model constants, prior parameters, and decay settings.
366    pub strategy_config: DiffStrategyConfig,
367
368    /// Maximum successful incremental frames to allow between physical full redraws.
369    ///
370    /// Terminals and mux panes can lose their visible buffer without notifying the
371    /// process. A bounded full redraw interval repairs that state divergence even
372    /// when the application model has not changed. Set to `0` to disable.
373    ///
374    /// Default: 240
375    pub full_redraw_interval_frames: u64,
376
377    /// Maximum *wall-clock* time to allow between physical full redraws.
378    ///
379    /// The frame-count interval ([`Self::full_redraw_interval_frames`]) only
380    /// advances on rendered frames, so a TUI that renders sparsely (dirty-driven,
381    /// idle, or rendering slowly) can leave a desynchronized physical terminal —
382    /// e.g. corruption from an aggressive incremental diff, or a mux pane swap /
383    /// reattach — visible for a long time. A wall-clock bound guarantees the
384    /// physical terminal is fully repainted at least this often regardless of
385    /// render cadence, so any such corruption self-heals within the interval.
386    ///
387    /// `None` (the default) disables the time-based resync, preserving the
388    /// purely frame-count behavior (and deterministic-test reproducibility).
389    pub full_redraw_max_interval: Option<std::time::Duration>,
390}
391
392impl Default for RuntimeDiffConfig {
393    fn default() -> Self {
394        Self {
395            bayesian_enabled: true,
396            dirty_rows_enabled: true,
397            certified_skips: true,
398            dirty_span_config: DirtySpanConfig::default(),
399            tile_diff_config: TileDiffConfig::default(),
400            reset_on_resize: true,
401            reset_on_invalidation: true,
402            strategy_config: DiffStrategyConfig::default(),
403            full_redraw_interval_frames: 240,
404            full_redraw_max_interval: None,
405        }
406    }
407}
408
409impl RuntimeDiffConfig {
410    /// Create a new config with all defaults.
411    pub fn new() -> Self {
412        Self::default()
413    }
414
415    /// Set whether Bayesian strategy selection is enabled.
416    #[must_use]
417    pub fn with_bayesian_enabled(mut self, enabled: bool) -> Self {
418        self.bayesian_enabled = enabled;
419        self
420    }
421
422    /// Set whether dirty-row optimization is enabled.
423    #[must_use]
424    pub fn with_dirty_rows_enabled(mut self, enabled: bool) -> Self {
425        self.dirty_rows_enabled = enabled;
426        self
427    }
428
429    /// Set whether dirty-span tracking is enabled.
430    #[must_use]
431    pub fn with_dirty_spans_enabled(mut self, enabled: bool) -> Self {
432        self.dirty_span_config = self.dirty_span_config.with_enabled(enabled);
433        self
434    }
435
436    /// Set the dirty-span tracking configuration.
437    #[must_use]
438    pub fn with_dirty_span_config(mut self, config: DirtySpanConfig) -> Self {
439        self.dirty_span_config = config;
440        self
441    }
442
443    /// Toggle tile-based skipping.
444    #[must_use]
445    pub fn with_tile_skip_enabled(mut self, enabled: bool) -> Self {
446        self.tile_diff_config = self.tile_diff_config.with_enabled(enabled);
447        self
448    }
449
450    /// Set the tile-based diff configuration.
451    #[must_use]
452    pub fn with_tile_diff_config(mut self, config: TileDiffConfig) -> Self {
453        self.tile_diff_config = config;
454        self
455    }
456
457    /// Set whether to reset posterior on resize.
458    #[must_use]
459    pub fn with_reset_on_resize(mut self, enabled: bool) -> Self {
460        self.reset_on_resize = enabled;
461        self
462    }
463
464    /// Set whether to reset posterior on invalidation.
465    #[must_use]
466    pub fn with_reset_on_invalidation(mut self, enabled: bool) -> Self {
467        self.reset_on_invalidation = enabled;
468        self
469    }
470
471    /// Set the underlying strategy configuration.
472    #[must_use]
473    pub fn with_strategy_config(mut self, config: DiffStrategyConfig) -> Self {
474        self.strategy_config = config;
475        self
476    }
477
478    /// Set the maximum successful incremental frames between physical full redraws.
479    ///
480    /// A value of `0` disables periodic terminal resynchronization.
481    #[must_use]
482    pub fn with_full_redraw_interval_frames(mut self, frames: u64) -> Self {
483        self.full_redraw_interval_frames = frames;
484        self
485    }
486
487    /// Set the maximum wall-clock time between physical full redraws.
488    ///
489    /// Unlike [`Self::with_full_redraw_interval_frames`] (which only advances on
490    /// rendered frames), this bounds resynchronization by elapsed time, so an
491    /// idle or sparsely-rendering TUI still repaints the physical terminal at
492    /// least this often — bounding how long any terminal-state desync (diff
493    /// corruption, mux pane swap, reattach) can stay on screen. `None` disables
494    /// it.
495    #[must_use]
496    pub fn with_full_redraw_max_interval(mut self, interval: Option<std::time::Duration>) -> Self {
497        self.full_redraw_max_interval = interval;
498        self
499    }
500}
501
502/// Unified terminal output coordinator.
503///
504/// Enforces the one-writer rule and implements inline mode correctly.
505/// All terminal output should go through this struct.
506pub struct TerminalWriter<W: Write> {
507    /// Presenter handles efficient ANSI emission and cursor tracking.
508    /// Wrapped in `Option` so `into_inner` can take ownership; `Drop` skips
509    /// cleanup when `None` (already consumed).
510    presenter: Option<Presenter<W>>,
511    /// Current screen mode.
512    screen_mode: ScreenMode,
513    /// Last computed auto UI height (inline auto mode only).
514    auto_ui_height: Option<u16>,
515    /// Where UI is anchored in inline mode.
516    ui_anchor: UiAnchor,
517    /// Previous buffer for diffing.
518    prev_buffer: Option<Buffer>,
519    /// Spare buffer for reuse as the next render target.
520    spare_buffer: Option<Buffer>,
521    /// Pre-allocated buffer for zero-alloc clone in present_ui.
522    /// Part of a 3-buffer rotation: spare ← prev ← clone_buf ← spare.
523    clone_buf: Option<Buffer>,
524    /// Grapheme pool for complex characters.
525    pool: GraphemePool,
526    /// Link registry for hyperlinks.
527    links: LinkRegistry,
528    /// Terminal capabilities.
529    capabilities: TerminalCapabilities,
530    /// Terminal width in columns.
531    term_width: u16,
532    /// Terminal height in rows.
533    term_height: u16,
534    /// Whether we're in the middle of a sync block.
535    in_sync_block: bool,
536    /// Whether cursor has been saved.
537    cursor_saved: bool,
538    /// Current cursor visibility state (best-effort).
539    cursor_visible: bool,
540    /// Inline mode rendering strategy (selected from capabilities).
541    inline_strategy: InlineStrategy,
542    /// Whether a scroll region is currently active.
543    scroll_region_active: bool,
544    /// Last inline UI region for clearing on shrink.
545    last_inline_region: Option<InlineRegion>,
546    /// Bayesian diff strategy selector.
547    diff_strategy: DiffStrategySelector,
548    /// Reusable diff buffer to avoid per-frame allocations.
549    diff_scratch: BufferDiff,
550    /// Frames since last diff probe while in FullRedraw.
551    full_redraw_probe: u64,
552    /// Successful incremental frames since the terminal was physically redrawn.
553    frames_since_full_redraw: u64,
554    /// Wall-clock instant of the last physical full redraw, used to bound
555    /// terminal-state desync by elapsed time (see
556    /// [`RuntimeDiffConfig::full_redraw_max_interval`]). `None` until the first
557    /// full redraw is presented.
558    last_full_redraw_at: Option<Instant>,
559    /// Runtime diff configuration.
560    #[allow(dead_code)] // runtime toggles wired up in follow-up work
561    diff_config: RuntimeDiffConfig,
562    /// Evidence JSONL sink for diff decisions.
563    evidence_sink: Option<EvidenceSink>,
564    /// Run identifier for diff decision evidence.
565    #[allow(dead_code)]
566    /// The explicit skip certificate for the most recent diff decision.
567    last_certificate: Option<RenderCertificate>,
568    diff_evidence_run_id: String,
569    /// Monotonic event index for diff decision evidence.
570    #[allow(dead_code)]
571    diff_evidence_idx: u64,
572    /// Last diff strategy selected during present.
573    last_diff_strategy: Option<DiffStrategy>,
574    /// Render-trace recorder (optional).
575    render_trace: Option<RenderTraceRecorder>,
576    /// Whether per-frame timing capture is enabled.
577    timing_enabled: bool,
578    /// Last present timings (diff compute duration).
579    last_present_timings: Option<PresentTimings>,
580}
581
582impl<W: Write> TerminalWriter<W> {
583    /// Create a new terminal writer.
584    ///
585    /// # Arguments
586    ///
587    /// * `writer` - Output destination (takes ownership for one-writer rule)
588    /// * `screen_mode` - Inline or alternate screen mode
589    /// * `ui_anchor` - Where to anchor UI in inline mode
590    /// * `capabilities` - Terminal capabilities
591    pub fn new(
592        writer: W,
593        screen_mode: ScreenMode,
594        ui_anchor: UiAnchor,
595        capabilities: TerminalCapabilities,
596    ) -> Self {
597        Self::with_diff_config(
598            writer,
599            screen_mode,
600            ui_anchor,
601            capabilities,
602            RuntimeDiffConfig::default(),
603        )
604    }
605
606    /// Create a new terminal writer with custom diff strategy configuration.
607    ///
608    /// # Arguments
609    ///
610    /// * `writer` - Output destination (takes ownership for one-writer rule)
611    /// * `screen_mode` - Inline or alternate screen mode
612    /// * `ui_anchor` - Where to anchor UI in inline mode
613    /// * `capabilities` - Terminal capabilities
614    /// * `diff_config` - Configuration for diff strategy selection
615    ///
616    /// # Example
617    ///
618    /// ```ignore
619    /// use ftui_runtime::{TerminalWriter, ScreenMode, UiAnchor, RuntimeDiffConfig};
620    /// use ftui_core::terminal_capabilities::TerminalCapabilities;
621    ///
622    /// // Disable Bayesian selection for deterministic diffing
623    /// let config = RuntimeDiffConfig::default()
624    ///     .with_bayesian_enabled(false);
625    ///
626    /// let writer = TerminalWriter::with_diff_config(
627    ///     std::io::stdout(),
628    ///     ScreenMode::AltScreen,
629    ///     UiAnchor::Bottom,
630    ///     TerminalCapabilities::detect(),
631    ///     config,
632    /// );
633    /// ```
634    pub fn with_diff_config(
635        writer: W,
636        screen_mode: ScreenMode,
637        ui_anchor: UiAnchor,
638        capabilities: TerminalCapabilities,
639        diff_config: RuntimeDiffConfig,
640    ) -> Self {
641        let inline_strategy = InlineStrategy::select(&capabilities);
642        let auto_ui_height = None;
643        let diff_strategy = DiffStrategySelector::new(diff_config.strategy_config.clone());
644
645        // Increment the inline-active gauge.
646        // We do this BEFORE potentially returning/panicking to maintain invariant
647        // that a TerminalWriter in inline mode ALWAYS has a corresponding increment,
648        // which will be decremented on Drop.
649        let is_inline = matches!(
650            screen_mode,
651            ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
652        );
653        if is_inline {
654            INLINE_ACTIVE_WIDGETS.fetch_add(1, Ordering::SeqCst);
655        }
656
657        // Log inline mode activation.
658        match screen_mode {
659            ScreenMode::Inline { ui_height } => {
660                info!(
661                    inline_height = ui_height,
662                    render_mode = %inline_strategy_str(inline_strategy),
663                    "inline mode activated"
664                );
665            }
666            ScreenMode::InlineAuto {
667                min_height,
668                max_height,
669            } => {
670                info!(
671                    min_height,
672                    max_height,
673                    render_mode = %inline_strategy_str(inline_strategy),
674                    "inline auto mode activated"
675                );
676            }
677            ScreenMode::AltScreen => {}
678        }
679
680        let mut diff_scratch = BufferDiff::new();
681        diff_scratch
682            .tile_config_mut()
683            .clone_from(&diff_config.tile_diff_config);
684
685        let presenter = Presenter::new(writer, capabilities);
686
687        Self {
688            presenter: Some(presenter),
689            screen_mode,
690            auto_ui_height,
691            ui_anchor,
692            prev_buffer: None,
693            spare_buffer: None,
694            clone_buf: None,
695            pool: GraphemePool::new(),
696            links: LinkRegistry::new(),
697            capabilities,
698            term_width: 80,
699            term_height: 24,
700            in_sync_block: false,
701            cursor_saved: false,
702            cursor_visible: true,
703            inline_strategy,
704            scroll_region_active: false,
705            last_inline_region: None,
706            diff_strategy,
707            diff_scratch,
708            full_redraw_probe: 0,
709            frames_since_full_redraw: 0,
710            last_full_redraw_at: None,
711            diff_config,
712            evidence_sink: None,
713            diff_evidence_run_id: default_diff_run_id(),
714            diff_evidence_idx: 0,
715            last_diff_strategy: None,
716            last_certificate: None,
717            render_trace: None,
718            timing_enabled: false,
719            last_present_timings: None,
720        }
721    }
722
723    /// Get a mutable reference to the internal counting writer.
724    ///
725    /// # Panics
726    ///
727    /// Panics if the presenter has been taken (via `into_inner`).
728    #[inline]
729    fn writer(&mut self) -> &mut CountingWriter<BufWriter<W>> {
730        self.presenter_mut().counting_writer_mut()
731    }
732
733    /// Get a mutable reference to the presenter.
734    ///
735    /// # Panics
736    ///
737    /// Panics if the presenter has been taken (via `into_inner`).
738    #[inline]
739    fn presenter_mut(&mut self) -> &mut Presenter<W> {
740        self.presenter
741            .as_mut()
742            .expect("presenter has been consumed")
743    }
744
745    /// Reset diff strategy state when the previous buffer is invalidated.
746    fn reset_diff_strategy(&mut self) {
747        if self.diff_config.reset_on_invalidation {
748            self.diff_strategy.reset();
749        }
750        self.full_redraw_probe = 0;
751        self.frames_since_full_redraw = 0;
752        self.last_diff_strategy = None;
753    }
754
755    /// Reset diff strategy state on terminal resize.
756    #[allow(dead_code)] // used by upcoming resize-aware diff strategy work
757    fn reset_diff_on_resize(&mut self) {
758        if self.diff_config.reset_on_resize {
759            self.diff_strategy.reset();
760        }
761        self.full_redraw_probe = 0;
762        self.frames_since_full_redraw = 0;
763        self.last_diff_strategy = None;
764    }
765
766    /// Get the current diff configuration.
767    pub fn diff_config(&self) -> &RuntimeDiffConfig {
768        &self.diff_config
769    }
770
771    /// Enable or disable per-frame timing capture.
772    pub(crate) fn set_timing_enabled(&mut self, enabled: bool) {
773        self.timing_enabled = enabled;
774        if !enabled {
775            self.last_present_timings = None;
776        }
777    }
778
779    /// Take the last present timings (if available).
780    pub(crate) fn take_last_present_timings(&mut self) -> Option<PresentTimings> {
781        self.last_present_timings.take()
782    }
783
784    /// Attach an evidence sink for diff decision logging.
785    #[must_use]
786    pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
787        self.evidence_sink = Some(sink);
788        self
789    }
790
791    /// Set the evidence JSONL sink for diff decision logging.
792    pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
793        self.evidence_sink = sink;
794    }
795
796    /// Attach a render-trace recorder.
797    #[must_use]
798    pub fn with_render_trace(mut self, recorder: RenderTraceRecorder) -> Self {
799        self.render_trace = Some(recorder);
800        self
801    }
802
803    /// Set the render-trace recorder.
804    pub fn set_render_trace(&mut self, recorder: Option<RenderTraceRecorder>) {
805        self.render_trace = recorder;
806    }
807
808    /// Get mutable access to the diff strategy selector.
809    ///
810    /// Useful for advanced scenarios like manual posterior updates.
811    pub fn diff_strategy_mut(&mut self) -> &mut DiffStrategySelector {
812        &mut self.diff_strategy
813    }
814
815    /// Get the diff strategy selector (read-only).
816    pub fn diff_strategy(&self) -> &DiffStrategySelector {
817        &self.diff_strategy
818    }
819
820    /// Get the last diff strategy selected during present, if any.
821    pub fn last_diff_strategy(&self) -> Option<DiffStrategy> {
822        self.last_diff_strategy
823    }
824
825    /// The explicit render certificate behind the most recent diff decision
826    /// (`None` before the first present or when certified skips are
827    /// disabled).
828    #[must_use]
829    pub fn last_render_certificate(&self) -> Option<&RenderCertificate> {
830        self.last_certificate.as_ref()
831    }
832
833    /// Set the terminal size.
834    ///
835    /// Call this when the terminal is resized.
836    pub fn set_size(&mut self, width: u16, height: u16) {
837        // May emit a DECSTBM reset when a scroll region is active; keep it
838        // inside the one-writer discipline (bd-kdn7n item 2).
839        let _output_guard = terminal_output_lock();
840        self.term_width = width;
841        self.term_height = height;
842        if matches!(self.screen_mode, ScreenMode::InlineAuto { .. }) {
843            self.auto_ui_height = None;
844        }
845        // Clear prev_buffer to force full redraw after resize
846        self.prev_buffer = None;
847        // Old-geometry coordinates are meaningless after a resize: drop the
848        // displayed-region cache so neither log positioning nor
849        // clear_inline_region_diff acts on rows that no longer exist.
850        self.last_inline_region = None;
851        self.spare_buffer = None;
852        self.clone_buf = None;
853        self.reset_diff_on_resize();
854        // Reset scroll region on resize; it will be re-established on next present
855        if self.scroll_region_active {
856            let _ = self.deactivate_scroll_region();
857        }
858    }
859
860    /// Take a reusable render buffer sized for the current frame.
861    ///
862    /// Uses a spare buffer when available to avoid per-frame allocation.
863    pub fn take_render_buffer(&mut self, width: u16, height: u16) -> Buffer {
864        if let Some(mut buffer) = self.spare_buffer.take()
865            && buffer.width() == width
866            && buffer.height() == height
867        {
868            buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
869            buffer.reset_for_frame();
870            return buffer;
871        }
872
873        let mut buffer = Buffer::new(width, height);
874        buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
875        buffer
876    }
877
878    /// Get the current terminal width.
879    #[inline]
880    pub fn width(&self) -> u16 {
881        self.term_width
882    }
883
884    /// Get the current terminal height.
885    #[inline]
886    pub fn height(&self) -> u16 {
887        self.term_height
888    }
889
890    /// Get the current screen mode.
891    #[inline]
892    pub fn screen_mode(&self) -> ScreenMode {
893        self.screen_mode
894    }
895
896    /// Height to use for rendering a frame.
897    ///
898    /// In inline auto mode, this returns the configured maximum (clamped to
899    /// terminal height) so measurement can determine actual UI height.
900    pub fn render_height_hint(&self) -> u16 {
901        match self.screen_mode {
902            ScreenMode::Inline { ui_height } => ui_height,
903            ScreenMode::InlineAuto {
904                min_height,
905                max_height,
906            } => {
907                let (min, max) = sanitize_auto_bounds(min_height, max_height);
908                let max = max.min(self.term_height);
909                let min = min.min(max);
910                if let Some(current) = self.auto_ui_height {
911                    current.clamp(min, max).min(self.term_height).max(min)
912                } else {
913                    max.max(min)
914                }
915            }
916            ScreenMode::AltScreen => self.term_height,
917        }
918    }
919
920    /// Get sanitized min/max bounds for inline auto mode (clamped to terminal height).
921    pub fn inline_auto_bounds(&self) -> Option<(u16, u16)> {
922        match self.screen_mode {
923            ScreenMode::InlineAuto {
924                min_height,
925                max_height,
926            } => {
927                let (min, max) = sanitize_auto_bounds(min_height, max_height);
928                Some((min.min(self.term_height), max.min(self.term_height)))
929            }
930            _ => None,
931        }
932    }
933
934    /// Get the cached auto UI height (inline auto mode only).
935    pub fn auto_ui_height(&self) -> Option<u16> {
936        match self.screen_mode {
937            ScreenMode::InlineAuto { .. } => self.auto_ui_height,
938            _ => None,
939        }
940    }
941
942    /// Update the computed height for inline auto mode.
943    pub fn set_auto_ui_height(&mut self, height: u16) {
944        if let ScreenMode::InlineAuto {
945            min_height,
946            max_height,
947        } = self.screen_mode
948        {
949            let (min, max) = sanitize_auto_bounds(min_height, max_height);
950            let max = max.min(self.term_height);
951            let min = min.min(max);
952            let clamped = height.clamp(min, max);
953            let previous_effective = self.auto_ui_height.unwrap_or(min);
954            if self.auto_ui_height != Some(clamped) {
955                self.auto_ui_height = Some(clamped);
956                if clamped != previous_effective {
957                    self.prev_buffer = None;
958                    self.reset_diff_strategy();
959                    if self.scroll_region_active {
960                        let _ = self.deactivate_scroll_region();
961                    }
962                }
963            }
964        }
965    }
966
967    /// Clear the cached auto UI height (inline auto mode only).
968    pub fn clear_auto_ui_height(&mut self) {
969        if matches!(self.screen_mode, ScreenMode::InlineAuto { .. })
970            && self.auto_ui_height.is_some()
971        {
972            self.auto_ui_height = None;
973            self.prev_buffer = None;
974            self.reset_diff_strategy();
975            if self.scroll_region_active {
976                let _ = self.deactivate_scroll_region();
977            }
978        }
979    }
980
981    fn effective_ui_height(&self) -> u16 {
982        match self.screen_mode {
983            ScreenMode::Inline { ui_height } => ui_height,
984            ScreenMode::InlineAuto {
985                min_height,
986                max_height,
987            } => {
988                let (min, max) = sanitize_auto_bounds(min_height, max_height);
989                let current = self.auto_ui_height.unwrap_or(min);
990                current.clamp(min, max).min(self.term_height)
991            }
992            ScreenMode::AltScreen => self.term_height,
993        }
994    }
995
996    /// Get the UI height for the current mode.
997    pub fn ui_height(&self) -> u16 {
998        self.effective_ui_height()
999    }
1000
1001    /// Calculate the row where the UI starts (0-indexed).
1002    fn ui_start_row(&self) -> u16 {
1003        let ui_height = self.effective_ui_height().min(self.term_height);
1004        match (self.screen_mode, self.ui_anchor) {
1005            (ScreenMode::Inline { .. }, UiAnchor::Bottom)
1006            | (ScreenMode::InlineAuto { .. }, UiAnchor::Bottom) => {
1007                self.term_height.saturating_sub(ui_height)
1008            }
1009            (ScreenMode::Inline { .. }, UiAnchor::Top)
1010            | (ScreenMode::InlineAuto { .. }, UiAnchor::Top) => 0,
1011            (ScreenMode::AltScreen, _) => 0,
1012        }
1013    }
1014
1015    /// Get the inline mode rendering strategy.
1016    pub fn inline_strategy(&self) -> InlineStrategy {
1017        self.inline_strategy
1018    }
1019
1020    /// Check if a scroll region is currently active.
1021    pub fn scroll_region_active(&self) -> bool {
1022        self.scroll_region_active
1023    }
1024
1025    /// Activate the scroll region for inline mode.
1026    ///
1027    /// Sets DECSTBM to constrain scrolling to the log region:
1028    /// - Bottom-anchored UI: log region is above the UI.
1029    /// - Top-anchored UI: log region is below the UI.
1030    ///
1031    /// Only called when the strategy permits scroll-region usage.
1032    fn activate_scroll_region(&mut self, ui_height: u16) -> io::Result<()> {
1033        if self.scroll_region_active {
1034            return Ok(());
1035        }
1036
1037        let ui_height = ui_height.min(self.term_height);
1038        if ui_height >= self.term_height {
1039            return Ok(());
1040        }
1041
1042        match self.ui_anchor {
1043            UiAnchor::Bottom => {
1044                let term_height = self.term_height;
1045                let log_bottom = term_height.saturating_sub(ui_height);
1046                if log_bottom > 0 {
1047                    // DECSTBM: set scroll region to rows 1..log_bottom (1-indexed)
1048                    write!(self.writer(), "\x1b[1;{}r", log_bottom)?;
1049                    self.scroll_region_active = true;
1050                }
1051            }
1052            UiAnchor::Top => {
1053                let term_height = self.term_height;
1054                let log_top = ui_height.saturating_add(1);
1055                if log_top <= term_height {
1056                    // DECSTBM: set scroll region to rows log_top..term_height (1-indexed)
1057                    write!(self.writer(), "\x1b[{};{}r", log_top, term_height)?;
1058                    self.scroll_region_active = true;
1059                    // DECSTBM moves cursor to home; for top-anchored UI we move it
1060                    // into the log region so any subsequent output stays below UI.
1061                    write!(self.writer(), "\x1b[{};1H", log_top)?;
1062                }
1063            }
1064        }
1065        Ok(())
1066    }
1067
1068    /// Deactivate the scroll region, resetting to full screen.
1069    fn deactivate_scroll_region(&mut self) -> io::Result<()> {
1070        if self.scroll_region_active {
1071            self.writer().write_all(b"\x1b[r")?;
1072            self.scroll_region_active = false;
1073        }
1074        Ok(())
1075    }
1076
1077    fn clear_rows(&mut self, start_row: u16, height: u16) -> io::Result<()> {
1078        let start_row = start_row.min(self.term_height);
1079        let end_row = start_row.saturating_add(height).min(self.term_height);
1080        if start_row >= end_row {
1081            return Ok(());
1082        }
1083
1084        // Ensure erase operations clear to the terminal default background.
1085        // Without this, stale background fills can persist when inline regions shrink.
1086        self.writer().write_all(SGR_BG_DEFAULT)?;
1087        for row in start_row..end_row {
1088            write!(self.writer(), "\x1b[{};1H", row.saturating_add(1))?;
1089            self.writer().write_all(ERASE_LINE)?;
1090        }
1091        Ok(())
1092    }
1093
1094    fn clear_inline_region_diff(&mut self, current: InlineRegion) -> io::Result<()> {
1095        let Some(previous) = self.last_inline_region else {
1096            return Ok(());
1097        };
1098
1099        let prev_start = previous.start.min(self.term_height);
1100        let prev_end = previous
1101            .start
1102            .saturating_add(previous.height)
1103            .min(self.term_height);
1104        if prev_start >= prev_end {
1105            return Ok(());
1106        }
1107
1108        let curr_start = current.start.min(self.term_height);
1109        let curr_end = current
1110            .start
1111            .saturating_add(current.height)
1112            .min(self.term_height);
1113
1114        if curr_start > prev_start {
1115            let clear_end = curr_start.min(prev_end);
1116            if clear_end > prev_start {
1117                self.clear_rows(prev_start, clear_end - prev_start)?;
1118            }
1119        }
1120
1121        if curr_end < prev_end {
1122            let clear_start = curr_end.max(prev_start);
1123            if prev_end > clear_start {
1124                self.clear_rows(clear_start, prev_end - clear_start)?;
1125            }
1126        }
1127
1128        Ok(())
1129    }
1130
1131    /// Present a UI frame.
1132    ///
1133    /// In inline mode, this:
1134    /// 1. Begins synchronized output (if supported)
1135    /// 2. Saves cursor position
1136    /// 3. Moves to UI region and clears it
1137    /// 4. Renders the buffer using the presenter
1138    /// 5. Restores cursor position
1139    /// 6. Moves cursor to requested UI position (if any)
1140    /// 7. Applies cursor visibility
1141    /// 8. Ends synchronized output
1142    ///
1143    /// In AltScreen mode, this just renders the buffer and positions cursor.
1144    pub fn present_ui(
1145        &mut self,
1146        buffer: &Buffer,
1147        cursor: Option<(u16, u16)>,
1148        cursor_visible: bool,
1149    ) -> io::Result<()> {
1150        // One-writer discipline vs the signal/panic teardown paths
1151        // (bd-kdn7n item 2): the whole frame is one emission batch.
1152        let _output_guard = terminal_output_lock();
1153        let mode_str = match self.screen_mode {
1154            ScreenMode::Inline { .. } => "inline",
1155            ScreenMode::InlineAuto { .. } => "inline_auto",
1156            ScreenMode::AltScreen => "altscreen",
1157        };
1158        let trace_enabled = self.render_trace.is_some();
1159        if trace_enabled {
1160            self.writer().reset_counter();
1161        }
1162        let present_start = if trace_enabled {
1163            Some(Instant::now())
1164        } else {
1165            None
1166        };
1167        let _span = info_span!(
1168            "ftui.render.present",
1169            mode = mode_str,
1170            width = buffer.width(),
1171            height = buffer.height(),
1172        )
1173        .entered();
1174
1175        let result = match self.screen_mode {
1176            ScreenMode::Inline { ui_height } => {
1177                self.present_inline(buffer, ui_height, cursor, cursor_visible)
1178            }
1179            ScreenMode::InlineAuto { .. } => {
1180                let ui_height = self.effective_ui_height();
1181                self.present_inline(buffer, ui_height, cursor, cursor_visible)
1182            }
1183            ScreenMode::AltScreen => self.present_altscreen(buffer, cursor, cursor_visible),
1184        };
1185
1186        let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1187        let present_bytes = if trace_enabled {
1188            {
1189                let w = self.writer();
1190                let count = w.bytes_written();
1191                w.reset_counter();
1192                Some(count)
1193            }
1194        } else {
1195            None
1196        };
1197        if trace_enabled {
1198            // No-op: ftui_render::CountingWriter always counts; reset happens in take above.
1199        }
1200
1201        if let Ok(stats) = result {
1202            self.record_successful_present(stats.diff_strategy);
1203            // 3-buffer rotation: reuse clone_buf's allocation to avoid per-frame alloc.
1204            // Only advance the diff baseline after a successful present. If a write
1205            // failed partway through, the terminal state is unknown; the error path
1206            // below invalidates the baseline so the next frame physically repaints.
1207            let new_prev = match self.clone_buf.take() {
1208                Some(mut buf)
1209                    if buf.width() == buffer.width() && buf.height() == buffer.height() =>
1210                {
1211                    buf.clone_from(buffer);
1212                    buf
1213                }
1214                _ => buffer.clone(),
1215            };
1216            self.clone_buf = self.spare_buffer.take();
1217            self.spare_buffer = self.prev_buffer.take();
1218            self.prev_buffer = Some(new_prev);
1219
1220            if let Some(ref mut trace) = self.render_trace {
1221                let payload_info = match stats.diff_strategy {
1222                    DiffStrategy::FullRedraw => {
1223                        let payload = build_full_buffer_payload(buffer, &self.pool);
1224                        trace.write_payload(&payload).ok()
1225                    }
1226                    _ => {
1227                        let payload =
1228                            build_diff_runs_payload(buffer, &self.diff_scratch, &self.pool);
1229                        trace.write_payload(&payload).ok()
1230                    }
1231                };
1232                let (payload_kind, payload_path) = match payload_info {
1233                    Some(info) => (info.kind, Some(info.path)),
1234                    None => ("none", None),
1235                };
1236                let payload_path_ref = payload_path.as_deref();
1237                let diff_strategy = diff_strategy_str(stats.diff_strategy);
1238                let ui_anchor = ui_anchor_str(self.ui_anchor);
1239                let frame = RenderTraceFrame {
1240                    cols: buffer.width(),
1241                    rows: buffer.height(),
1242                    mode: mode_str,
1243                    ui_height: stats.ui_height,
1244                    ui_anchor,
1245                    diff_strategy,
1246                    diff_cells: stats.diff_cells,
1247                    diff_runs: stats.diff_runs,
1248                    present_bytes: present_bytes.unwrap_or(0),
1249                    render_us: None,
1250                    present_us,
1251                    payload_kind,
1252                    payload_path: payload_path_ref,
1253                    trace_us: None,
1254                };
1255                let _ = trace.record_frame(frame, buffer, &self.pool);
1256            }
1257            return Ok(());
1258        }
1259
1260        self.invalidate_after_present_error();
1261        result.map(|_| ())
1262    }
1263
1264    /// Present a UI frame, taking ownership of the buffer (O(1) — no clone).
1265    ///
1266    /// Prefer this over `present_ui` when the caller has an owned buffer
1267    /// that won't be reused, as it avoids an O(width × height) clone.
1268    pub fn present_ui_owned(
1269        &mut self,
1270        buffer: Buffer,
1271        cursor: Option<(u16, u16)>,
1272        cursor_visible: bool,
1273    ) -> io::Result<()> {
1274        // One-writer discipline vs teardown paths (bd-kdn7n item 2).
1275        let _output_guard = terminal_output_lock();
1276        let mode_str = match self.screen_mode {
1277            ScreenMode::Inline { .. } => "inline",
1278            ScreenMode::InlineAuto { .. } => "inline_auto",
1279            ScreenMode::AltScreen => "altscreen",
1280        };
1281        let trace_enabled = self.render_trace.is_some();
1282        if trace_enabled {
1283            self.writer().reset_counter();
1284        }
1285        let present_start = if trace_enabled {
1286            Some(Instant::now())
1287        } else {
1288            None
1289        };
1290        let _span = info_span!(
1291            "ftui.render.present",
1292            mode = mode_str,
1293            width = buffer.width(),
1294            height = buffer.height(),
1295        )
1296        .entered();
1297
1298        let result = match self.screen_mode {
1299            ScreenMode::Inline { ui_height } => {
1300                self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1301            }
1302            ScreenMode::InlineAuto { .. } => {
1303                let ui_height = self.effective_ui_height();
1304                self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1305            }
1306            ScreenMode::AltScreen => self.present_altscreen(&buffer, cursor, cursor_visible),
1307        };
1308
1309        let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1310        let present_bytes = if trace_enabled {
1311            {
1312                let w = self.writer();
1313                let count = w.bytes_written();
1314                w.reset_counter();
1315                Some(count)
1316            }
1317        } else {
1318            None
1319        };
1320        if trace_enabled {
1321            // No-op: ftui_render::CountingWriter always counts; reset happens in take above.
1322        }
1323
1324        if let Ok(stats) = result {
1325            self.record_successful_present(stats.diff_strategy);
1326            if let Some(ref mut trace) = self.render_trace {
1327                let payload_info = match stats.diff_strategy {
1328                    DiffStrategy::FullRedraw => {
1329                        let payload = build_full_buffer_payload(&buffer, &self.pool);
1330                        trace.write_payload(&payload).ok()
1331                    }
1332                    _ => {
1333                        let payload =
1334                            build_diff_runs_payload(&buffer, &self.diff_scratch, &self.pool);
1335                        trace.write_payload(&payload).ok()
1336                    }
1337                };
1338                let (payload_kind, payload_path) = match payload_info {
1339                    Some(info) => (info.kind, Some(info.path)),
1340                    None => ("none", None),
1341                };
1342                let payload_path_ref = payload_path.as_deref();
1343                let diff_strategy = diff_strategy_str(stats.diff_strategy);
1344                let ui_anchor = ui_anchor_str(self.ui_anchor);
1345                let frame = RenderTraceFrame {
1346                    cols: buffer.width(),
1347                    rows: buffer.height(),
1348                    mode: mode_str,
1349                    ui_height: stats.ui_height,
1350                    ui_anchor,
1351                    diff_strategy,
1352                    diff_cells: stats.diff_cells,
1353                    diff_runs: stats.diff_runs,
1354                    present_bytes: present_bytes.unwrap_or(0),
1355                    render_us: None,
1356                    present_us,
1357                    payload_kind,
1358                    payload_path: payload_path_ref,
1359                    trace_us: None,
1360                };
1361                let _ = trace.record_frame(frame, &buffer, &self.pool);
1362            }
1363
1364            // 3-buffer rotation: keep clone_buf populated for present_ui path.
1365            self.clone_buf = self.spare_buffer.take();
1366            self.spare_buffer = self.prev_buffer.take();
1367            self.prev_buffer = Some(buffer);
1368            return Ok(());
1369        }
1370
1371        self.invalidate_after_present_error();
1372        result.map(|_| ())
1373    }
1374
1375    fn decide_diff(&mut self, buffer: &Buffer) -> DiffDecision {
1376        let prev_dims = self
1377            .prev_buffer
1378            .as_ref()
1379            .map(|prev| (prev.width(), prev.height()));
1380        if prev_dims.is_none() || prev_dims != Some((buffer.width(), buffer.height())) {
1381            self.full_redraw_probe = 0;
1382            self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1383            self.last_certificate = Some(evaluate_render_certificate(
1384                &RenderCertificateInputs {
1385                    prev_available: prev_dims.is_some(),
1386                    dims_changed: prev_dims.is_some(),
1387                    full_redraw_due: false,
1388                    dirty_row_count: buffer.dirty_row_count(),
1389                    total_rows: buffer.height(),
1390                },
1391                Vec::new(),
1392            ));
1393            return DiffDecision {
1394                strategy: DiffStrategy::FullRedraw,
1395                has_diff: false,
1396            };
1397        }
1398
1399        if self.full_redraw_interval_due() {
1400            self.full_redraw_probe = 0;
1401            self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1402            self.last_certificate = Some(evaluate_render_certificate(
1403                &RenderCertificateInputs {
1404                    prev_available: true,
1405                    dims_changed: false,
1406                    full_redraw_due: true,
1407                    dirty_row_count: buffer.dirty_row_count(),
1408                    total_rows: buffer.height(),
1409                },
1410                Vec::new(),
1411            ));
1412            return DiffDecision {
1413                strategy: DiffStrategy::FullRedraw,
1414                has_diff: false,
1415            };
1416        }
1417
1418        let dirty_rows = buffer.dirty_row_count();
1419        let width = buffer.width() as usize;
1420        let height = buffer.height() as usize;
1421        let mut span_stats_snapshot: Option<DirtySpanStats> = None;
1422        let mut dirty_scan_cells_estimate = dirty_rows.saturating_mul(width);
1423
1424        if self.diff_config.bayesian_enabled {
1425            let span_stats = buffer.dirty_span_stats();
1426            if span_stats.span_coverage_cells > 0 {
1427                dirty_scan_cells_estimate = span_stats.span_coverage_cells;
1428            }
1429            span_stats_snapshot = Some(span_stats);
1430        }
1431
1432        // Select strategy based on config
1433        let mut strategy = if self.diff_config.bayesian_enabled {
1434            // Use Bayesian selector
1435            self.diff_strategy.select_with_scan_estimate(
1436                buffer.width(),
1437                buffer.height(),
1438                dirty_rows,
1439                dirty_scan_cells_estimate,
1440            )
1441        } else {
1442            // Simple heuristic: use DirtyRows if few rows dirty, else Full
1443            if self.diff_config.dirty_rows_enabled && dirty_rows < buffer.height() as usize {
1444                DiffStrategy::DirtyRows
1445            } else {
1446                DiffStrategy::Full
1447            }
1448        };
1449
1450        // Enforce dirty_rows_enabled toggle
1451        if !self.diff_config.dirty_rows_enabled && strategy == DiffStrategy::DirtyRows {
1452            strategy = DiffStrategy::Full;
1453            if self.diff_config.bayesian_enabled {
1454                self.diff_strategy
1455                    .override_last_strategy(strategy, "dirty_rows_disabled");
1456            }
1457        }
1458
1459        // Periodic probe when FullRedraw is selected (to update posterior)
1460        if strategy == DiffStrategy::FullRedraw {
1461            if self.full_redraw_probe >= FULL_REDRAW_PROBE_INTERVAL {
1462                self.full_redraw_probe = 0;
1463                let probed = if self.diff_config.dirty_rows_enabled
1464                    && dirty_rows < buffer.height() as usize
1465                {
1466                    DiffStrategy::DirtyRows
1467                } else {
1468                    DiffStrategy::Full
1469                };
1470                if probed != strategy {
1471                    strategy = probed;
1472                    if self.diff_config.bayesian_enabled {
1473                        self.diff_strategy
1474                            .override_last_strategy(strategy, "full_redraw_probe");
1475                    }
1476                }
1477            } else {
1478                self.full_redraw_probe = self.full_redraw_probe.saturating_add(1);
1479            }
1480        } else {
1481            self.full_redraw_probe = 0;
1482        }
1483
1484        let mut has_diff = false;
1485        match strategy {
1486            DiffStrategy::Full => {
1487                let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1488                self.diff_scratch.compute_into(prev, buffer);
1489                self.last_certificate = Some(RenderCertificate {
1490                    level: RenderCertificateLevel::FullRequired,
1491                    causes: vec!["strategy-selected-full"],
1492                    dirty_rows: Vec::new(),
1493                    fell_back: false,
1494                });
1495                has_diff = true;
1496            }
1497            DiffStrategy::DirtyRows => {
1498                let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1499                if self.diff_config.certified_skips {
1500                    let certificate = evaluate_render_certificate(
1501                        &RenderCertificateInputs {
1502                            prev_available: true,
1503                            dims_changed: false,
1504                            full_redraw_due: false,
1505                            dirty_row_count: dirty_rows,
1506                            total_rows: buffer.height(),
1507                        },
1508                        buffer.dirty_row_indices(),
1509                    );
1510                    self.diff_scratch
1511                        .compute_certified_into(prev, buffer, certificate.to_hint());
1512                    self.last_certificate = Some(certificate);
1513                } else {
1514                    self.diff_scratch.compute_dirty_into(prev, buffer);
1515                    self.last_certificate = None;
1516                }
1517                has_diff = true;
1518            }
1519            DiffStrategy::FullRedraw => {}
1520        }
1521
1522        let mut scan_cost_estimate = 0usize;
1523        let mut fallback_reason: &'static str = "none";
1524        let tile_stats = if strategy == DiffStrategy::DirtyRows {
1525            self.diff_scratch.last_tile_stats()
1526        } else {
1527            None
1528        };
1529
1530        // Update posterior if Bayesian mode is enabled
1531        if self.diff_config.bayesian_enabled && has_diff {
1532            let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1533            let (scan_cost, reason) = estimate_diff_scan_cost(
1534                strategy,
1535                dirty_rows,
1536                width,
1537                height,
1538                &span_stats,
1539                tile_stats,
1540            );
1541            let scanned_cells = scan_cost.max(self.diff_scratch.len());
1542            self.diff_strategy
1543                .observe(scanned_cells, self.diff_scratch.len());
1544            span_stats_snapshot = Some(span_stats);
1545            scan_cost_estimate = scan_cost;
1546            fallback_reason = reason;
1547        }
1548
1549        if let Some(evidence) = self.diff_strategy.last_evidence() {
1550            let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1551            let (scan_cost, reason) = if span_stats_snapshot.is_some() {
1552                (scan_cost_estimate, fallback_reason)
1553            } else {
1554                estimate_diff_scan_cost(
1555                    strategy,
1556                    dirty_rows,
1557                    width,
1558                    height,
1559                    &span_stats,
1560                    tile_stats,
1561                )
1562            };
1563            let span_coverage_pct = if evidence.total_cells == 0 {
1564                0.0
1565            } else {
1566                (span_stats.span_coverage_cells as f64 / evidence.total_cells as f64) * 100.0
1567            };
1568            let span_count = span_stats.total_spans;
1569            let max_span_len = span_stats.max_span_len;
1570            let event_idx = self.diff_evidence_idx;
1571            self.diff_evidence_idx = self.diff_evidence_idx.saturating_add(1);
1572            let tile_used = tile_stats.is_some_and(|stats| stats.fallback.is_none());
1573            let tile_fallback = tile_stats
1574                .and_then(|stats| stats.fallback)
1575                .map(TileDiffFallback::as_str)
1576                .unwrap_or("none");
1577            let run_id = json_escape(&self.diff_evidence_run_id);
1578            let strategy_json = json_escape(&strategy.to_string());
1579            let guard_reason_json = json_escape(evidence.guard_reason);
1580            let fallback_reason_json = json_escape(reason);
1581            let tile_fallback_json = json_escape(tile_fallback);
1582            let schema_version = crate::evidence_sink::EVIDENCE_SCHEMA_VERSION;
1583            let screen_mode = match self.screen_mode {
1584                ScreenMode::Inline { .. } => "inline",
1585                ScreenMode::InlineAuto { .. } => "inline_auto",
1586                ScreenMode::AltScreen => "altscreen",
1587            };
1588            let (
1589                tile_w,
1590                tile_h,
1591                tiles_x,
1592                tiles_y,
1593                dirty_tiles,
1594                dirty_cells,
1595                dirty_tile_ratio,
1596                dirty_cell_ratio,
1597                scanned_tiles,
1598                skipped_tiles,
1599                scan_cells_estimate,
1600                sat_build_cells,
1601            ) = if let Some(stats) = tile_stats {
1602                (
1603                    stats.tile_w,
1604                    stats.tile_h,
1605                    stats.tiles_x,
1606                    stats.tiles_y,
1607                    stats.dirty_tiles,
1608                    stats.dirty_cells,
1609                    stats.dirty_tile_ratio,
1610                    stats.dirty_cell_ratio,
1611                    stats.scanned_tiles,
1612                    stats.skipped_tiles,
1613                    stats.scan_cells_estimate,
1614                    stats.sat_build_cells,
1615                )
1616            } else {
1617                (0, 0, 0, 0, 0, 0, 0.0, 0.0, 0, 0, 0, 0)
1618            };
1619            let tile_size = tile_w as usize * tile_h as usize;
1620            let dirty_tile_count = dirty_tiles;
1621            let skipped_tile_count = skipped_tiles;
1622            let sat_build_cost_est = sat_build_cells;
1623
1624            set_diff_snapshot(Some(DiffDecisionSnapshot {
1625                event_idx,
1626                screen_mode: screen_mode.to_string(),
1627                cols: u16::try_from(width).unwrap_or(u16::MAX),
1628                rows: u16::try_from(height).unwrap_or(u16::MAX),
1629                evidence: evidence.clone(),
1630                span_count,
1631                span_coverage_pct,
1632                max_span_len,
1633                scan_cost_estimate: scan_cost,
1634                fallback_reason: reason.to_string(),
1635                tile_used,
1636                tile_fallback: tile_fallback.to_string(),
1637                strategy_used: strategy,
1638            }));
1639
1640            trace!(
1641                strategy = %strategy,
1642                selected = %evidence.strategy,
1643                cost_full = evidence.cost_full,
1644                cost_dirty = evidence.cost_dirty,
1645                cost_redraw = evidence.cost_redraw,
1646                dirty_rows = evidence.dirty_rows,
1647                total_rows = evidence.total_rows,
1648                total_cells = evidence.total_cells,
1649                bayesian_enabled = self.diff_config.bayesian_enabled,
1650                dirty_rows_enabled = self.diff_config.dirty_rows_enabled,
1651                "diff strategy selected"
1652            );
1653            if let Some(ref sink) = self.evidence_sink {
1654                let line = format!(
1655                    r#"{{"schema_version":"{}","event":"diff_decision","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{},"strategy":"{}","cost_full":{:.6},"cost_dirty":{:.6},"cost_redraw":{:.6},"posterior_mean":{:.6},"posterior_variance":{:.6},"alpha":{:.6},"beta":{:.6},"guard_reason":"{}","hysteresis_applied":{},"hysteresis_ratio":{:.6},"dirty_rows":{},"total_rows":{},"total_cells":{},"span_count":{},"span_coverage_pct":{:.6},"max_span_len":{},"fallback_reason":"{}","scan_cost_estimate":{},"tile_used":{},"tile_fallback":"{}","tile_w":{},"tile_h":{},"tile_size":{},"tiles_x":{},"tiles_y":{},"dirty_tiles":{},"dirty_tile_count":{},"dirty_cells":{},"dirty_tile_ratio":{:.6},"dirty_cell_ratio":{:.6},"scanned_tiles":{},"skipped_tiles":{},"skipped_tile_count":{},"tile_scan_cells_estimate":{},"sat_build_cost_est":{},"bayesian_enabled":{},"dirty_rows_enabled":{}}}"#,
1656                    schema_version,
1657                    run_id,
1658                    event_idx,
1659                    screen_mode,
1660                    width,
1661                    height,
1662                    strategy_json,
1663                    evidence.cost_full,
1664                    evidence.cost_dirty,
1665                    evidence.cost_redraw,
1666                    evidence.posterior_mean,
1667                    evidence.posterior_variance,
1668                    evidence.alpha,
1669                    evidence.beta,
1670                    guard_reason_json,
1671                    evidence.hysteresis_applied,
1672                    evidence.hysteresis_ratio,
1673                    evidence.dirty_rows,
1674                    evidence.total_rows,
1675                    evidence.total_cells,
1676                    span_count,
1677                    span_coverage_pct,
1678                    max_span_len,
1679                    fallback_reason_json,
1680                    scan_cost,
1681                    tile_used,
1682                    tile_fallback_json,
1683                    tile_w,
1684                    tile_h,
1685                    tile_size,
1686                    tiles_x,
1687                    tiles_y,
1688                    dirty_tiles,
1689                    dirty_tile_count,
1690                    dirty_cells,
1691                    dirty_tile_ratio,
1692                    dirty_cell_ratio,
1693                    scanned_tiles,
1694                    skipped_tiles,
1695                    skipped_tile_count,
1696                    scan_cells_estimate,
1697                    sat_build_cost_est,
1698                    self.diff_config.bayesian_enabled,
1699                    self.diff_config.dirty_rows_enabled,
1700                );
1701                let _ = sink.write_jsonl(&line);
1702            }
1703        }
1704
1705        // Emit the explicit skip certificate alongside the diff decision so
1706        // operators can see WHY work was skipped or performed (bd-6b9nr).
1707        if let Some(ref certificate) = self.last_certificate
1708            && let Some(ref sink) = self.evidence_sink
1709        {
1710            let line = format!(
1711                r#"{{"event":"certificate_decision","run_id":"{}","event_idx":{},"strategy":"{:?}","certificate":{}}}"#,
1712                self.diff_evidence_run_id,
1713                self.diff_evidence_idx,
1714                strategy,
1715                certificate.to_evidence_json()
1716            );
1717            let _ = sink.write_jsonl(&line);
1718        }
1719
1720        self.last_diff_strategy = Some(strategy);
1721        DiffDecision { strategy, has_diff }
1722    }
1723
1724    fn full_redraw_interval_due(&self) -> bool {
1725        // Frame-count bound: resync after N rendered incremental frames.
1726        let frame_due = self.diff_config.full_redraw_interval_frames > 0
1727            && self.frames_since_full_redraw >= self.diff_config.full_redraw_interval_frames;
1728        // Wall-clock bound: resync after the configured elapsed time regardless
1729        // of how many frames have rendered. This is what bounds terminal-state
1730        // desync (diff corruption, mux pane swap, reattach) on a sparsely- or
1731        // idly-rendering TUI, where the frame counter advances too slowly. A
1732        // missing `last_full_redraw_at` (no full redraw presented yet) is
1733        // treated as due so the first eligible frame resynchronizes.
1734        let time_due = self
1735            .diff_config
1736            .full_redraw_max_interval
1737            .is_some_and(|interval| {
1738                self.last_full_redraw_at
1739                    .is_none_or(|at| at.elapsed() >= interval)
1740            });
1741        frame_due || time_due
1742    }
1743
1744    fn record_successful_present(&mut self, strategy: DiffStrategy) {
1745        if strategy == DiffStrategy::FullRedraw {
1746            self.frames_since_full_redraw = 0;
1747            // Anchor the wall-clock resync window to this physical full redraw.
1748            if self.diff_config.full_redraw_max_interval.is_some() {
1749                self.last_full_redraw_at = Some(Instant::now());
1750            }
1751        } else {
1752            self.frames_since_full_redraw = self.frames_since_full_redraw.saturating_add(1);
1753        }
1754    }
1755
1756    fn invalidate_after_present_error(&mut self) {
1757        self.prev_buffer = None;
1758        self.last_inline_region = None;
1759        self.reset_diff_strategy();
1760    }
1761
1762    /// Present UI in inline mode with cursor save/restore.
1763    ///
1764    /// When the scroll-region strategy is active, DECSTBM is set to constrain
1765    /// log scrolling to the region above the UI. This prevents log output from
1766    /// overwriting the UI, reducing redraw work.
1767    fn present_inline(
1768        &mut self,
1769        buffer: &Buffer,
1770        ui_height: u16,
1771        cursor: Option<(u16, u16)>,
1772        cursor_visible: bool,
1773    ) -> io::Result<FrameEmitStats> {
1774        let sync_output_enabled = self.capabilities.use_sync_output();
1775        let render_mode = inline_strategy_str(self.inline_strategy);
1776        let _inline_span = info_span!(
1777            "inline.render",
1778            inline_height = ui_height,
1779            scrollback_preserved = tracing::field::Empty,
1780            render_mode,
1781        )
1782        .entered();
1783
1784        let result = (|| -> io::Result<FrameEmitStats> {
1785            let visible_height = ui_height.min(self.term_height);
1786            let ui_y_start = self.ui_start_row();
1787            let current_region = InlineRegion {
1788                start: ui_y_start,
1789                height: visible_height,
1790            };
1791
1792            // Begin sync output if available
1793            if sync_output_enabled && !self.in_sync_block {
1794                // Mark active before write so cleanup paths conservatively emit
1795                // SYNC_END even if the begin write fails after partial bytes.
1796                self.in_sync_block = true;
1797                if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
1798                    // Attempt immediate close to avoid leaving the terminal in a
1799                    // potentially open synchronized-output state.
1800                    let _ = self.writer().write_all(SYNC_END);
1801                    self.in_sync_block = false;
1802                    let _ = self.writer().flush();
1803                    return Err(err);
1804                }
1805            }
1806
1807            // Save cursor (DEC save)
1808            self.writer().write_all(CURSOR_SAVE)?;
1809            self.cursor_saved = true;
1810
1811            // Keep the hardware cursor hidden while we issue many cursor moves.
1812            // This prevents visible cursor "speckling" artifacts during redraws.
1813            self.set_cursor_visibility(false)?;
1814
1815            // Activate scroll region if strategy calls for it
1816            {
1817                let _span = debug_span!("ftui.render.scroll_region").entered();
1818                if visible_height > 0 {
1819                    match self.inline_strategy {
1820                        InlineStrategy::ScrollRegion | InlineStrategy::Hybrid => {
1821                            self.activate_scroll_region(visible_height)?;
1822                        }
1823                        InlineStrategy::OverlayRedraw => {}
1824                    }
1825                } else if self.scroll_region_active {
1826                    self.deactivate_scroll_region()?;
1827                }
1828            }
1829
1830            self.clear_inline_region_diff(current_region)?;
1831
1832            let mut diff_strategy = DiffStrategy::FullRedraw;
1833            let mut diff_us = 0u64;
1834            let mut emit_stats = EmitStats {
1835                diff_cells: 0,
1836                diff_runs: 0,
1837            };
1838
1839            if visible_height > 0 {
1840                // If this is a full redraw (no previous buffer) OR dimensions changed,
1841                // we must clear the entire UI region to prevent ghosting (e.g. if width shrank).
1842                let dims_changed = self.prev_buffer.as_ref().map(|b| (b.width(), b.height()))
1843                    != Some((buffer.width(), buffer.height()));
1844
1845                if self.prev_buffer.is_none() || dims_changed {
1846                    self.clear_rows(ui_y_start, visible_height)?;
1847                } else {
1848                    // If dimensions match but the buffer is shorter than the visible height,
1849                    // clear the remaining rows to prevent garbage from logs or previous frames.
1850                    let buf_height = buffer.height().min(visible_height);
1851                    if buf_height < visible_height {
1852                        let clear_start = ui_y_start.saturating_add(buf_height);
1853                        let clear_height = visible_height.saturating_sub(buf_height);
1854                        self.clear_rows(clear_start, clear_height)?;
1855                    }
1856                }
1857
1858                // Compute diff
1859                let diff_start = if self.timing_enabled {
1860                    Some(Instant::now())
1861                } else {
1862                    None
1863                };
1864                let decision = {
1865                    let _span = debug_span!("ftui.render.diff_compute").entered();
1866                    self.decide_diff(buffer)
1867                };
1868                if let Some(start) = diff_start {
1869                    diff_us = start.elapsed().as_micros() as u64;
1870                }
1871                diff_strategy = decision.strategy;
1872
1873                // Emit diff using Presenter
1874                {
1875                    let _span = debug_span!("ftui.render.emit").entered();
1876
1877                    // Reset presenter state (cursor unknown) because we manually moved cursor/saved
1878                    // and apply viewport offset for inline positioning.
1879                    let presenter = self.presenter.as_mut().expect("presenter consumed");
1880                    presenter.reset();
1881                    presenter.set_viewport_offset_y(ui_y_start);
1882
1883                    if decision.has_diff {
1884                        presenter.prepare_runs(&self.diff_scratch);
1885                        // Clip to the visible terminal region, mirroring the
1886                        // full-redraw branch: when the terminal is shorter
1887                        // than ui_height, a below-the-fold run would emit a
1888                        // CUP past the physical bottom row, which real
1889                        // terminals clamp — corrupting the last visible row.
1890                        presenter.clip_runs_below(visible_height);
1891                        // Emit
1892                        presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1893
1894                        emit_stats.diff_cells = self.diff_scratch.len();
1895                        emit_stats.diff_runs = self.diff_scratch.runs().len();
1896                    } else {
1897                        // Full redraw — clip to the visible terminal region and
1898                        // to the buffer's actual height. This avoids generating
1899                        // diff runs for rows that are outside `buffer`.
1900                        let render_height = buffer.height().min(visible_height);
1901                        let full = BufferDiff::full(buffer.width(), render_height);
1902                        presenter.prepare_runs(&full);
1903                        presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
1904
1905                        emit_stats.diff_cells = full.len();
1906                        emit_stats.diff_runs = full.runs().len();
1907                    }
1908
1909                    presenter.finish_frame()?;
1910                }
1911            }
1912
1913            // Restore cursor
1914            self.writer().write_all(CURSOR_RESTORE)?;
1915            self.cursor_saved = false;
1916
1917            let mut show_cursor = false;
1918            if cursor_visible
1919                && let Some((cx, cy)) = cursor
1920                && cx < buffer.width()
1921                && cy < buffer.height()
1922                && cy < visible_height
1923            {
1924                // Move to UI start + cursor y
1925                let abs_y = ui_y_start.saturating_add(cy);
1926                write!(
1927                    self.writer(),
1928                    "\x1b[{};{}H",
1929                    abs_y.saturating_add(1),
1930                    cx.saturating_add(1)
1931                )?;
1932                show_cursor = true;
1933            }
1934            self.set_cursor_visibility(show_cursor)?;
1935
1936            // End sync output (mux-aware policy).
1937            if sync_output_enabled && self.in_sync_block {
1938                self.writer().write_all(SYNC_END)?;
1939                self.in_sync_block = false;
1940            } else if !sync_output_enabled {
1941                // Defensive stale-state cleanup: clear internal state without
1942                // emitting DEC 2026 in mux/unsupported environments.
1943                self.in_sync_block = false;
1944            }
1945
1946            self.writer().flush()?;
1947            self.last_inline_region = if visible_height > 0 {
1948                Some(current_region)
1949            } else {
1950                None
1951            };
1952
1953            if self.timing_enabled {
1954                self.last_present_timings = Some(PresentTimings { diff_us });
1955            }
1956
1957            Ok(FrameEmitStats {
1958                diff_strategy,
1959                diff_cells: emit_stats.diff_cells,
1960                diff_runs: emit_stats.diff_runs,
1961                ui_height: visible_height,
1962            })
1963        })();
1964
1965        if result.is_err() {
1966            _inline_span.record("scrollback_preserved", false);
1967            warn!(
1968                inline_height = ui_height,
1969                render_mode, "scrollback preservation failed during inline render"
1970            );
1971            self.best_effort_inline_cleanup();
1972        } else {
1973            _inline_span.record("scrollback_preserved", true);
1974        }
1975
1976        result
1977    }
1978
1979    /// Present UI in alternate screen mode (simpler, no cursor gymnastics).
1980    fn present_altscreen(
1981        &mut self,
1982        buffer: &Buffer,
1983        cursor: Option<(u16, u16)>,
1984        cursor_visible: bool,
1985    ) -> io::Result<FrameEmitStats> {
1986        let sync_output_enabled = self.capabilities.use_sync_output();
1987        let diff_start = if self.timing_enabled {
1988            Some(Instant::now())
1989        } else {
1990            None
1991        };
1992        let decision = {
1993            let _span = debug_span!("ftui.render.diff_compute").entered();
1994            self.decide_diff(buffer)
1995        };
1996        let diff_us = diff_start
1997            .map(|start| start.elapsed().as_micros() as u64)
1998            .unwrap_or(0);
1999
2000        // Begin sync if available. Track state so we can reliably close the
2001        // block even on early-return error paths.
2002        if sync_output_enabled && !self.in_sync_block {
2003            // Mark active before write so partial begin writes are treated as
2004            // an open block for best-effort close.
2005            self.in_sync_block = true;
2006            if let Err(err) = self.writer().write_all(SYNC_BEGIN) {
2007                // Attempt immediate close to avoid leaving the terminal in a
2008                // potentially open synchronized-output state.
2009                let _ = self.writer().write_all(SYNC_END);
2010                self.in_sync_block = false;
2011                let _ = self.writer().flush();
2012                return Err(err);
2013            }
2014        }
2015
2016        let operation_result = (|| -> io::Result<FrameEmitStats> {
2017            // Keep the hardware cursor hidden while we issue many cursor moves.
2018            // This prevents visible cursor "speckling" artifacts during redraws.
2019            self.set_cursor_visibility(false)?;
2020
2021            let emit_stats = {
2022                let _span = debug_span!("ftui.render.emit").entered();
2023                let presenter = self.presenter.as_mut().expect("presenter consumed");
2024
2025                // Reset presenter state (cursor and style) because we manually moved
2026                // the cursor and reset the style at the end of the previous frame.
2027                presenter.reset();
2028                // AltScreen always starts at (0,0) relative to terminal.
2029                presenter.set_viewport_offset_y(0);
2030
2031                let stats = if decision.has_diff {
2032                    presenter.prepare_runs(&self.diff_scratch);
2033                    presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
2034
2035                    EmitStats {
2036                        diff_cells: self.diff_scratch.len(),
2037                        diff_runs: self.diff_scratch.runs().len(),
2038                    }
2039                } else {
2040                    // Full redraw: populate diff with all cells and emit.
2041                    self.diff_scratch.fill_full(buffer.width(), buffer.height());
2042                    presenter.prepare_runs(&self.diff_scratch);
2043                    presenter.emit_diff_runs(buffer, Some(&self.pool), Some(&self.links))?;
2044
2045                    EmitStats {
2046                        diff_cells: (buffer.width() as usize) * (buffer.height() as usize),
2047                        diff_runs: buffer.height() as usize,
2048                    }
2049                };
2050
2051                presenter.finish_frame()?;
2052                stats
2053            };
2054
2055            let mut show_cursor = false;
2056            if cursor_visible
2057                && let Some((cx, cy)) = cursor
2058                && cx < buffer.width()
2059                && cy < buffer.height()
2060            {
2061                // Apply requested cursor position
2062                write!(
2063                    self.writer(),
2064                    "\x1b[{};{}H",
2065                    cy.saturating_add(1),
2066                    cx.saturating_add(1)
2067                )?;
2068                show_cursor = true;
2069            }
2070            self.set_cursor_visibility(show_cursor)?;
2071
2072            if self.timing_enabled {
2073                self.last_present_timings = Some(PresentTimings { diff_us });
2074            }
2075
2076            Ok(FrameEmitStats {
2077                diff_strategy: decision.strategy,
2078                diff_cells: emit_stats.diff_cells,
2079                diff_runs: emit_stats.diff_runs,
2080                ui_height: 0,
2081            })
2082        })();
2083
2084        if operation_result.is_err()
2085            && let Some(ref mut presenter) = self.presenter
2086        {
2087            presenter.finish_frame_best_effort();
2088        }
2089
2090        // Always attempt to close sync and flush, regardless of operation_result.
2091        let sync_end_result = if sync_output_enabled && self.in_sync_block {
2092            let res = self.writer().write_all(SYNC_END);
2093            if res.is_ok() {
2094                self.in_sync_block = false;
2095            }
2096            Some(res)
2097        } else {
2098            if !sync_output_enabled {
2099                // Defensive stale-state cleanup: do not emit DEC 2026 when
2100                // policy disallows synchronized output.
2101                self.in_sync_block = false;
2102            }
2103            None
2104        };
2105        let flush_result = self.writer().flush();
2106
2107        // Cleanup failures (sync-end/flush) take precedence so terminal-state
2108        // restoration errors are never hidden by a concurrent render failure.
2109        let cleanup_error = sync_end_result
2110            .and_then(Result::err)
2111            .or_else(|| flush_result.err());
2112        if let Some(err) = cleanup_error {
2113            return Err(err);
2114        }
2115        operation_result
2116    }
2117
2118    // emit_diff, emit_full_redraw, and emit_style_flags have been removed
2119    // in favor of delegating to the Presenter for all emission paths.
2120
2121    /// Create a full-screen diff (marks all cells as changed).
2122    #[allow(dead_code)] // API for future diff strategy integration
2123    fn create_full_diff(&self, buffer: &Buffer) -> BufferDiff {
2124        BufferDiff::full(buffer.width(), buffer.height())
2125    }
2126
2127    /// Write log output (goes to the log region in inline mode).
2128    ///
2129    /// Behavior depends on how the UI is anchored on screen:
2130    ///
2131    /// - **Scroll region active** (ScrollRegion/Hybrid strategies): the cursor
2132    ///   is parked at the bottom margin of the DECSTBM log region and the text
2133    ///   is written verbatim (after escape-stripping). Each LF scrolls only
2134    ///   the log region, so lines accumulate upward into scrollback while the
2135    ///   UI stays put.
2136    ///
2137    /// - **Overlay fallback** (OverlayRedraw strategy, or any transient state
2138    ///   without an active region): an unqualified LF would scroll the whole
2139    ///   screen and displace or corrupt the displayed UI, so logs NEVER emit
2140    ///   one. Instead exactly one width-clamped line is erased-and-rewritten,
2141    ///   referenced against the *displayed* region (`last_inline_region`) so
2142    ///   height changes between presents cannot land the line inside on-screen
2143    ///   UI. Older lines remain visible above until displaced by present
2144    ///   repaints; accumulation into scrollback requires the scroll-region
2145    ///   path.
2146    ///
2147    /// If the UI consumes the entire terminal height, there is no log region
2148    /// available and the write becomes a no-op.
2149    ///
2150    /// In AltScreen mode, logs are typically not shown (returns Ok silently).
2151    pub fn write_log(&mut self, text: &str) -> io::Result<()> {
2152        // One-writer discipline vs teardown paths (bd-kdn7n item 2).
2153        let _output_guard = terminal_output_lock();
2154        // Defense in depth: callers usually sanitize before logging, but the
2155        // terminal writer is the final emission boundary and must never pass
2156        // through escape/control injection payloads.
2157        let sanitized = sanitize(text);
2158        let text = sanitized.as_ref();
2159        match self.screen_mode {
2160            ScreenMode::Inline { ui_height } => self.write_log_inline(ui_height, text),
2161            ScreenMode::InlineAuto { .. } => {
2162                let ui_height = self.effective_ui_height();
2163                self.write_log_inline(ui_height, text)
2164            }
2165            ScreenMode::AltScreen => {
2166                // AltScreen: no scrollback, logs are typically handled differently
2167                // (e.g., written to a log pane or file)
2168                Ok(())
2169            }
2170        }
2171    }
2172
2173    /// Shared inline log-write path for [`ScreenMode::Inline`] and
2174    /// [`ScreenMode::InlineAuto`].
2175    ///
2176    /// `ui_height` is the *effective* (pending) UI height; the physically
2177    /// displayed region may differ until the next `present_ui`, which is why
2178    /// the overlay arm anchors against `last_inline_region`.
2179    fn write_log_inline(&mut self, ui_height: u16, text: &str) -> io::Result<()> {
2180        let visible_height = ui_height.min(self.term_height);
2181        if visible_height >= self.term_height {
2182            // No log region available when UI fills the terminal
2183            return Ok(());
2184        }
2185
2186        if self.scroll_region_active {
2187            // A DECSTBM region matching the current effective height is
2188            // active: parking at its bottom margin lets every LF scroll the
2189            // log region alone, accumulating lines into scrollback.
2190            if !self.position_cursor_for_log(visible_height)? {
2191                return Ok(());
2192            }
2193            self.writer().write_all(text.as_bytes())?;
2194            return self.writer().flush();
2195        }
2196
2197        // Overlay discipline: never emit an unscoped LF (it would scroll the
2198        // whole screen and push UI rows around). Erase-and-rewrite exactly
2199        // one width-clamped line instead.
2200        let log_row = match self.ui_anchor {
2201            UiAnchor::Bottom => {
2202                // One row above the uppermost of the displayed/pending UI
2203                // regions (0-indexed start doubles as the 1-indexed row
2204                // directly above that region).
2205                let pending_start = self.ui_start_row();
2206                let anchor = self.last_inline_region.map_or(pending_start, |displayed| {
2207                    displayed.start.min(pending_start)
2208                });
2209                if anchor == 0 {
2210                    // No free row above the UI.
2211                    return Ok(());
2212                }
2213                anchor
2214            }
2215            UiAnchor::Top => {
2216                // Log region sits below the top-anchored UI; the screen's
2217                // bottom row is always inside it and safe because this path
2218                // never emits LF.
2219                self.term_height
2220            }
2221        };
2222
2223        let line = sanitize_overlay_log_line(text, usize::from(self.term_width));
2224        if line.is_empty() {
2225            return Ok(());
2226        }
2227
2228        // Bound the cursor disturbance to this write so an interleaved
2229        // present (or none) always finds the cursor where it left it.
2230        self.writer().write_all(CURSOR_SAVE)?;
2231        let write_result = write!(self.writer(), "\x1b[{log_row};1H")
2232            .and_then(|()| self.writer().write_all(ERASE_LINE))
2233            .and_then(|()| self.writer().write_all(line.as_bytes()));
2234        let restore_result = self.writer().write_all(CURSOR_RESTORE);
2235
2236        // Restoration failures must not be swallowed by a successful body
2237        // write: the cursor contract outranks the log payload.
2238        write_result?;
2239        restore_result?;
2240        self.writer().flush()
2241    }
2242
2243    /// Position cursor at the bottom margin of the active DECSTBM log region.
2244    ///
2245    /// Only meaningful while a scroll region is active (see
2246    /// [`TerminalWriter::write_log`]): for bottom-anchored UI the region spans
2247    /// rows 1..=term_height - ui_height, for top-anchored UI rows
2248    /// ui_height + 1..=term_height. Parking at the region's bottom margin
2249    /// makes each subsequent LF scroll only that region, leaving the UI
2250    /// untouched while log lines accumulate into scrollback.
2251    fn position_cursor_for_log(&mut self, ui_height: u16) -> io::Result<bool> {
2252        let visible_height = ui_height.min(self.term_height);
2253        if visible_height >= self.term_height {
2254            // No log region available when UI fills the terminal
2255            return Ok(false);
2256        }
2257
2258        let log_row = match self.ui_anchor {
2259            UiAnchor::Bottom => {
2260                // Log region is above UI: rows 1 to (term_height - ui_height)
2261                // Position at the bottom of the log region
2262                self.term_height.saturating_sub(visible_height)
2263            }
2264            UiAnchor::Top => {
2265                // Log region is below UI: rows (ui_height + 1) to term_height
2266                // Position at the bottom of the log region (last row)
2267                self.term_height
2268            }
2269        };
2270
2271        // Move to the target row, column 1 (1-indexed)
2272        write!(self.writer(), "\x1b[{};1H", log_row)?;
2273        Ok(true)
2274    }
2275
2276    /// Clear the screen.
2277    pub fn clear_screen(&mut self) -> io::Result<()> {
2278        // One-writer discipline vs teardown paths (bd-kdn7n item 2).
2279        let _output_guard = terminal_output_lock();
2280        let mut first_error = None;
2281        if self.in_sync_block {
2282            if self.capabilities.use_sync_output()
2283                && let Err(err) = self.writer().write_all(SYNC_END)
2284            {
2285                first_error = Some(err);
2286            }
2287            self.in_sync_block = false;
2288        }
2289        if self.cursor_saved {
2290            if let Err(err) = self.writer().write_all(CURSOR_RESTORE) {
2291                first_error.get_or_insert(err);
2292            }
2293            self.cursor_saved = false;
2294        }
2295        if self.scroll_region_active {
2296            if let Err(err) = self.writer().write_all(b"\x1b[r") {
2297                first_error.get_or_insert(err);
2298            }
2299            self.scroll_region_active = false;
2300        }
2301        if let Err(err) = self.writer().write_all(b"\x1b[2J\x1b[1;1H") {
2302            first_error.get_or_insert(err);
2303        }
2304        if let Err(err) = self.writer().flush() {
2305            first_error.get_or_insert(err);
2306        }
2307        self.prev_buffer = None;
2308        self.last_inline_region = None;
2309        self.reset_diff_strategy();
2310        if let Some(err) = first_error {
2311            Err(err)
2312        } else {
2313            Ok(())
2314        }
2315    }
2316
2317    fn set_cursor_visibility(&mut self, visible: bool) -> io::Result<()> {
2318        if self.cursor_visible == visible {
2319            return Ok(());
2320        }
2321        self.cursor_visible = visible;
2322        if visible {
2323            self.writer().write_all(b"\x1b[?25h")?;
2324        } else {
2325            self.writer().write_all(b"\x1b[?25l")?;
2326        }
2327        Ok(())
2328    }
2329
2330    /// Hide the cursor.
2331    pub fn hide_cursor(&mut self) -> io::Result<()> {
2332        let _output_guard = terminal_output_lock();
2333        self.set_cursor_visibility(false)?;
2334        self.writer().flush()
2335    }
2336
2337    /// Show the cursor.
2338    pub fn show_cursor(&mut self) -> io::Result<()> {
2339        let _output_guard = terminal_output_lock();
2340        self.set_cursor_visibility(true)?;
2341        self.writer().flush()
2342    }
2343
2344    /// Flush any buffered output.
2345    pub fn flush(&mut self) -> io::Result<()> {
2346        let _output_guard = terminal_output_lock();
2347        self.writer().flush()
2348    }
2349
2350    /// Get the grapheme pool for interning complex characters.
2351    pub fn pool(&self) -> &GraphemePool {
2352        &self.pool
2353    }
2354
2355    /// Get mutable access to the grapheme pool.
2356    pub fn pool_mut(&mut self) -> &mut GraphemePool {
2357        &mut self.pool
2358    }
2359
2360    /// Get the link registry.
2361    pub fn links(&self) -> &LinkRegistry {
2362        &self.links
2363    }
2364
2365    /// Get mutable access to the link registry.
2366    pub fn links_mut(&mut self) -> &mut LinkRegistry {
2367        &mut self.links
2368    }
2369
2370    /// Borrow the grapheme pool and link registry together.
2371    ///
2372    /// This avoids double-borrowing `self` at call sites that need both.
2373    pub fn pool_and_links_mut(&mut self) -> (&mut GraphemePool, &mut LinkRegistry) {
2374        (&mut self.pool, &mut self.links)
2375    }
2376
2377    /// Get the terminal capabilities.
2378    pub fn capabilities(&self) -> &TerminalCapabilities {
2379        &self.capabilities
2380    }
2381
2382    /// Consume the writer and return the underlying writer.
2383    ///
2384    /// Performs cleanup operations before returning.
2385    /// Returns `None` if the buffer could not be flushed.
2386    pub fn into_inner(mut self) -> Option<W> {
2387        self.cleanup();
2388        // Take the presenter before Drop runs (Drop will see None and skip cleanup)
2389        self.presenter.take()?.into_inner().ok()
2390    }
2391
2392    /// Perform garbage collection on the grapheme pool.
2393    ///
2394    /// Frees graphemes that are not referenced by the current front buffer (`prev_buffer`)
2395    /// or the optional `extra_buffer` (e.g. a pending render).
2396    ///
2397    /// This should be called periodically (e.g. every N frames) to prevent memory leaks
2398    /// in long-running applications with dynamic content.
2399    pub fn gc(&mut self, extra_buffer: Option<&Buffer>) {
2400        let mut buffers = Vec::with_capacity(2);
2401        if let Some(ref buf) = self.prev_buffer {
2402            buffers.push(buf);
2403        }
2404        if let Some(buf) = extra_buffer {
2405            buffers.push(buf);
2406        }
2407        self.pool.gc(&buffers);
2408    }
2409
2410    /// Estimate total memory usage in bytes (buffers + pools).
2411    pub fn estimate_memory_usage(&self) -> usize {
2412        let mut total = 0;
2413        // Buffers (16 bytes per cell)
2414        if let Some(b) = &self.prev_buffer {
2415            total += b.width() as usize * b.height() as usize * 16;
2416        }
2417        if let Some(b) = &self.spare_buffer {
2418            total += b.width() as usize * b.height() as usize * 16;
2419        }
2420        if let Some(b) = &self.clone_buf {
2421            total += b.width() as usize * b.height() as usize * 16;
2422        }
2423        // Grapheme pool (approx 32 bytes per slot: 24 for String + overhead)
2424        total += self.pool.capacity() * 32;
2425        // Link registry
2426        total += self.links.estimate_memory();
2427        total
2428    }
2429
2430    /// Best-effort cleanup when inline present fails mid-frame.
2431    ///
2432    /// This restores sync/cursor/scroll-region state without terminating the writer.
2433    fn best_effort_inline_cleanup(&mut self) {
2434        let Some(ref mut presenter) = self.presenter else {
2435            return;
2436        };
2437        presenter.finish_frame_best_effort();
2438        let writer = presenter.counting_writer_mut();
2439
2440        // Ensure erase operations clear to the terminal default background.
2441        // Without this, background color leakage occurs during cleanup.
2442        let _ = writer.write_all(SGR_BG_DEFAULT);
2443
2444        // Emit restorations unconditionally: write errors can occur after bytes
2445        // were partially written, so internal flags may be stale.
2446        if self.in_sync_block {
2447            if self.capabilities.use_sync_output() {
2448                let _ = writer.write_all(SYNC_END);
2449            }
2450            self.in_sync_block = false;
2451        }
2452
2453        let _ = writer.write_all(CURSOR_RESTORE);
2454        self.cursor_saved = false;
2455
2456        let _ = writer.write_all(b"\x1b[r");
2457        self.scroll_region_active = false;
2458
2459        let _ = writer.write_all(b"\x1b[?25h");
2460        self.cursor_visible = true;
2461        let _ = writer.flush();
2462    }
2463
2464    /// Internal cleanup on drop.
2465    fn cleanup(&mut self) {
2466        // Teardown bytes must not interleave with another thread's emission
2467        // batch (bd-kdn7n item 2); reentrant so Drop-under-lock is safe.
2468        let _output_guard = terminal_output_lock();
2469        let Some(presenter) = &mut self.presenter else {
2470            return; // Presenter already taken (via into_inner)
2471        };
2472        presenter.finish_frame_best_effort();
2473        let writer = presenter.counting_writer_mut();
2474
2475        // Ensure erase operations clear to the terminal default background.
2476        let _ = writer.write_all(SGR_BG_DEFAULT);
2477
2478        // End any pending sync block
2479        if self.in_sync_block {
2480            if self.capabilities.use_sync_output() {
2481                let _ = writer.write_all(SYNC_END);
2482            }
2483            self.in_sync_block = false;
2484        }
2485
2486        // Reset scroll region BEFORE restoring the cursor: DECSTBM reset
2487        // homes the cursor to (1,1) on real terminals, so emitting it after
2488        // CURSOR_RESTORE would discard the deliberate final placement.
2489        if self.scroll_region_active {
2490            let _ = writer.write_all(b"\x1b[r");
2491            self.scroll_region_active = false;
2492        }
2493
2494        // Restore cursor if saved
2495        if self.cursor_saved {
2496            let _ = writer.write_all(CURSOR_RESTORE);
2497            self.cursor_saved = false;
2498        }
2499
2500        // Show cursor
2501        let _ = writer.write_all(b"\x1b[?25h");
2502        self.cursor_visible = true;
2503
2504        // Flush
2505        let _ = writer.flush();
2506
2507        if let Some(ref mut trace) = self.render_trace {
2508            let _ = trace.finish(None);
2509        }
2510    }
2511}
2512
2513impl<W: Write> Drop for TerminalWriter<W> {
2514    fn drop(&mut self) {
2515        // Decrement the inline-active gauge.
2516        if matches!(
2517            self.screen_mode,
2518            ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
2519        ) {
2520            INLINE_ACTIVE_WIDGETS.fetch_sub(1, Ordering::SeqCst);
2521        }
2522        self.cleanup();
2523    }
2524}
2525
2526#[cfg(test)]
2527mod tests {
2528    use super::*;
2529    use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags};
2530    use std::cell::RefCell;
2531    use std::io;
2532    use std::path::PathBuf;
2533    use std::rc::Rc;
2534    use std::sync::atomic::{AtomicUsize, Ordering};
2535
2536    fn max_cursor_row(output: &[u8]) -> u16 {
2537        let mut max_row = 0u16;
2538        let mut i = 0;
2539        while i + 2 < output.len() {
2540            if output[i] == 0x1b && output[i + 1] == b'[' {
2541                let mut j = i + 2;
2542                let mut row: u16 = 0;
2543                let mut saw_row = false;
2544                while j < output.len() && output[j].is_ascii_digit() {
2545                    saw_row = true;
2546                    row = row
2547                        .saturating_mul(10)
2548                        .saturating_add((output[j] - b'0') as u16);
2549                    j += 1;
2550                }
2551                if saw_row && j < output.len() && output[j] == b';' {
2552                    j += 1;
2553                    let mut saw_col = false;
2554                    while j < output.len() && output[j].is_ascii_digit() {
2555                        saw_col = true;
2556                        j += 1;
2557                    }
2558                    if saw_col && j < output.len() && output[j] == b'H' {
2559                        max_row = max_row.max(row);
2560                    }
2561                }
2562            }
2563            i += 1;
2564        }
2565        max_row
2566    }
2567
2568    fn basic_caps() -> TerminalCapabilities {
2569        TerminalCapabilities::basic()
2570    }
2571
2572    fn full_caps() -> TerminalCapabilities {
2573        let mut caps = TerminalCapabilities::basic();
2574        caps.color_depth = ColorDepth::TrueColor;
2575        caps.sync_output = true;
2576        caps
2577    }
2578
2579    fn find_nth(haystack: &[u8], needle: &[u8], nth: usize) -> Option<usize> {
2580        if nth == 0 {
2581            return None;
2582        }
2583        let mut count = 0;
2584        let mut i = 0;
2585        while i + needle.len() <= haystack.len() {
2586            if &haystack[i..i + needle.len()] == needle {
2587                count += 1;
2588                if count == nth {
2589                    return Some(i);
2590                }
2591            }
2592            i += 1;
2593        }
2594        None
2595    }
2596
2597    fn temp_evidence_path(label: &str) -> PathBuf {
2598        static COUNTER: AtomicUsize = AtomicUsize::new(0);
2599        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2600        let mut path = std::env::temp_dir();
2601        path.push(format!(
2602            "ftui_{}_{}_{}.jsonl",
2603            label,
2604            std::process::id(),
2605            id
2606        ));
2607        path
2608    }
2609
2610    #[derive(Default)]
2611    struct FaultState {
2612        bytes: Vec<u8>,
2613        write_calls: usize,
2614        injected_failure_triggered: bool,
2615    }
2616
2617    struct SingleWriteFaultWriter {
2618        state: Rc<RefCell<FaultState>>,
2619        fail_on_call: usize,
2620        max_chunk_len: usize,
2621    }
2622
2623    impl SingleWriteFaultWriter {
2624        fn new(state: Rc<RefCell<FaultState>>, fail_on_call: usize, max_chunk_len: usize) -> Self {
2625            Self {
2626                state,
2627                fail_on_call,
2628                max_chunk_len: max_chunk_len.max(1),
2629            }
2630        }
2631    }
2632
2633    impl Write for SingleWriteFaultWriter {
2634        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
2635            let mut state = self.state.borrow_mut();
2636            state.write_calls = state.write_calls.saturating_add(1);
2637            if !state.injected_failure_triggered && state.write_calls == self.fail_on_call {
2638                state.injected_failure_triggered = true;
2639                return Err(io::Error::other("injected partial-write fault"));
2640            }
2641
2642            let write_len = buf.len().min(self.max_chunk_len);
2643            state.bytes.extend_from_slice(&buf[..write_len]);
2644            Ok(write_len)
2645        }
2646
2647        fn flush(&mut self) -> io::Result<()> {
2648            Ok(())
2649        }
2650    }
2651
2652    #[test]
2653    fn new_creates_writer() {
2654        let output = Vec::new();
2655        let writer = TerminalWriter::new(
2656            output,
2657            ScreenMode::Inline { ui_height: 10 },
2658            UiAnchor::Bottom,
2659            basic_caps(),
2660        );
2661        assert_eq!(writer.ui_height(), 10);
2662    }
2663
2664    #[test]
2665    fn ui_start_row_bottom_anchor() {
2666        let output = Vec::new();
2667        let mut writer = TerminalWriter::new(
2668            output,
2669            ScreenMode::Inline { ui_height: 10 },
2670            UiAnchor::Bottom,
2671            basic_caps(),
2672        );
2673        writer.set_size(80, 24);
2674        assert_eq!(writer.ui_start_row(), 14); // 24 - 10 = 14
2675    }
2676
2677    #[test]
2678    fn ui_start_row_top_anchor() {
2679        let output = Vec::new();
2680        let mut writer = TerminalWriter::new(
2681            output,
2682            ScreenMode::Inline { ui_height: 10 },
2683            UiAnchor::Top,
2684            basic_caps(),
2685        );
2686        writer.set_size(80, 24);
2687        assert_eq!(writer.ui_start_row(), 0);
2688    }
2689
2690    #[test]
2691    fn ui_start_row_altscreen() {
2692        let output = Vec::new();
2693        let mut writer = TerminalWriter::new(
2694            output,
2695            ScreenMode::AltScreen,
2696            UiAnchor::Bottom,
2697            basic_caps(),
2698        );
2699        writer.set_size(80, 24);
2700        assert_eq!(writer.ui_start_row(), 0);
2701    }
2702
2703    #[test]
2704    fn present_ui_inline_saves_restores_cursor() {
2705        let mut output = Vec::new();
2706        {
2707            let mut writer = TerminalWriter::new(
2708                &mut output,
2709                ScreenMode::Inline { ui_height: 5 },
2710                UiAnchor::Bottom,
2711                basic_caps(),
2712            );
2713            writer.set_size(10, 10);
2714
2715            let buffer = Buffer::new(10, 5);
2716            writer.present_ui(&buffer, None, true).unwrap();
2717        }
2718
2719        // Should contain cursor save and restore
2720        assert!(output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE));
2721        assert!(
2722            output
2723                .windows(CURSOR_RESTORE.len())
2724                .any(|w| w == CURSOR_RESTORE)
2725        );
2726    }
2727
2728    #[test]
2729    fn present_ui_with_sync_output() {
2730        let mut output = Vec::new();
2731        {
2732            let mut writer = TerminalWriter::new(
2733                &mut output,
2734                ScreenMode::Inline { ui_height: 5 },
2735                UiAnchor::Bottom,
2736                full_caps(),
2737            );
2738            writer.set_size(10, 10);
2739
2740            let buffer = Buffer::new(10, 5);
2741            writer.present_ui(&buffer, None, true).unwrap();
2742        }
2743
2744        // Should contain sync begin and end
2745        assert!(output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN));
2746        assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2747    }
2748
2749    #[test]
2750    fn present_ui_altscreen_closes_stale_sync_block_when_policy_allows_sync() {
2751        let mut output = Vec::new();
2752        {
2753            let mut writer = TerminalWriter::new(
2754                &mut output,
2755                ScreenMode::AltScreen,
2756                UiAnchor::Bottom,
2757                full_caps(),
2758            );
2759            writer.set_size(8, 2);
2760            writer.in_sync_block = true;
2761
2762            let mut buffer = Buffer::new(8, 2);
2763            buffer.set_raw(0, 0, Cell::from_char('X'));
2764            writer.present_ui(&buffer, None, true).unwrap();
2765
2766            assert!(
2767                !writer.in_sync_block,
2768                "present_altscreen must close stale sync blocks"
2769            );
2770        }
2771
2772        assert!(
2773            output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2774            "sync end should be emitted when stale sync state is detected"
2775        );
2776    }
2777
2778    #[test]
2779    fn present_ui_altscreen_stale_sync_block_skips_sync_end_in_mux() {
2780        let mut output = Vec::new();
2781        {
2782            let mut writer = TerminalWriter::new(
2783                &mut output,
2784                ScreenMode::AltScreen,
2785                UiAnchor::Bottom,
2786                mux_caps(),
2787            );
2788            writer.set_size(8, 2);
2789            writer.in_sync_block = true;
2790
2791            let mut buffer = Buffer::new(8, 2);
2792            buffer.set_raw(0, 0, Cell::from_char('X'));
2793            writer.present_ui(&buffer, None, true).unwrap();
2794
2795            assert!(
2796                !writer.in_sync_block,
2797                "present_altscreen must clear stale sync state"
2798            );
2799        }
2800
2801        assert!(
2802            !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2803            "sync end must be suppressed when policy disables synchronized output"
2804        );
2805    }
2806
2807    #[test]
2808    fn present_ui_altscreen_sanitizes_grapheme_escape_payloads() {
2809        let mut output = Vec::new();
2810        {
2811            let mut writer = TerminalWriter::new(
2812                &mut output,
2813                ScreenMode::AltScreen,
2814                UiAnchor::Bottom,
2815                basic_caps(),
2816            );
2817            writer.set_size(12, 1);
2818
2819            let gid = writer
2820                .pool_mut()
2821                .intern("ok\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}", 6);
2822            let mut buffer = Buffer::new(12, 1);
2823            buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
2824
2825            writer.present_ui(&buffer, None, true).unwrap();
2826        }
2827
2828        let output_str = String::from_utf8_lossy(&output);
2829        assert!(
2830            output_str.contains("oktail"),
2831            "sanitized grapheme content should preserve visible payload"
2832        );
2833        assert!(
2834            !output_str.contains("52;c;SGVsbG8"),
2835            "OSC payload must not be forwarded by alt-screen emitter"
2836        );
2837        assert!(
2838            !output_str.contains('\u{009d}'),
2839            "C1 controls must be stripped from alt-screen grapheme output"
2840        );
2841    }
2842
2843    #[test]
2844    fn present_ui_inline_skips_sync_output_in_mux() {
2845        let mut output = Vec::new();
2846        {
2847            let mut writer = TerminalWriter::new(
2848                &mut output,
2849                ScreenMode::Inline { ui_height: 5 },
2850                UiAnchor::Bottom,
2851                mux_caps(),
2852            );
2853            writer.set_size(10, 10);
2854
2855            let buffer = Buffer::new(10, 5);
2856            writer.present_ui(&buffer, None, true).unwrap();
2857        }
2858
2859        assert!(
2860            !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2861            "sync begin must be suppressed in tmux/screen/zellij environments"
2862        );
2863        assert!(
2864            !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2865            "sync end must be suppressed in tmux/screen/zellij environments"
2866        );
2867    }
2868
2869    #[test]
2870    fn present_ui_altscreen_skips_sync_output_in_mux() {
2871        let mut output = Vec::new();
2872        {
2873            let mut writer = TerminalWriter::new(
2874                &mut output,
2875                ScreenMode::AltScreen,
2876                UiAnchor::Bottom,
2877                mux_caps(),
2878            );
2879            writer.set_size(10, 10);
2880
2881            let buffer = Buffer::new(10, 5);
2882            writer.present_ui(&buffer, None, true).unwrap();
2883        }
2884
2885        assert!(
2886            !output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN),
2887            "sync begin must be suppressed in tmux/screen/zellij environments"
2888        );
2889        assert!(
2890            !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2891            "sync end must be suppressed in tmux/screen/zellij environments"
2892        );
2893    }
2894
2895    #[test]
2896    fn present_ui_inline_skips_hyperlinks_in_mux() {
2897        let mut output = Vec::new();
2898        {
2899            let mut caps = mux_caps();
2900            caps.osc8_hyperlinks = true;
2901
2902            let mut writer = TerminalWriter::new(
2903                &mut output,
2904                ScreenMode::Inline { ui_height: 2 },
2905                UiAnchor::Bottom,
2906                caps,
2907            );
2908            writer.set_size(8, 4);
2909
2910            let link_id = writer.links_mut().register("https://example.com");
2911            let mut buffer = Buffer::new(8, 2);
2912            buffer.set_raw(
2913                0,
2914                0,
2915                Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2916            );
2917            writer.present_ui(&buffer, None, true).unwrap();
2918        }
2919
2920        assert!(
2921            !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2922            "OSC 8 sequences must be suppressed by mux hyperlink policy"
2923        );
2924    }
2925
2926    #[test]
2927    fn present_ui_inline_closes_hyperlinks_at_frame_end() {
2928        let mut output = Vec::new();
2929        {
2930            let mut caps = full_caps();
2931            caps.osc8_hyperlinks = true;
2932
2933            let mut writer = TerminalWriter::new(
2934                &mut output,
2935                ScreenMode::Inline { ui_height: 2 },
2936                UiAnchor::Bottom,
2937                caps,
2938            );
2939            writer.set_size(8, 4);
2940
2941            let link_id = writer.links_mut().register("https://example.com");
2942            let mut buffer = Buffer::new(8, 2);
2943            buffer.set_raw(
2944                0,
2945                0,
2946                Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2947            );
2948            writer.present_ui(&buffer, None, true).unwrap();
2949        }
2950
2951        let open = b"\x1b]8;;https://example.com\x07";
2952        let close = b"\x1b]8;;\x07";
2953        let open_pos = output
2954            .windows(open.len())
2955            .position(|window| window == open)
2956            .expect("expected OSC 8 open sequence");
2957        let close_pos = output
2958            .windows(close.len())
2959            .position(|window| window == close)
2960            .expect("expected OSC 8 close sequence");
2961        assert!(
2962            open_pos < close_pos,
2963            "hyperlink must close before frame end"
2964        );
2965    }
2966
2967    #[test]
2968    fn present_ui_altscreen_skips_hyperlinks_in_mux() {
2969        let mut output = Vec::new();
2970        {
2971            let mut caps = mux_caps();
2972            caps.osc8_hyperlinks = true;
2973
2974            let mut writer =
2975                TerminalWriter::new(&mut output, ScreenMode::AltScreen, UiAnchor::Bottom, caps);
2976            writer.set_size(8, 4);
2977
2978            let link_id = writer.links_mut().register("https://example.com");
2979            let mut buffer = Buffer::new(8, 2);
2980            buffer.set_raw(
2981                0,
2982                0,
2983                Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2984            );
2985            writer.present_ui(&buffer, None, true).unwrap();
2986        }
2987
2988        assert!(
2989            !output.windows(b"\x1b]8;".len()).any(|w| w == b"\x1b]8;"),
2990            "OSC 8 sequences must be suppressed by mux hyperlink policy"
2991        );
2992    }
2993
2994    #[test]
2995    fn present_ui_altscreen_closes_hyperlinks_at_frame_end() {
2996        let mut output = Vec::new();
2997        {
2998            let mut caps = full_caps();
2999            caps.osc8_hyperlinks = true;
3000
3001            let mut writer =
3002                TerminalWriter::new(&mut output, ScreenMode::AltScreen, UiAnchor::Bottom, caps);
3003            writer.set_size(8, 4);
3004
3005            let link_id = writer.links_mut().register("https://example.com");
3006            let mut buffer = Buffer::new(8, 2);
3007            buffer.set_raw(
3008                0,
3009                0,
3010                Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
3011            );
3012            writer.present_ui(&buffer, None, true).unwrap();
3013        }
3014
3015        let open = b"\x1b]8;;https://example.com\x07";
3016        let close = b"\x1b]8;;\x07";
3017        let open_pos = output
3018            .windows(open.len())
3019            .position(|window| window == open)
3020            .expect("expected OSC 8 open sequence");
3021        let close_pos = output
3022            .windows(close.len())
3023            .position(|window| window == close)
3024            .expect("expected OSC 8 close sequence");
3025        assert!(
3026            open_pos < close_pos,
3027            "hyperlink must close before frame end"
3028        );
3029    }
3030
3031    #[test]
3032    fn present_ui_hides_cursor_when_requested() {
3033        let mut output = Vec::new();
3034        {
3035            let mut writer = TerminalWriter::new(
3036                &mut output,
3037                ScreenMode::AltScreen,
3038                UiAnchor::Bottom,
3039                basic_caps(),
3040            );
3041            writer.set_size(10, 5);
3042
3043            let buffer = Buffer::new(10, 5);
3044            writer.present_ui(&buffer, None, false).unwrap();
3045        }
3046
3047        assert!(
3048            output.windows(6).any(|w| w == b"\x1b[?25l"),
3049            "expected cursor hide sequence"
3050        );
3051    }
3052
3053    #[test]
3054    fn present_ui_visible_with_position_temporarily_hides_cursor() {
3055        let mut output = Vec::new();
3056        {
3057            let mut writer = TerminalWriter::new(
3058                &mut output,
3059                ScreenMode::AltScreen,
3060                UiAnchor::Bottom,
3061                basic_caps(),
3062            );
3063            writer.set_size(10, 5);
3064
3065            let buffer = Buffer::new(10, 5);
3066            writer.present_ui(&buffer, Some((0, 0)), true).unwrap();
3067        }
3068
3069        assert!(
3070            output.windows(6).any(|w| w == b"\x1b[?25l"),
3071            "expected cursor hide during frame emission"
3072        );
3073    }
3074
3075    #[test]
3076    fn present_ui_visible_without_position_hides_cursor() {
3077        let mut output = Vec::new();
3078        {
3079            let mut writer = TerminalWriter::new(
3080                &mut output,
3081                ScreenMode::AltScreen,
3082                UiAnchor::Bottom,
3083                basic_caps(),
3084            );
3085            writer.set_size(10, 5);
3086
3087            let buffer = Buffer::new(10, 5);
3088            writer.present_ui(&buffer, None, true).unwrap();
3089        }
3090
3091        assert!(
3092            output.windows(6).any(|w| w == b"\x1b[?25l"),
3093            "expected cursor hide sequence when no explicit cursor position exists"
3094        );
3095    }
3096
3097    #[test]
3098    fn write_log_in_inline_mode() {
3099        let mut output = Vec::new();
3100        {
3101            let mut writer = TerminalWriter::new(
3102                &mut output,
3103                ScreenMode::Inline { ui_height: 5 },
3104                UiAnchor::Bottom,
3105                basic_caps(),
3106            );
3107            writer.write_log("test log\n").unwrap();
3108        }
3109
3110        let output_str = String::from_utf8_lossy(&output);
3111        assert!(output_str.contains("test log"));
3112    }
3113
3114    #[test]
3115    fn write_log_in_altscreen_is_noop() {
3116        let mut output = Vec::new();
3117        {
3118            let mut writer = TerminalWriter::new(
3119                &mut output,
3120                ScreenMode::AltScreen,
3121                UiAnchor::Bottom,
3122                basic_caps(),
3123            );
3124            writer.write_log("test log\n").unwrap();
3125        }
3126
3127        let output_str = String::from_utf8_lossy(&output);
3128        // Should not contain log text (altscreen drops logs)
3129        assert!(!output_str.contains("test log"));
3130    }
3131
3132    #[test]
3133    fn clear_screen_resets_prev_buffer() {
3134        let mut output = Vec::new();
3135        let mut writer = TerminalWriter::new(
3136            &mut output,
3137            ScreenMode::AltScreen,
3138            UiAnchor::Bottom,
3139            basic_caps(),
3140        );
3141
3142        // Present a buffer
3143        let buffer = Buffer::new(10, 5);
3144        writer.present_ui(&buffer, None, true).unwrap();
3145        assert!(writer.prev_buffer.is_some());
3146
3147        // Clear screen should reset
3148        writer.clear_screen().unwrap();
3149        assert!(writer.prev_buffer.is_none());
3150    }
3151
3152    #[test]
3153    fn set_size_clears_prev_buffer() {
3154        let output = Vec::new();
3155        let mut writer = TerminalWriter::new(
3156            output,
3157            ScreenMode::AltScreen,
3158            UiAnchor::Bottom,
3159            basic_caps(),
3160        );
3161
3162        writer.prev_buffer = Some(Buffer::new(10, 10));
3163        writer.set_size(20, 20);
3164
3165        assert!(writer.prev_buffer.is_none());
3166    }
3167
3168    #[test]
3169    fn inline_auto_resize_clears_cached_height() {
3170        let output = Vec::new();
3171        let mut writer = TerminalWriter::new(
3172            output,
3173            ScreenMode::InlineAuto {
3174                min_height: 3,
3175                max_height: 8,
3176            },
3177            UiAnchor::Bottom,
3178            basic_caps(),
3179        );
3180
3181        writer.set_size(80, 24);
3182        writer.set_auto_ui_height(6);
3183        assert_eq!(writer.auto_ui_height(), Some(6));
3184        assert_eq!(writer.render_height_hint(), 6);
3185
3186        writer.set_size(100, 30);
3187        assert_eq!(writer.auto_ui_height(), None);
3188        assert_eq!(writer.render_height_hint(), 8);
3189    }
3190
3191    #[test]
3192    fn drop_cleanup_restores_cursor() {
3193        let mut output = Vec::new();
3194        {
3195            let mut writer = TerminalWriter::new(
3196                &mut output,
3197                ScreenMode::Inline { ui_height: 5 },
3198                UiAnchor::Bottom,
3199                basic_caps(),
3200            );
3201            writer.cursor_saved = true;
3202            // Dropped here
3203        }
3204
3205        // Should contain cursor restore
3206        assert!(
3207            output
3208                .windows(CURSOR_RESTORE.len())
3209                .any(|w| w == CURSOR_RESTORE)
3210        );
3211    }
3212
3213    #[test]
3214    fn drop_cleanup_ends_sync_block() {
3215        let mut output = Vec::new();
3216        {
3217            let mut writer = TerminalWriter::new(
3218                &mut output,
3219                ScreenMode::Inline { ui_height: 5 },
3220                UiAnchor::Bottom,
3221                full_caps(),
3222            );
3223            writer.in_sync_block = true;
3224            // Dropped here
3225        }
3226
3227        // Should contain sync end
3228        assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
3229    }
3230
3231    #[test]
3232    fn drop_cleanup_skips_sync_end_in_mux_even_with_stale_state() {
3233        let mut output = Vec::new();
3234        {
3235            let mut writer = TerminalWriter::new(
3236                &mut output,
3237                ScreenMode::Inline { ui_height: 5 },
3238                UiAnchor::Bottom,
3239                mux_caps(),
3240            );
3241            writer.in_sync_block = true;
3242            // Dropped here
3243        }
3244
3245        assert!(
3246            !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
3247            "drop cleanup must not emit sync_end in mux environments"
3248        );
3249    }
3250
3251    #[test]
3252    fn present_multiple_frames_uses_diff() {
3253        use std::io::Cursor;
3254
3255        // Use Cursor<Vec<u8>> which allows us to track position
3256        let output = Cursor::new(Vec::new());
3257        let mut writer = TerminalWriter::new(
3258            output,
3259            ScreenMode::AltScreen,
3260            UiAnchor::Bottom,
3261            basic_caps(),
3262        );
3263        writer.set_size(10, 5);
3264
3265        // First frame - full draw
3266        let mut buffer1 = Buffer::new(10, 5);
3267        buffer1.set_raw(0, 0, Cell::from_char('A'));
3268        writer.present_ui(&buffer1, None, true).unwrap();
3269
3270        // Second frame - same content (diff is empty, minimal output)
3271        writer.present_ui(&buffer1, None, true).unwrap();
3272
3273        // Third frame - change one cell
3274        let mut buffer2 = buffer1.clone();
3275        buffer2.set_raw(1, 0, Cell::from_char('B'));
3276        writer.present_ui(&buffer2, None, true).unwrap();
3277
3278        // Test passes if it doesn't panic - the diffing is working
3279        // (Detailed output length verification would require more complex setup)
3280    }
3281
3282    #[test]
3283    fn cell_content_rendered_correctly() {
3284        let mut output = Vec::new();
3285        {
3286            let mut writer = TerminalWriter::new(
3287                &mut output,
3288                ScreenMode::AltScreen,
3289                UiAnchor::Bottom,
3290                basic_caps(),
3291            );
3292            writer.set_size(10, 5);
3293
3294            let mut buffer = Buffer::new(10, 5);
3295            buffer.set_raw(0, 0, Cell::from_char('H'));
3296            buffer.set_raw(1, 0, Cell::from_char('i'));
3297            buffer.set_raw(2, 0, Cell::from_char('!'));
3298            writer.present_ui(&buffer, None, true).unwrap();
3299        }
3300
3301        let output_str = String::from_utf8_lossy(&output);
3302        assert!(output_str.contains('H'));
3303        assert!(output_str.contains('i'));
3304        assert!(output_str.contains('!'));
3305    }
3306
3307    #[test]
3308    fn resize_reanchors_ui_region() {
3309        let output = Vec::new();
3310        let mut writer = TerminalWriter::new(
3311            output,
3312            ScreenMode::Inline { ui_height: 10 },
3313            UiAnchor::Bottom,
3314            basic_caps(),
3315        );
3316
3317        // Initial size: 80x24, UI at row 14 (24 - 10)
3318        writer.set_size(80, 24);
3319        assert_eq!(writer.ui_start_row(), 14);
3320
3321        // After resize to 80x40, UI should be at row 30 (40 - 10)
3322        writer.set_size(80, 40);
3323        assert_eq!(writer.ui_start_row(), 30);
3324
3325        // After resize to smaller 80x15, UI at row 5 (15 - 10)
3326        writer.set_size(80, 15);
3327        assert_eq!(writer.ui_start_row(), 5);
3328    }
3329
3330    #[test]
3331    fn inline_auto_height_clamps_and_uses_max_for_render() {
3332        let output = Vec::new();
3333        let mut writer = TerminalWriter::new(
3334            output,
3335            ScreenMode::InlineAuto {
3336                min_height: 3,
3337                max_height: 8,
3338            },
3339            UiAnchor::Bottom,
3340            basic_caps(),
3341        );
3342        writer.set_size(80, 24);
3343
3344        // Default to min height until measured.
3345        assert_eq!(writer.ui_height(), 3);
3346        assert_eq!(writer.auto_ui_height(), None);
3347
3348        // render_height_hint uses max to allow measurement when cache is empty.
3349        assert_eq!(writer.render_height_hint(), 8);
3350
3351        // Cache hit: render_height_hint uses cached height.
3352        writer.set_auto_ui_height(6);
3353        assert_eq!(writer.render_height_hint(), 6);
3354
3355        // Cache miss: clearing restores max hint.
3356        writer.clear_auto_ui_height();
3357        assert_eq!(writer.render_height_hint(), 8);
3358
3359        // Cache should still set when clamped to min.
3360        writer.set_auto_ui_height(3);
3361        assert_eq!(writer.auto_ui_height(), Some(3));
3362        assert_eq!(writer.ui_height(), 3);
3363
3364        writer.clear_auto_ui_height();
3365        assert_eq!(writer.render_height_hint(), 8);
3366
3367        // Clamp to max.
3368        writer.set_auto_ui_height(10);
3369        assert_eq!(writer.ui_height(), 8);
3370
3371        // Clamp to min.
3372        writer.set_auto_ui_height(1);
3373        assert_eq!(writer.ui_height(), 3);
3374    }
3375
3376    #[test]
3377    fn resize_with_top_anchor_stays_at_zero() {
3378        let output = Vec::new();
3379        let mut writer = TerminalWriter::new(
3380            output,
3381            ScreenMode::Inline { ui_height: 10 },
3382            UiAnchor::Top,
3383            basic_caps(),
3384        );
3385
3386        writer.set_size(80, 24);
3387        assert_eq!(writer.ui_start_row(), 0);
3388
3389        writer.set_size(80, 40);
3390        assert_eq!(writer.ui_start_row(), 0);
3391    }
3392
3393    #[test]
3394    fn inline_mode_never_clears_full_screen() {
3395        let mut output = Vec::new();
3396        {
3397            let mut writer = TerminalWriter::new(
3398                &mut output,
3399                ScreenMode::Inline { ui_height: 5 },
3400                UiAnchor::Bottom,
3401                basic_caps(),
3402            );
3403            writer.set_size(10, 10);
3404
3405            let buffer = Buffer::new(10, 5);
3406            writer.present_ui(&buffer, None, true).unwrap();
3407        }
3408
3409        // Should NOT contain full screen clear (ED2 = "\x1b[2J")
3410        let has_ed2 = output.windows(4).any(|w| w == b"\x1b[2J");
3411        assert!(!has_ed2, "Inline mode should never use full screen clear");
3412
3413        // Should contain individual line clears (EL = "\x1b[2K")
3414        assert!(output.windows(ERASE_LINE.len()).any(|w| w == ERASE_LINE));
3415    }
3416
3417    #[test]
3418    fn present_after_log_maintains_cursor_position() {
3419        let mut output = Vec::new();
3420        {
3421            let mut writer = TerminalWriter::new(
3422                &mut output,
3423                ScreenMode::Inline { ui_height: 5 },
3424                UiAnchor::Bottom,
3425                basic_caps(),
3426            );
3427            writer.set_size(10, 10);
3428
3429            // Present UI first
3430            let buffer = Buffer::new(10, 5);
3431            writer.present_ui(&buffer, None, true).unwrap();
3432
3433            // Write a log
3434            writer.write_log("log line\n").unwrap();
3435
3436            // Present UI again
3437            writer.present_ui(&buffer, None, true).unwrap();
3438        }
3439
3440        // Should have cursor save before each UI present. Overlay log
3441        // writes add their own balanced SAVE/RESTORE pair, so only a floor
3442        // is asserted here; balance itself is covered by the overlay tests.
3443        let save_count = output
3444            .windows(CURSOR_SAVE.len())
3445            .filter(|w| *w == CURSOR_SAVE)
3446            .count();
3447        assert!(
3448            save_count >= 2,
3449            "Should have saved cursor at least twice (once per present)"
3450        );
3451
3452        // Should have cursor restore after each UI present
3453        let restore_count = output
3454            .windows(CURSOR_RESTORE.len())
3455            .filter(|w| *w == CURSOR_RESTORE)
3456            .count();
3457        // At least 2 from presents, plus 1 from drop cleanup = 3
3458        assert!(
3459            restore_count >= 2,
3460            "Should have restored cursor at least twice"
3461        );
3462    }
3463
3464    #[test]
3465    fn ui_height_bounds_check() {
3466        let output = Vec::new();
3467        let mut writer = TerminalWriter::new(
3468            output,
3469            ScreenMode::Inline { ui_height: 100 },
3470            UiAnchor::Bottom,
3471            basic_caps(),
3472        );
3473
3474        // Terminal smaller than UI height
3475        writer.set_size(80, 10);
3476
3477        // Should saturate to 0, not underflow
3478        assert_eq!(writer.ui_start_row(), 0);
3479    }
3480
3481    #[test]
3482    fn inline_ui_height_clamped_to_terminal_height() {
3483        let mut output = Vec::new();
3484        {
3485            let mut writer = TerminalWriter::new(
3486                &mut output,
3487                ScreenMode::Inline { ui_height: 10 },
3488                UiAnchor::Bottom,
3489                basic_caps(),
3490            );
3491            writer.set_size(8, 3);
3492            let buffer = Buffer::new(8, 10);
3493            writer.present_ui(&buffer, None, true).unwrap();
3494        }
3495
3496        let max_row = max_cursor_row(&output);
3497        assert!(
3498            max_row <= 3,
3499            "cursor row {} exceeds terminal height",
3500            max_row
3501        );
3502    }
3503
3504    #[test]
3505    fn inline_diff_frame_never_emits_below_the_fold() {
3506        // Regression: the incremental-diff branch emitted every diff run
3507        // unclipped. With ui_height > term_height, a change on a buffer row
3508        // below the fold produced a CUP past the physical bottom row, which
3509        // real terminals clamp — scribbling over the last visible row. The
3510        // full-redraw branch already clipped; the diff branch must too.
3511        let mut output = Vec::new();
3512        {
3513            let mut writer = TerminalWriter::new(
3514                &mut output,
3515                ScreenMode::Inline { ui_height: 20 },
3516                UiAnchor::Bottom,
3517                basic_caps(),
3518            );
3519            writer.set_size(80, 10); // terminal shorter than the UI
3520
3521            // Frame 1: full redraw (clipped by the existing logic).
3522            let buffer = Buffer::new(80, 20);
3523            writer.present_ui(&buffer, None, true).unwrap();
3524
3525            // Frame 2: change ONLY a below-the-fold row (row 19).
3526            let mut buffer2 = Buffer::new(80, 20);
3527            buffer2.set(0, 19, ftui_render::cell::Cell::from_char('B'));
3528            writer.present_ui(&buffer2, None, true).unwrap();
3529        }
3530
3531        let max_row = max_cursor_row(&output);
3532        assert!(
3533            max_row <= 10,
3534            "diff frame emitted cursor row {max_row} past the 10-row terminal"
3535        );
3536        // The below-the-fold glyph must not appear anywhere in the output.
3537        assert!(
3538            !output.windows(1).any(|w| w == b"B"),
3539            "below-the-fold cell was emitted"
3540        );
3541    }
3542
3543    #[test]
3544    fn inline_shrink_clears_stale_rows() {
3545        let mut output = Vec::new();
3546        {
3547            let mut writer = TerminalWriter::new(
3548                &mut output,
3549                ScreenMode::InlineAuto {
3550                    min_height: 1,
3551                    max_height: 6,
3552                },
3553                UiAnchor::Bottom,
3554                basic_caps(),
3555            );
3556            writer.set_size(10, 10);
3557
3558            let buffer = Buffer::new(10, 6);
3559            writer.set_auto_ui_height(6);
3560            writer.present_ui(&buffer, None, true).unwrap();
3561
3562            writer.set_auto_ui_height(3);
3563            writer.present_ui(&buffer, None, true).unwrap();
3564        }
3565
3566        let second_save = find_nth(&output, CURSOR_SAVE, 2).expect("expected second cursor save");
3567        let after_save = &output[second_save..];
3568        let restore_idx = after_save
3569            .windows(CURSOR_RESTORE.len())
3570            .position(|w| w == CURSOR_RESTORE)
3571            .expect("expected cursor restore after second save");
3572        let segment = &after_save[..restore_idx];
3573        let erase_count = segment
3574            .windows(ERASE_LINE.len())
3575            .filter(|w| *w == ERASE_LINE)
3576            .count();
3577        let bg_reset_count = segment
3578            .windows(SGR_BG_DEFAULT.len())
3579            .filter(|w| *w == SGR_BG_DEFAULT)
3580            .count();
3581
3582        assert_eq!(erase_count, 6, "expected clears for stale + new rows");
3583        assert!(
3584            bg_reset_count >= 2,
3585            "expected background resets before row clears"
3586        );
3587    }
3588
3589    // --- Scroll-region optimization tests ---
3590
3591    /// Capabilities that enable scroll-region strategy (no mux, scroll_region + sync_output).
3592    fn scroll_region_caps() -> TerminalCapabilities {
3593        let mut caps = TerminalCapabilities::basic();
3594        caps.scroll_region = true;
3595        caps.sync_output = true;
3596        caps
3597    }
3598
3599    /// Capabilities for hybrid strategy (scroll_region but no sync_output).
3600    fn hybrid_caps() -> TerminalCapabilities {
3601        let mut caps = TerminalCapabilities::basic();
3602        caps.scroll_region = true;
3603        caps
3604    }
3605
3606    /// Capabilities that force overlay (in tmux even with scroll_region).
3607    fn mux_caps() -> TerminalCapabilities {
3608        let mut caps = TerminalCapabilities::basic();
3609        caps.scroll_region = true;
3610        caps.sync_output = true;
3611        caps.in_tmux = true;
3612        caps
3613    }
3614
3615    #[test]
3616    fn scroll_region_bounds_bottom_anchor() {
3617        let mut output = Vec::new();
3618        {
3619            let mut writer = TerminalWriter::new(
3620                &mut output,
3621                ScreenMode::Inline { ui_height: 5 },
3622                UiAnchor::Bottom,
3623                scroll_region_caps(),
3624            );
3625            writer.set_size(10, 10);
3626            let buffer = Buffer::new(10, 5);
3627            writer.present_ui(&buffer, None, true).unwrap();
3628        }
3629
3630        let seq = b"\x1b[1;5r";
3631        assert!(
3632            output.windows(seq.len()).any(|w| w == seq),
3633            "expected scroll region for bottom anchor"
3634        );
3635    }
3636
3637    #[test]
3638    fn scroll_region_bounds_top_anchor() {
3639        let mut output = Vec::new();
3640        {
3641            let mut writer = TerminalWriter::new(
3642                &mut output,
3643                ScreenMode::Inline { ui_height: 5 },
3644                UiAnchor::Top,
3645                scroll_region_caps(),
3646            );
3647            writer.set_size(10, 10);
3648            let buffer = Buffer::new(10, 5);
3649            writer.present_ui(&buffer, None, true).unwrap();
3650        }
3651
3652        let seq = b"\x1b[6;10r";
3653        assert!(
3654            output.windows(seq.len()).any(|w| w == seq),
3655            "expected scroll region for top anchor"
3656        );
3657        let cursor_seq = b"\x1b[6;1H";
3658        assert!(
3659            output.windows(cursor_seq.len()).any(|w| w == cursor_seq),
3660            "expected cursor move into log region for top anchor"
3661        );
3662    }
3663
3664    #[test]
3665    fn present_ui_inline_resets_style_before_cursor_restore() {
3666        let mut output = Vec::new();
3667        {
3668            let mut writer = TerminalWriter::new(
3669                &mut output,
3670                ScreenMode::Inline { ui_height: 2 },
3671                UiAnchor::Bottom,
3672                basic_caps(),
3673            );
3674            writer.set_size(5, 5);
3675            let mut buffer = Buffer::new(5, 2);
3676            buffer.set_raw(0, 0, Cell::from_char('X').with_fg(PackedRgba::RED));
3677            writer.present_ui(&buffer, None, true).unwrap();
3678        }
3679
3680        let seq = b"\x1b[0m\x1b8";
3681        assert!(
3682            output.windows(seq.len()).any(|w| w == seq),
3683            "expected SGR reset before cursor restore in inline mode"
3684        );
3685    }
3686
3687    #[test]
3688    fn strategy_selected_from_capabilities() {
3689        // No capabilities → OverlayRedraw
3690        let w = TerminalWriter::new(
3691            Vec::new(),
3692            ScreenMode::Inline { ui_height: 5 },
3693            UiAnchor::Bottom,
3694            basic_caps(),
3695        );
3696        assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3697
3698        // scroll_region + sync_output → ScrollRegion
3699        let w = TerminalWriter::new(
3700            Vec::new(),
3701            ScreenMode::Inline { ui_height: 5 },
3702            UiAnchor::Bottom,
3703            scroll_region_caps(),
3704        );
3705        assert_eq!(w.inline_strategy(), InlineStrategy::ScrollRegion);
3706
3707        // scroll_region only → Hybrid
3708        let w = TerminalWriter::new(
3709            Vec::new(),
3710            ScreenMode::Inline { ui_height: 5 },
3711            UiAnchor::Bottom,
3712            hybrid_caps(),
3713        );
3714        assert_eq!(w.inline_strategy(), InlineStrategy::Hybrid);
3715
3716        // In mux → OverlayRedraw even with all caps
3717        let w = TerminalWriter::new(
3718            Vec::new(),
3719            ScreenMode::Inline { ui_height: 5 },
3720            UiAnchor::Bottom,
3721            mux_caps(),
3722        );
3723        assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3724    }
3725
3726    #[test]
3727    fn scroll_region_activated_on_present() {
3728        let mut output = Vec::new();
3729        {
3730            let mut writer = TerminalWriter::new(
3731                &mut output,
3732                ScreenMode::Inline { ui_height: 5 },
3733                UiAnchor::Bottom,
3734                scroll_region_caps(),
3735            );
3736            writer.set_size(80, 24);
3737            assert!(!writer.scroll_region_active());
3738
3739            let buffer = Buffer::new(80, 5);
3740            writer.present_ui(&buffer, None, true).unwrap();
3741            assert!(writer.scroll_region_active());
3742        }
3743
3744        // Should contain DECSTBM: ESC [ 1 ; 19 r (rows 1-19 are log region)
3745        let expected = b"\x1b[1;19r";
3746        assert!(
3747            output.windows(expected.len()).any(|w| w == expected),
3748            "Should set scroll region to rows 1-19"
3749        );
3750    }
3751
3752    #[test]
3753    fn scroll_region_not_activated_for_overlay() {
3754        let mut output = Vec::new();
3755        {
3756            let mut writer = TerminalWriter::new(
3757                &mut output,
3758                ScreenMode::Inline { ui_height: 5 },
3759                UiAnchor::Bottom,
3760                basic_caps(),
3761            );
3762            writer.set_size(80, 24);
3763
3764            let buffer = Buffer::new(80, 5);
3765            writer.present_ui(&buffer, None, true).unwrap();
3766            assert!(!writer.scroll_region_active());
3767        }
3768
3769        // Should NOT contain any scroll region setup
3770        let decstbm = b"\x1b[1;19r";
3771        assert!(
3772            !output.windows(decstbm.len()).any(|w| w == decstbm),
3773            "OverlayRedraw should not set scroll region"
3774        );
3775    }
3776
3777    #[test]
3778    fn scroll_region_not_activated_in_mux() {
3779        let mut output = Vec::new();
3780        {
3781            let mut writer = TerminalWriter::new(
3782                &mut output,
3783                ScreenMode::Inline { ui_height: 5 },
3784                UiAnchor::Bottom,
3785                mux_caps(),
3786            );
3787            writer.set_size(80, 24);
3788
3789            let buffer = Buffer::new(80, 5);
3790            writer.present_ui(&buffer, None, true).unwrap();
3791            assert!(!writer.scroll_region_active());
3792        }
3793
3794        // Should NOT contain scroll region setup despite having the capability
3795        let decstbm = b"\x1b[1;19r";
3796        assert!(
3797            !output.windows(decstbm.len()).any(|w| w == decstbm),
3798            "Mux environment should not use scroll region"
3799        );
3800    }
3801
3802    #[test]
3803    fn scroll_region_reset_on_cleanup() {
3804        let mut output = Vec::new();
3805        {
3806            let mut writer = TerminalWriter::new(
3807                &mut output,
3808                ScreenMode::Inline { ui_height: 5 },
3809                UiAnchor::Bottom,
3810                scroll_region_caps(),
3811            );
3812            writer.set_size(80, 24);
3813
3814            let buffer = Buffer::new(80, 5);
3815            writer.present_ui(&buffer, None, true).unwrap();
3816            // Dropped here - cleanup should reset scroll region
3817        }
3818
3819        // Should contain scroll region reset: ESC [ r
3820        let reset = b"\x1b[r";
3821        assert!(
3822            output.windows(reset.len()).any(|w| w == reset),
3823            "Cleanup should reset scroll region"
3824        );
3825    }
3826
3827    #[test]
3828    fn scroll_region_reset_on_resize() {
3829        let output = Vec::new();
3830        let mut writer = TerminalWriter::new(
3831            output,
3832            ScreenMode::Inline { ui_height: 5 },
3833            UiAnchor::Bottom,
3834            scroll_region_caps(),
3835        );
3836        writer.set_size(80, 24);
3837
3838        // Manually activate scroll region
3839        writer.activate_scroll_region(5).unwrap();
3840        assert!(writer.scroll_region_active());
3841
3842        // Resize should deactivate it
3843        writer.set_size(80, 40);
3844        assert!(!writer.scroll_region_active());
3845    }
3846
3847    #[test]
3848    fn scroll_region_reactivated_after_resize() {
3849        let mut output = Vec::new();
3850        {
3851            let mut writer = TerminalWriter::new(
3852                &mut output,
3853                ScreenMode::Inline { ui_height: 5 },
3854                UiAnchor::Bottom,
3855                scroll_region_caps(),
3856            );
3857            writer.set_size(80, 24);
3858
3859            // First present activates scroll region
3860            let buffer = Buffer::new(80, 5);
3861            writer.present_ui(&buffer, None, true).unwrap();
3862            assert!(writer.scroll_region_active());
3863
3864            // Resize deactivates
3865            writer.set_size(80, 40);
3866            assert!(!writer.scroll_region_active());
3867
3868            // Next present re-activates with new dimensions
3869            let buffer2 = Buffer::new(80, 5);
3870            writer.present_ui(&buffer2, None, true).unwrap();
3871            assert!(writer.scroll_region_active());
3872        }
3873
3874        // Should contain the new scroll region: ESC [ 1 ; 35 r (40 - 5 = 35)
3875        let new_region = b"\x1b[1;35r";
3876        assert!(
3877            output.windows(new_region.len()).any(|w| w == new_region),
3878            "Should set scroll region to new dimensions after resize"
3879        );
3880    }
3881
3882    #[test]
3883    fn hybrid_strategy_activates_scroll_region() {
3884        let mut output = Vec::new();
3885        {
3886            let mut writer = TerminalWriter::new(
3887                &mut output,
3888                ScreenMode::Inline { ui_height: 5 },
3889                UiAnchor::Bottom,
3890                hybrid_caps(),
3891            );
3892            writer.set_size(80, 24);
3893
3894            let buffer = Buffer::new(80, 5);
3895            writer.present_ui(&buffer, None, true).unwrap();
3896            assert!(writer.scroll_region_active());
3897        }
3898
3899        // Hybrid uses scroll region as internal optimization
3900        let expected = b"\x1b[1;19r";
3901        assert!(
3902            output.windows(expected.len()).any(|w| w == expected),
3903            "Hybrid should activate scroll region as optimization"
3904        );
3905    }
3906
3907    #[test]
3908    fn altscreen_does_not_activate_scroll_region() {
3909        let output = Vec::new();
3910        let mut writer = TerminalWriter::new(
3911            output,
3912            ScreenMode::AltScreen,
3913            UiAnchor::Bottom,
3914            scroll_region_caps(),
3915        );
3916        writer.set_size(80, 24);
3917
3918        let buffer = Buffer::new(80, 24);
3919        writer.present_ui(&buffer, None, true).unwrap();
3920        assert!(!writer.scroll_region_active());
3921    }
3922
3923    #[test]
3924    fn scroll_region_still_saves_restores_cursor() {
3925        let mut output = Vec::new();
3926        {
3927            let mut writer = TerminalWriter::new(
3928                &mut output,
3929                ScreenMode::Inline { ui_height: 5 },
3930                UiAnchor::Bottom,
3931                scroll_region_caps(),
3932            );
3933            writer.set_size(80, 24);
3934
3935            let buffer = Buffer::new(80, 5);
3936            writer.present_ui(&buffer, None, true).unwrap();
3937        }
3938
3939        // Even with scroll region, cursor save/restore is used for UI presents
3940        assert!(
3941            output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE),
3942            "Scroll region mode should still save cursor"
3943        );
3944        assert!(
3945            output
3946                .windows(CURSOR_RESTORE.len())
3947                .any(|w| w == CURSOR_RESTORE),
3948            "Scroll region mode should still restore cursor"
3949        );
3950    }
3951
3952    // --- Log write cursor positioning tests (bd-xh8s) ---
3953
3954    #[test]
3955    fn write_log_positions_cursor_bottom_anchor() {
3956        // Verify log writes position cursor at the bottom of the log region
3957        // for bottom-anchored UI (log region is above UI).
3958        let mut output = Vec::new();
3959        {
3960            let mut writer = TerminalWriter::new(
3961                &mut output,
3962                ScreenMode::Inline { ui_height: 5 },
3963                UiAnchor::Bottom,
3964                basic_caps(),
3965            );
3966            writer.set_size(80, 24);
3967            writer.write_log("test log\n").unwrap();
3968        }
3969
3970        // For bottom-anchored with ui_height=5, term_height=24:
3971        // Log region is rows 1-19 (24-5=19 rows)
3972        // Cursor should be positioned at row 19 (bottom of log region)
3973        let expected_pos = b"\x1b[19;1H";
3974        assert!(
3975            output
3976                .windows(expected_pos.len())
3977                .any(|w| w == expected_pos),
3978            "Log write should position cursor at row 19 for bottom anchor"
3979        );
3980    }
3981
3982    #[test]
3983    fn write_log_positions_cursor_top_anchor() {
3984        // Verify log writes position cursor at the bottom of the log region
3985        // for top-anchored UI (log region is below UI).
3986        let mut output = Vec::new();
3987        {
3988            let mut writer = TerminalWriter::new(
3989                &mut output,
3990                ScreenMode::Inline { ui_height: 5 },
3991                UiAnchor::Top,
3992                basic_caps(),
3993            );
3994            writer.set_size(80, 24);
3995            writer.write_log("test log\n").unwrap();
3996        }
3997
3998        // For top-anchored with ui_height=5, term_height=24:
3999        // Log region is rows 6-24 (below UI)
4000        // Cursor should be positioned at row 24 (bottom of log region)
4001        let expected_pos = b"\x1b[24;1H";
4002        assert!(
4003            output
4004                .windows(expected_pos.len())
4005                .any(|w| w == expected_pos),
4006            "Log write should position cursor at row 24 for top anchor"
4007        );
4008    }
4009
4010    #[test]
4011    fn write_log_contains_text() {
4012        // Verify the log text is actually written after cursor positioning.
4013        let mut output = Vec::new();
4014        {
4015            let mut writer = TerminalWriter::new(
4016                &mut output,
4017                ScreenMode::Inline { ui_height: 5 },
4018                UiAnchor::Bottom,
4019                basic_caps(),
4020            );
4021            writer.set_size(80, 24);
4022            writer.write_log("hello world\n").unwrap();
4023        }
4024
4025        let output_str = String::from_utf8_lossy(&output);
4026        assert!(output_str.contains("hello world"));
4027    }
4028
4029    #[test]
4030    fn write_log_sanitizes_escape_injection_payloads() {
4031        let mut output = Vec::new();
4032        {
4033            let mut writer = TerminalWriter::new(
4034                &mut output,
4035                ScreenMode::Inline { ui_height: 5 },
4036                UiAnchor::Bottom,
4037                basic_caps(),
4038            );
4039            writer.set_size(80, 24);
4040            writer
4041                .write_log("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x\n")
4042                .unwrap();
4043        }
4044
4045        let output_str = String::from_utf8_lossy(&output);
4046        assert!(output_str.contains("safetailx"));
4047        assert!(
4048            !output_str.contains("52;c;SGVsbG8"),
4049            "OSC payload must not be forwarded to terminal output"
4050        );
4051        assert!(
4052            !output_str.contains('\u{009d}'),
4053            "C1 controls must be stripped from log output"
4054        );
4055    }
4056
4057    #[test]
4058    fn write_log_multiple_writes_position_each_time() {
4059        // Verify cursor is positioned for each log write.
4060        let mut output = Vec::new();
4061        {
4062            let mut writer = TerminalWriter::new(
4063                &mut output,
4064                ScreenMode::Inline { ui_height: 5 },
4065                UiAnchor::Bottom,
4066                basic_caps(),
4067            );
4068            writer.set_size(80, 24);
4069            writer.write_log("first\n").unwrap();
4070            writer.write_log("second\n").unwrap();
4071        }
4072
4073        // Should have cursor positioning twice
4074        let expected_pos = b"\x1b[19;1H";
4075        let count = output
4076            .windows(expected_pos.len())
4077            .filter(|w| *w == expected_pos)
4078            .count();
4079        assert_eq!(count, 2, "Should position cursor for each log write");
4080    }
4081
4082    #[test]
4083    fn write_log_after_present_ui_works_correctly() {
4084        // Verify log writes work correctly after UI presentation.
4085        let mut output = Vec::new();
4086        {
4087            let mut writer = TerminalWriter::new(
4088                &mut output,
4089                ScreenMode::Inline { ui_height: 5 },
4090                UiAnchor::Bottom,
4091                basic_caps(),
4092            );
4093            writer.set_size(80, 24);
4094
4095            // Present UI first
4096            let buffer = Buffer::new(80, 5);
4097            writer.present_ui(&buffer, None, true).unwrap();
4098
4099            // Then write log
4100            writer.write_log("after UI\n").unwrap();
4101        }
4102
4103        let output_str = String::from_utf8_lossy(&output);
4104        assert!(output_str.contains("after UI"));
4105
4106        // Log write should still position cursor
4107        let expected_pos = b"\x1b[19;1H";
4108        // Find position after cursor restore (log write happens after present_ui)
4109        assert!(
4110            output
4111                .windows(expected_pos.len())
4112                .any(|w| w == expected_pos),
4113            "Log write after present_ui should position cursor"
4114        );
4115    }
4116
4117    #[test]
4118    fn write_log_ui_fills_terminal_is_noop() {
4119        // When UI fills the entire terminal, there's no log region.
4120        // Drop cleanup writes reset sequences (\x1b[0m, \x1b[?25h), so we
4121        // verify the output does not contain the log text itself.
4122        let mut output = Vec::new();
4123        {
4124            let mut writer = TerminalWriter::new(
4125                &mut output,
4126                ScreenMode::Inline { ui_height: 24 },
4127                UiAnchor::Bottom,
4128                basic_caps(),
4129            );
4130            writer.set_size(80, 24);
4131            writer.write_log("should still write\n").unwrap();
4132        }
4133        // Log text must NOT appear; only Drop cleanup sequences are expected.
4134        assert!(
4135            !output
4136                .windows(b"should still write".len())
4137                .any(|w| w == b"should still write"),
4138            "write_log should not emit log text when UI fills the terminal"
4139        );
4140    }
4141
4142    #[test]
4143    fn write_log_with_scroll_region_active() {
4144        // Verify log writes work correctly when scroll region is active.
4145        let mut output = Vec::new();
4146        {
4147            let mut writer = TerminalWriter::new(
4148                &mut output,
4149                ScreenMode::Inline { ui_height: 5 },
4150                UiAnchor::Bottom,
4151                scroll_region_caps(),
4152            );
4153            writer.set_size(80, 24);
4154
4155            // Present UI to activate scroll region
4156            let buffer = Buffer::new(80, 5);
4157            writer.present_ui(&buffer, None, true).unwrap();
4158            assert!(writer.scroll_region_active());
4159
4160            // Log write should still position cursor
4161            writer.write_log("with scroll region\n").unwrap();
4162        }
4163
4164        let output_str = String::from_utf8_lossy(&output);
4165        assert!(output_str.contains("with scroll region"));
4166    }
4167
4168    #[test]
4169    fn log_write_cursor_position_not_in_ui_region_bottom_anchor() {
4170        // Verify the cursor position for log writes is never in the UI region.
4171        // For bottom-anchored with ui_height=5, term_height=24:
4172        // UI region is rows 20-24 (1-indexed)
4173        // Log region is rows 1-19
4174        // Log cursor should be at row 19 (bottom of log region)
4175        let mut output = Vec::new();
4176        {
4177            let mut writer = TerminalWriter::new(
4178                &mut output,
4179                ScreenMode::Inline { ui_height: 5 },
4180                UiAnchor::Bottom,
4181                basic_caps(),
4182            );
4183            writer.set_size(80, 24);
4184            writer.write_log("test\n").unwrap();
4185        }
4186
4187        // Parse cursor position commands in output
4188        // Looking for ESC [ row ; col H patterns
4189        let mut found_row = None;
4190        let mut i = 0;
4191        while i + 2 < output.len() {
4192            if output[i] == 0x1b && output[i + 1] == b'[' {
4193                let mut j = i + 2;
4194                let mut row: u16 = 0;
4195                while j < output.len() && output[j].is_ascii_digit() {
4196                    row = row * 10 + (output[j] - b'0') as u16;
4197                    j += 1;
4198                }
4199                if j < output.len() && output[j] == b';' {
4200                    j += 1;
4201                    while j < output.len() && output[j].is_ascii_digit() {
4202                        j += 1;
4203                    }
4204                    if j < output.len() && output[j] == b'H' {
4205                        found_row = Some(row);
4206                    }
4207                }
4208            }
4209            i += 1;
4210        }
4211
4212        if let Some(row) = found_row {
4213            // UI region starts at row 20 (24 - 5 + 1 = 20)
4214            assert!(
4215                row < 20,
4216                "Log cursor row {} should be below UI start row 20",
4217                row
4218            );
4219        }
4220    }
4221
4222    #[test]
4223    fn log_write_cursor_position_not_in_ui_region_top_anchor() {
4224        // Verify the cursor position for log writes is never in the UI region.
4225        // For top-anchored with ui_height=5, term_height=24:
4226        // UI region is rows 1-5 (1-indexed)
4227        // Log region is rows 6-24
4228        // Log cursor should be at row 24 (bottom of log region)
4229        let mut output = Vec::new();
4230        {
4231            let mut writer = TerminalWriter::new(
4232                &mut output,
4233                ScreenMode::Inline { ui_height: 5 },
4234                UiAnchor::Top,
4235                basic_caps(),
4236            );
4237            writer.set_size(80, 24);
4238            writer.write_log("test\n").unwrap();
4239        }
4240
4241        // Parse cursor position commands in output
4242        let mut found_row = None;
4243        let mut i = 0;
4244        while i + 2 < output.len() {
4245            if output[i] == 0x1b && output[i + 1] == b'[' {
4246                let mut j = i + 2;
4247                let mut row: u16 = 0;
4248                while j < output.len() && output[j].is_ascii_digit() {
4249                    row = row * 10 + (output[j] - b'0') as u16;
4250                    j += 1;
4251                }
4252                if j < output.len() && output[j] == b';' {
4253                    j += 1;
4254                    while j < output.len() && output[j].is_ascii_digit() {
4255                        j += 1;
4256                    }
4257                    if j < output.len() && output[j] == b'H' {
4258                        found_row = Some(row);
4259                    }
4260                }
4261            }
4262            i += 1;
4263        }
4264
4265        if let Some(row) = found_row {
4266            // UI region is rows 1-5
4267            assert!(
4268                row > 5,
4269                "Log cursor row {} should be above UI end row 5",
4270                row
4271            );
4272        }
4273    }
4274
4275    #[test]
4276    fn present_ui_positions_cursor_after_restore() {
4277        let mut output = Vec::new();
4278        {
4279            let mut writer = TerminalWriter::new(
4280                &mut output,
4281                ScreenMode::Inline { ui_height: 5 },
4282                UiAnchor::Bottom,
4283                basic_caps(),
4284            );
4285            writer.set_size(80, 24);
4286
4287            let buffer = Buffer::new(80, 5);
4288            // Request cursor at (2, 1) in UI coordinates
4289            writer.present_ui(&buffer, Some((2, 1)), true).unwrap();
4290        }
4291
4292        // UI starts at row 20 (24 - 5 + 1 = 20) (1-indexed)
4293        // Cursor requested at relative (2, 1) -> (x=3, y=2) (1-indexed)
4294        // Absolute position: y = 20 + 1 = 21. x = 3.
4295        let expected_pos = b"\x1b[21;3H";
4296
4297        // Find restore
4298        let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4299        let after_restore = &output[restore_idx..];
4300
4301        // Ensure cursor positioning happens *after* restore
4302        assert!(
4303            after_restore
4304                .windows(expected_pos.len())
4305                .any(|w| w == expected_pos),
4306            "Cursor positioning should happen after restore"
4307        );
4308    }
4309
4310    #[test]
4311    fn present_ui_inline_skips_cursor_position_when_x_is_out_of_bounds() {
4312        let mut output = Vec::new();
4313        {
4314            let mut writer = TerminalWriter::new(
4315                &mut output,
4316                ScreenMode::Inline { ui_height: 5 },
4317                UiAnchor::Bottom,
4318                basic_caps(),
4319            );
4320            writer.set_size(80, 24);
4321
4322            let buffer = Buffer::new(4, 5);
4323            writer.present_ui(&buffer, Some((4, 1)), true).unwrap();
4324        }
4325
4326        let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4327        let after_restore = &output[restore_idx..];
4328        let invalid_pos = b"\x1b[21;5H";
4329        assert!(
4330            !after_restore
4331                .windows(invalid_pos.len())
4332                .any(|w| w == invalid_pos),
4333            "inline cursor should not move to x outside the buffer width"
4334        );
4335    }
4336
4337    #[test]
4338    fn present_ui_inline_skips_cursor_position_when_y_is_below_buffer_height() {
4339        let mut output = Vec::new();
4340        {
4341            let mut writer = TerminalWriter::new(
4342                &mut output,
4343                ScreenMode::Inline { ui_height: 5 },
4344                UiAnchor::Bottom,
4345                basic_caps(),
4346            );
4347            writer.set_size(80, 24);
4348
4349            let buffer = Buffer::new(4, 2);
4350            writer.present_ui(&buffer, Some((1, 4)), true).unwrap();
4351        }
4352
4353        let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
4354        let after_restore = &output[restore_idx..];
4355        let invalid_pos = b"\x1b[24;2H";
4356        assert!(
4357            !after_restore
4358                .windows(invalid_pos.len())
4359                .any(|w| w == invalid_pos),
4360            "inline cursor should not move below the buffer height just because the inline region is taller"
4361        );
4362    }
4363
4364    // =========================================================================
4365    // RuntimeDiffConfig tests
4366    // =========================================================================
4367
4368    #[test]
4369    fn runtime_diff_config_default() {
4370        let config = RuntimeDiffConfig::default();
4371        assert!(config.bayesian_enabled);
4372        assert!(config.dirty_rows_enabled);
4373        assert!(config.dirty_span_config.enabled);
4374        assert!(config.tile_diff_config.enabled);
4375        assert!(config.reset_on_resize);
4376        assert!(config.reset_on_invalidation);
4377        assert_eq!(config.full_redraw_interval_frames, 240);
4378    }
4379
4380    #[test]
4381    fn runtime_diff_config_builder() {
4382        let custom_span = DirtySpanConfig::default().with_max_spans_per_row(8);
4383        let tile_config = TileDiffConfig::default()
4384            .with_enabled(false)
4385            .with_tile_size(24, 12)
4386            .with_dense_tile_ratio(0.75)
4387            .with_max_tiles(2048);
4388        let config = RuntimeDiffConfig::new()
4389            .with_bayesian_enabled(false)
4390            .with_dirty_rows_enabled(false)
4391            .with_dirty_span_config(custom_span)
4392            .with_dirty_spans_enabled(false)
4393            .with_tile_diff_config(tile_config)
4394            .with_reset_on_resize(false)
4395            .with_reset_on_invalidation(false)
4396            .with_full_redraw_interval_frames(17);
4397
4398        assert!(!config.bayesian_enabled);
4399        assert!(!config.dirty_rows_enabled);
4400        assert!(!config.dirty_span_config.enabled);
4401        assert_eq!(config.dirty_span_config.max_spans_per_row, 8);
4402        assert!(!config.tile_diff_config.enabled);
4403        assert_eq!(config.tile_diff_config.tile_w, 24);
4404        assert_eq!(config.tile_diff_config.tile_h, 12);
4405        assert_eq!(config.tile_diff_config.max_tiles, 2048);
4406        assert!(!config.reset_on_resize);
4407        assert!(!config.reset_on_invalidation);
4408        assert_eq!(config.full_redraw_interval_frames, 17);
4409    }
4410
4411    #[test]
4412    fn with_diff_config_applies_strategy_config() {
4413        use ftui_render::diff_strategy::DiffStrategyConfig;
4414
4415        let strategy_config = DiffStrategyConfig {
4416            prior_alpha: 5.0,
4417            prior_beta: 5.0,
4418            ..Default::default()
4419        };
4420
4421        let runtime_config =
4422            RuntimeDiffConfig::default().with_strategy_config(strategy_config.clone());
4423
4424        let writer = TerminalWriter::with_diff_config(
4425            Vec::<u8>::new(),
4426            ScreenMode::AltScreen,
4427            UiAnchor::Bottom,
4428            basic_caps(),
4429            runtime_config,
4430        );
4431
4432        // Verify the strategy config was applied
4433        let (alpha, beta) = writer.diff_strategy().posterior_params();
4434        assert!((alpha - 5.0).abs() < 0.001);
4435        assert!((beta - 5.0).abs() < 0.001);
4436    }
4437
4438    #[test]
4439    fn with_diff_config_applies_tile_config() {
4440        let tile_config = TileDiffConfig::default()
4441            .with_enabled(false)
4442            .with_tile_size(32, 16)
4443            .with_max_tiles(1024);
4444        let runtime_config = RuntimeDiffConfig::default().with_tile_diff_config(tile_config);
4445
4446        let mut writer = TerminalWriter::with_diff_config(
4447            Vec::<u8>::new(),
4448            ScreenMode::AltScreen,
4449            UiAnchor::Bottom,
4450            basic_caps(),
4451            runtime_config,
4452        );
4453
4454        let applied = writer.diff_scratch.tile_config_mut();
4455        assert!(!applied.enabled);
4456        assert_eq!(applied.tile_w, 32);
4457        assert_eq!(applied.tile_h, 16);
4458        assert_eq!(applied.max_tiles, 1024);
4459    }
4460
4461    #[test]
4462    fn diff_config_accessor() {
4463        let config = RuntimeDiffConfig::default().with_bayesian_enabled(false);
4464
4465        let writer = TerminalWriter::with_diff_config(
4466            Vec::<u8>::new(),
4467            ScreenMode::AltScreen,
4468            UiAnchor::Bottom,
4469            basic_caps(),
4470            config,
4471        );
4472
4473        assert!(!writer.diff_config().bayesian_enabled);
4474    }
4475
4476    #[test]
4477    fn last_diff_strategy_updates_after_present() {
4478        let mut output = Vec::new();
4479        let mut writer = TerminalWriter::with_diff_config(
4480            &mut output,
4481            ScreenMode::AltScreen,
4482            UiAnchor::Bottom,
4483            basic_caps(),
4484            RuntimeDiffConfig::default(),
4485        );
4486        writer.set_size(10, 3);
4487
4488        let mut buffer = Buffer::new(10, 3);
4489        buffer.set_raw(0, 0, Cell::from_char('X'));
4490
4491        assert!(writer.last_diff_strategy().is_none());
4492        writer.present_ui(&buffer, None, false).unwrap();
4493        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4494
4495        buffer.set_raw(1, 1, Cell::from_char('Y'));
4496        writer.present_ui(&buffer, None, false).unwrap();
4497        assert!(writer.last_diff_strategy().is_some());
4498    }
4499
4500    #[test]
4501    fn full_redraw_interval_forces_terminal_resync() {
4502        let mut output = Vec::new();
4503        let mut writer = TerminalWriter::with_diff_config(
4504            &mut output,
4505            ScreenMode::AltScreen,
4506            UiAnchor::Bottom,
4507            basic_caps(),
4508            RuntimeDiffConfig::default()
4509                .with_bayesian_enabled(false)
4510                .with_full_redraw_interval_frames(1),
4511        );
4512        writer.set_size(4, 2);
4513
4514        let mut buffer = Buffer::new(4, 2);
4515        buffer.set_raw(0, 0, Cell::from_char('A'));
4516
4517        writer.present_ui(&buffer, None, false).unwrap();
4518        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4519
4520        writer.present_ui(&buffer, None, false).unwrap();
4521        assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4522
4523        writer.present_ui(&buffer, None, false).unwrap();
4524        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4525    }
4526
4527    #[test]
4528    fn full_redraw_interval_emits_current_frame_after_incremental_baseline() {
4529        let state = Rc::new(RefCell::new(FaultState::default()));
4530        let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), usize::MAX, 1);
4531        let mut writer = TerminalWriter::with_diff_config(
4532            writer_backend,
4533            ScreenMode::AltScreen,
4534            UiAnchor::Bottom,
4535            basic_caps(),
4536            RuntimeDiffConfig::default()
4537                .with_bayesian_enabled(false)
4538                .with_full_redraw_interval_frames(1),
4539        );
4540        writer.set_size(4, 2);
4541
4542        let mut buffer = Buffer::new(4, 2);
4543        buffer.set_raw(0, 0, Cell::from_char('Q'));
4544
4545        writer.present_ui(&buffer, None, false).unwrap();
4546        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4547
4548        buffer.set_raw(1, 0, Cell::from_char('Z'));
4549        writer.present_ui(&buffer, None, false).unwrap();
4550        assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4551
4552        state.borrow_mut().bytes.clear();
4553        writer.present_ui(&buffer, None, false).unwrap();
4554        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4555
4556        let bytes = state.borrow().bytes.clone();
4557        assert!(
4558            bytes.windows(b"QZ".len()).any(|window| window == b"QZ"),
4559            "forced full redraw should emit adjacent current-frame cells"
4560        );
4561    }
4562
4563    #[test]
4564    fn full_redraw_interval_zero_disables_terminal_resync() {
4565        let mut output = Vec::new();
4566        let mut writer = TerminalWriter::with_diff_config(
4567            &mut output,
4568            ScreenMode::AltScreen,
4569            UiAnchor::Bottom,
4570            basic_caps(),
4571            RuntimeDiffConfig::default()
4572                .with_bayesian_enabled(false)
4573                .with_full_redraw_interval_frames(0),
4574        );
4575        writer.set_size(4, 2);
4576
4577        let mut buffer = Buffer::new(4, 2);
4578        buffer.set_raw(0, 0, Cell::from_char('A'));
4579
4580        writer.present_ui(&buffer, None, false).unwrap();
4581        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4582
4583        for _ in 0..5 {
4584            writer.present_ui(&buffer, None, false).unwrap();
4585            assert_ne!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4586        }
4587    }
4588
4589    #[test]
4590    fn full_redraw_max_interval_zero_forces_resync_every_frame() {
4591        // A zero wall-clock interval means "always due": every present must be a
4592        // full physical redraw, regardless of the frame counter. This is the
4593        // wall-clock bound that repairs terminal-state desync on an idle /
4594        // sparsely-rendering TUI where the frame counter advances too slowly.
4595        let mut output = Vec::new();
4596        let mut writer = TerminalWriter::with_diff_config(
4597            &mut output,
4598            ScreenMode::AltScreen,
4599            UiAnchor::Bottom,
4600            basic_caps(),
4601            RuntimeDiffConfig::default()
4602                .with_bayesian_enabled(false)
4603                // Disable the frame-count path to isolate the wall-clock bound.
4604                .with_full_redraw_interval_frames(0)
4605                .with_full_redraw_max_interval(Some(std::time::Duration::ZERO)),
4606        );
4607        writer.set_size(4, 2);
4608
4609        let mut buffer = Buffer::new(4, 2);
4610        buffer.set_raw(0, 0, Cell::from_char('A'));
4611
4612        // First frame is a full redraw (no prior baseline) and every subsequent
4613        // frame is forced full by the zero interval.
4614        for _ in 0..5 {
4615            writer.present_ui(&buffer, None, false).unwrap();
4616            assert_eq!(
4617                writer.last_diff_strategy(),
4618                Some(DiffStrategy::FullRedraw),
4619                "zero wall-clock interval must force a full redraw every frame"
4620            );
4621        }
4622    }
4623
4624    #[test]
4625    fn full_redraw_max_interval_none_keeps_incremental_after_baseline() {
4626        // The wall-clock bound is opt-in: with it unset (None) and the
4627        // frame-count path disabled, frames stay incremental after the initial
4628        // baseline full redraw — preserving the default sparse-diff behavior and
4629        // deterministic-test reproducibility.
4630        let mut output = Vec::new();
4631        let mut writer = TerminalWriter::with_diff_config(
4632            &mut output,
4633            ScreenMode::AltScreen,
4634            UiAnchor::Bottom,
4635            basic_caps(),
4636            RuntimeDiffConfig::default()
4637                .with_bayesian_enabled(false)
4638                .with_full_redraw_interval_frames(0)
4639                .with_full_redraw_max_interval(None),
4640        );
4641        writer.set_size(4, 2);
4642
4643        let mut buffer = Buffer::new(4, 2);
4644        buffer.set_raw(0, 0, Cell::from_char('A'));
4645
4646        writer.present_ui(&buffer, None, false).unwrap();
4647        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
4648
4649        for _ in 0..5 {
4650            writer.present_ui(&buffer, None, false).unwrap();
4651            assert_ne!(
4652                writer.last_diff_strategy(),
4653                Some(DiffStrategy::FullRedraw),
4654                "no wall-clock bound must leave frames incremental after baseline"
4655            );
4656        }
4657    }
4658
4659    #[test]
4660    fn diff_decision_evidence_schema_includes_span_fields() {
4661        let evidence_path = temp_evidence_path("diff_decision_schema");
4662        let sink = EvidenceSink::from_config(
4663            &crate::evidence_sink::EvidenceSinkConfig::enabled_file(&evidence_path),
4664        )
4665        .expect("evidence sink config")
4666        .expect("evidence sink enabled");
4667
4668        let mut writer = TerminalWriter::with_diff_config(
4669            Vec::<u8>::new(),
4670            ScreenMode::AltScreen,
4671            UiAnchor::Bottom,
4672            basic_caps(),
4673            RuntimeDiffConfig::default(),
4674        )
4675        .with_evidence_sink(sink);
4676        writer.set_size(10, 3);
4677
4678        let mut buffer = Buffer::new(10, 3);
4679        buffer.set_raw(0, 0, Cell::from_char('X'));
4680        writer.present_ui(&buffer, None, false).unwrap();
4681
4682        buffer.set_raw(1, 1, Cell::from_char('Y'));
4683        writer.present_ui(&buffer, None, false).unwrap();
4684
4685        let jsonl = std::fs::read_to_string(&evidence_path).expect("read evidence jsonl");
4686        let line = jsonl
4687            .lines()
4688            .find(|line| line.contains("\"event\":\"diff_decision\""))
4689            .expect("diff_decision line");
4690        let value: serde_json::Value = serde_json::from_str(line).expect("valid json");
4691
4692        assert_eq!(
4693            value["schema_version"],
4694            crate::evidence_sink::EVIDENCE_SCHEMA_VERSION
4695        );
4696        assert_eq!(value["event"], "diff_decision");
4697        assert!(
4698            value["run_id"]
4699                .as_str()
4700                .map(|s| !s.is_empty())
4701                .unwrap_or(false),
4702            "run_id should be a non-empty string"
4703        );
4704        assert!(
4705            value["event_idx"].is_number(),
4706            "event_idx should be numeric"
4707        );
4708        assert_eq!(value["screen_mode"], "altscreen");
4709        assert!(value["cols"].is_number(), "cols should be numeric");
4710        assert!(value["rows"].is_number(), "rows should be numeric");
4711        assert!(
4712            value["span_count"].is_number(),
4713            "span_count should be numeric"
4714        );
4715        assert!(
4716            value["span_coverage_pct"].is_number(),
4717            "span_coverage_pct should be numeric"
4718        );
4719        assert!(
4720            value["tile_size"].is_number(),
4721            "tile_size should be numeric"
4722        );
4723        assert!(
4724            value["dirty_tile_count"].is_number(),
4725            "dirty_tile_count should be numeric"
4726        );
4727        assert!(
4728            value["skipped_tile_count"].is_number(),
4729            "skipped_tile_count should be numeric"
4730        );
4731        assert!(
4732            value["sat_build_cost_est"].is_number(),
4733            "sat_build_cost_est should be numeric"
4734        );
4735        assert!(
4736            value["fallback_reason"].is_string(),
4737            "fallback_reason should be string"
4738        );
4739        assert!(
4740            value["scan_cost_estimate"].is_number(),
4741            "scan_cost_estimate should be numeric"
4742        );
4743        assert!(
4744            value["max_span_len"].is_number(),
4745            "max_span_len should be numeric"
4746        );
4747        assert!(
4748            value["guard_reason"].is_string(),
4749            "guard_reason should be a string"
4750        );
4751        assert!(
4752            value["hysteresis_applied"].is_boolean(),
4753            "hysteresis_applied should be boolean"
4754        );
4755        assert!(
4756            value["hysteresis_ratio"].is_number(),
4757            "hysteresis_ratio should be numeric"
4758        );
4759        assert!(
4760            value["fallback_reason"].is_string(),
4761            "fallback_reason should be a string"
4762        );
4763        assert!(
4764            value["scan_cost_estimate"].is_number(),
4765            "scan_cost_estimate should be numeric"
4766        );
4767    }
4768
4769    #[test]
4770    fn diff_strategy_posterior_updates_with_total_cells() {
4771        let mut output = Vec::new();
4772        let mut writer = TerminalWriter::with_diff_config(
4773            &mut output,
4774            ScreenMode::AltScreen,
4775            UiAnchor::Bottom,
4776            basic_caps(),
4777            RuntimeDiffConfig::default(),
4778        );
4779        writer.set_size(10, 10);
4780
4781        let mut buffer = Buffer::new(10, 10);
4782        buffer.set_raw(0, 0, Cell::from_char('A'));
4783        writer.present_ui(&buffer, None, false).unwrap();
4784
4785        let mut buffer2 = Buffer::new(10, 10);
4786        for x in 0..10u16 {
4787            buffer2.set_raw(x, 0, Cell::from_char('X'));
4788        }
4789        writer.present_ui(&buffer2, None, false).unwrap();
4790
4791        let config = writer.diff_strategy().config().clone();
4792        let total_cells = 10usize * 10usize;
4793        let changed = 10usize;
4794        let alpha = config.prior_alpha * config.decay + changed as f64;
4795        let beta = config.prior_beta * config.decay + (total_cells - changed) as f64;
4796        let expected = alpha / (alpha + beta);
4797        let mean = writer.diff_strategy().posterior_mean();
4798        assert!(
4799            (mean - expected).abs() < 1e-9,
4800            "posterior mean should use total_cells; got {mean:.6}, expected {expected:.6}"
4801        );
4802    }
4803
4804    #[test]
4805    fn log_write_without_scroll_region_preserves_diff_strategy() {
4806        // Overlay log writes never scroll the screen (single erased-and-
4807        // rewritten line, no unqualified LF), so the displayed UI baseline
4808        // stays valid and the diff strategy posterior must be PRESERVED —
4809        // the old reset-to-priors behavior compensated for log-induced
4810        // scrolling that no longer happens.
4811        let mut output = Vec::new();
4812        {
4813            let config = RuntimeDiffConfig::default();
4814            let mut writer = TerminalWriter::with_diff_config(
4815                &mut output,
4816                ScreenMode::Inline { ui_height: 5 },
4817                UiAnchor::Bottom,
4818                basic_caps(), // no scroll region support
4819                config,
4820            );
4821            writer.set_size(80, 24);
4822
4823            // Present a frame and observe some changes to modify posterior
4824            let mut buffer = Buffer::new(80, 5);
4825            buffer.set_raw(0, 0, Cell::from_char('X'));
4826            writer.present_ui(&buffer, None, false).unwrap();
4827
4828            // Present another frame
4829            buffer.set_raw(1, 1, Cell::from_char('Y'));
4830            writer.present_ui(&buffer, None, false).unwrap();
4831
4832            let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4833
4834            // Log write without scroll region must not disturb diff state
4835            assert!(!writer.scroll_region_active());
4836            writer.write_log("log message\n").unwrap();
4837
4838            let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4839            assert!(
4840                (alpha_after - alpha_before).abs() < 0.01
4841                    && (beta_after - beta_before).abs() < 0.01,
4842                "posterior should be preserved across overlay log write: \
4843                 before=({}, {}), after=({}, {})",
4844                alpha_before,
4845                beta_before,
4846                alpha_after,
4847                beta_after
4848            );
4849        }
4850    }
4851
4852    #[test]
4853    fn log_write_with_scroll_region_preserves_diff_strategy() {
4854        // When scroll region is active, log writes should NOT reset diff strategy
4855        let mut output = Vec::new();
4856        {
4857            let config = RuntimeDiffConfig::default();
4858            let mut writer = TerminalWriter::with_diff_config(
4859                &mut output,
4860                ScreenMode::Inline { ui_height: 5 },
4861                UiAnchor::Bottom,
4862                scroll_region_caps(), // has scroll region support
4863                config,
4864            );
4865            writer.set_size(80, 24);
4866
4867            // Present frames to activate scroll region and update posterior
4868            let mut buffer = Buffer::new(80, 5);
4869            buffer.set_raw(0, 0, Cell::from_char('X'));
4870            writer.present_ui(&buffer, None, false).unwrap();
4871
4872            buffer.set_raw(1, 1, Cell::from_char('Y'));
4873            writer.present_ui(&buffer, None, false).unwrap();
4874
4875            assert!(writer.scroll_region_active());
4876
4877            // Get posterior before log write
4878            let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4879
4880            // Log write with scroll region active should NOT reset
4881            writer.write_log("log message\n").unwrap();
4882
4883            let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4884            assert!(
4885                (alpha_after - alpha_before).abs() < 0.01
4886                    && (beta_after - beta_before).abs() < 0.01,
4887                "posterior should be preserved with scroll region: before=({}, {}), after=({}, {})",
4888                alpha_before,
4889                beta_before,
4890                alpha_after,
4891                beta_after
4892            );
4893        }
4894    }
4895
4896    // --- Overlay log discipline regressions (bd-th0p6 / bd-le0q8) ---
4897
4898    /// CONTRACT: without a scroll region, write_log must never emit an
4899    /// unqualified LF (it would scroll the whole screen and displace the UI)
4900    /// and must erase the line before rewriting it so shorter lines cannot
4901    /// leave remnants of longer predecessors.
4902    #[test]
4903    fn overlay_write_log_emits_single_erased_line_no_lf() {
4904        let mut output = Vec::new();
4905        {
4906            let mut writer = TerminalWriter::new(
4907                &mut output,
4908                ScreenMode::Inline { ui_height: 5 },
4909                UiAnchor::Bottom,
4910                basic_caps(),
4911            );
4912            writer.set_size(80, 24);
4913            writer.write_log("first line\n").unwrap();
4914        }
4915
4916        assert!(
4917            !output.contains(&b'\n'),
4918            "overlay write_log must never emit a raw LF byte"
4919        );
4920        assert!(
4921            contains_bytes(&output, ERASE_LINE),
4922            "overlay write_log must erase the log row before writing"
4923        );
4924        assert!(
4925            contains_bytes(&output, b"first line"),
4926            "the (single-line) log payload must be emitted"
4927        );
4928    }
4929
4930    /// CONTRACT: multi-line payloads collapse to their first display line in
4931    /// overlay mode — subsequent lines must never scribble toward the UI.
4932    #[test]
4933    fn overlay_write_log_drops_lines_after_first() {
4934        let mut output = Vec::new();
4935        {
4936            let mut writer = TerminalWriter::new(
4937                &mut output,
4938                ScreenMode::Inline { ui_height: 5 },
4939                UiAnchor::Bottom,
4940                basic_caps(),
4941            );
4942            writer.set_size(80, 24);
4943            writer.write_log("only me\nhidden tail\n").unwrap();
4944        }
4945
4946        let text = String::from_utf8_lossy(&output);
4947        assert!(text.contains("only me"), "first line must be emitted");
4948        assert!(
4949            !text.contains("hidden tail"),
4950            "second line must be dropped in overlay mode"
4951        );
4952    }
4953
4954    /// CONTRACT (bd-th0p6 top-anchor): with a top-anchored UI and no scroll
4955    /// region, log writes park on the screen's bottom row but never emit LF,
4956    /// so nothing can push UI rows into scrollback.
4957    #[test]
4958    fn overlay_top_anchor_write_log_never_scrolls() {
4959        let mut output = Vec::new();
4960        {
4961            let mut writer = TerminalWriter::new(
4962                &mut output,
4963                ScreenMode::Inline { ui_height: 5 },
4964                UiAnchor::Top,
4965                basic_caps(),
4966            );
4967            writer.set_size(80, 24);
4968            writer.write_log("top anchored\n").unwrap();
4969        }
4970
4971        assert!(
4972            !output.contains(&b'\n'),
4973            "top-anchor overlay logs must not emit LF (screen-scroll hazard)"
4974        );
4975        assert!(
4976            contains_bytes(&output, b"\x1b[24;1H"),
4977            "log should target the screen's bottom row (below the UI)"
4978        );
4979        assert!(contains_bytes(&output, b"top anchored"));
4980    }
4981
4982    /// CONTRACT (bd-th0p6): log payloads are clamped to the terminal width so
4983    /// they cannot wrap into the first UI row.
4984    #[test]
4985    fn overlay_write_log_clamps_to_terminal_width() {
4986        let long_line = "x".repeat(100);
4987        let mut output = Vec::new();
4988        {
4989            let mut writer = TerminalWriter::new(
4990                &mut output,
4991                ScreenMode::Inline { ui_height: 5 },
4992                UiAnchor::Bottom,
4993                basic_caps(),
4994            );
4995            writer.set_size(80, 24);
4996            writer.write_log(&long_line).unwrap();
4997        }
4998
4999        let emitted_xs = output.iter().filter(|&&b| b == b'x').count();
5000        assert_eq!(
5001            emitted_xs, 80,
5002            "exactly term_width columns of the log line must be emitted"
5003        );
5004    }
5005
5006    /// never inside it — instead of using the pending effective height.
5007    #[test]
5008    fn inline_auto_shrink_positions_log_against_displayed_region() {
5009        let mut output = Vec::new();
5010        {
5011            let mut writer = TerminalWriter::new(
5012                &mut output,
5013                ScreenMode::InlineAuto {
5014                    min_height: 1,
5015                    max_height: 8,
5016                },
5017                UiAnchor::Bottom,
5018                basic_caps(),
5019            );
5020            writer.set_size(80, 24);
5021
5022            // Measure to the max: display an 8-row UI (rows 17-24,
5023            // region start 16, 0-indexed).
5024            writer.set_auto_ui_height(8);
5025            let buffer = Buffer::new(80, 8);
5026            writer.present_ui(&buffer, None, false).unwrap();
5027
5028            // Remeasure drops the pending effective height to min=1 while
5029            // rows 17-24 are still physically on screen.
5030            writer.clear_auto_ui_height();
5031
5032            // A Cmd::Log before the next present must land ABOVE row 17.
5033            writer.write_log("mid-flight\n").unwrap();
5034        }
5035
5036        // The overlay log write emits exactly SAVE + CUP + EL + payload, a
5037        // contiguous pattern no present path produces (presents emit more
5038        // bytes between SAVE and any CUP). Asserting on that whole pattern
5039        // scopes the check to the log write alone.
5040        assert!(
5041            contains_bytes(&output, b"\x1b7\x1b[16;1H\x1b[2K"),
5042            "log must anchor one row above the displayed region start"
5043        );
5044        for row in 17..=24 {
5045            assert!(
5046                !contains_bytes(&output, format!("\x1b7\x1b[{row};1H").as_bytes()),
5047                "log must not position inside displayed UI row {row}"
5048            );
5049        }
5050        assert!(contains_bytes(&output, b"mid-flight"));
5051    }
5052
5053    /// CONTRACT: set_size invalidates the displayed-region cache — old
5054    /// geometry must not steer log positioning or shrink-erasure afterward.
5055    #[test]
5056    fn set_size_clears_last_inline_region() {
5057        let mut writer = TerminalWriter::new(
5058            Vec::new(),
5059            ScreenMode::Inline { ui_height: 5 },
5060            UiAnchor::Bottom,
5061            basic_caps(),
5062        );
5063        writer.set_size(80, 24);
5064        let buffer = Buffer::new(80, 5);
5065        writer.present_ui(&buffer, None, false).unwrap();
5066        assert!(writer.last_inline_region.is_some());
5067
5068        writer.set_size(80, 30);
5069        assert!(
5070            writer.last_inline_region.is_none(),
5071            "resize must invalidate the displayed-region cache"
5072        );
5073    }
5074    #[test]
5075    fn strategy_selection_config_flags_applied() {
5076        // Verify that RuntimeDiffConfig flags are correctly stored and accessible
5077        let config = RuntimeDiffConfig::default()
5078            .with_dirty_rows_enabled(false)
5079            .with_bayesian_enabled(false);
5080
5081        let writer = TerminalWriter::with_diff_config(
5082            Vec::<u8>::new(),
5083            ScreenMode::AltScreen,
5084            UiAnchor::Bottom,
5085            basic_caps(),
5086            config,
5087        );
5088
5089        // Config should be accessible
5090        assert!(!writer.diff_config().dirty_rows_enabled);
5091        assert!(!writer.diff_config().bayesian_enabled);
5092
5093        // Diff strategy should use the underlying strategy config
5094        let (alpha, beta) = writer.diff_strategy().posterior_params();
5095        // Default priors
5096        assert!((alpha - 1.0).abs() < 0.01);
5097        assert!((beta - 19.0).abs() < 0.01);
5098    }
5099
5100    #[test]
5101    fn resize_respects_reset_toggle() {
5102        // With reset_on_resize disabled, posterior should be preserved after resize
5103        let config = RuntimeDiffConfig::default().with_reset_on_resize(false);
5104
5105        let mut writer = TerminalWriter::with_diff_config(
5106            Vec::<u8>::new(),
5107            ScreenMode::AltScreen,
5108            UiAnchor::Bottom,
5109            basic_caps(),
5110            config,
5111        );
5112        writer.set_size(80, 24);
5113
5114        // Present frames to update posterior
5115        let mut buffer = Buffer::new(80, 24);
5116        buffer.set_raw(0, 0, Cell::from_char('X'));
5117        writer.present_ui(&buffer, None, false).unwrap();
5118
5119        let mut buffer2 = Buffer::new(80, 24);
5120        buffer2.set_raw(1, 1, Cell::from_char('Y'));
5121        writer.present_ui(&buffer2, None, false).unwrap();
5122
5123        // Posterior should have moved from initial priors
5124        let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
5125
5126        // Resize - with reset disabled, posterior should be preserved
5127        writer.set_size(100, 30);
5128
5129        let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
5130        assert!(
5131            (alpha_after - alpha_before).abs() < 0.01 && (beta_after - beta_before).abs() < 0.01,
5132            "posterior should be preserved when reset_on_resize=false"
5133        );
5134    }
5135
5136    // =========================================================================
5137    // Enum / Default / Debug tests
5138    // =========================================================================
5139
5140    #[test]
5141    fn screen_mode_default_is_altscreen() {
5142        assert_eq!(ScreenMode::default(), ScreenMode::AltScreen);
5143    }
5144
5145    #[test]
5146    fn screen_mode_debug_format() {
5147        let dbg = format!("{:?}", ScreenMode::Inline { ui_height: 7 });
5148        assert!(dbg.contains("Inline"));
5149        assert!(dbg.contains('7'));
5150    }
5151
5152    #[test]
5153    fn screen_mode_inline_auto_debug_format() {
5154        let dbg = format!(
5155            "{:?}",
5156            ScreenMode::InlineAuto {
5157                min_height: 3,
5158                max_height: 10
5159            }
5160        );
5161        assert!(dbg.contains("InlineAuto"));
5162    }
5163
5164    #[test]
5165    fn screen_mode_eq_inline_auto() {
5166        let a = ScreenMode::InlineAuto {
5167            min_height: 2,
5168            max_height: 8,
5169        };
5170        let b = ScreenMode::InlineAuto {
5171            min_height: 2,
5172            max_height: 8,
5173        };
5174        assert_eq!(a, b);
5175        let c = ScreenMode::InlineAuto {
5176            min_height: 2,
5177            max_height: 9,
5178        };
5179        assert_ne!(a, c);
5180    }
5181
5182    #[test]
5183    fn ui_anchor_default_is_bottom() {
5184        assert_eq!(UiAnchor::default(), UiAnchor::Bottom);
5185    }
5186
5187    #[test]
5188    fn ui_anchor_debug_format() {
5189        assert_eq!(format!("{:?}", UiAnchor::Top), "Top");
5190        assert_eq!(format!("{:?}", UiAnchor::Bottom), "Bottom");
5191    }
5192
5193    // =========================================================================
5194    // Accessor tests
5195    // =========================================================================
5196
5197    #[test]
5198    fn width_height_accessors() {
5199        let output = Vec::new();
5200        let mut writer = TerminalWriter::new(
5201            output,
5202            ScreenMode::AltScreen,
5203            UiAnchor::Bottom,
5204            basic_caps(),
5205        );
5206        // Default dimensions are 80x24
5207        assert_eq!(writer.width(), 80);
5208        assert_eq!(writer.height(), 24);
5209
5210        writer.set_size(120, 40);
5211        assert_eq!(writer.width(), 120);
5212        assert_eq!(writer.height(), 40);
5213    }
5214
5215    #[test]
5216    fn screen_mode_accessor() {
5217        let writer = TerminalWriter::new(
5218            Vec::new(),
5219            ScreenMode::Inline { ui_height: 5 },
5220            UiAnchor::Top,
5221            basic_caps(),
5222        );
5223        assert_eq!(writer.screen_mode(), ScreenMode::Inline { ui_height: 5 });
5224    }
5225
5226    #[test]
5227    fn capabilities_accessor() {
5228        let caps = full_caps();
5229        let writer = TerminalWriter::new(Vec::new(), ScreenMode::AltScreen, UiAnchor::Bottom, caps);
5230        assert!(writer.capabilities().supports_true_color());
5231        assert!(writer.capabilities().sync_output);
5232    }
5233
5234    // =========================================================================
5235    // into_inner tests
5236    // =========================================================================
5237
5238    #[test]
5239    fn into_inner_returns_writer() {
5240        let writer = TerminalWriter::new(
5241            Vec::new(),
5242            ScreenMode::AltScreen,
5243            UiAnchor::Bottom,
5244            basic_caps(),
5245        );
5246        let inner = writer.into_inner();
5247        assert!(inner.is_some());
5248    }
5249
5250    #[test]
5251    fn into_inner_performs_cleanup() {
5252        let mut writer = TerminalWriter::new(
5253            Vec::new(),
5254            ScreenMode::Inline { ui_height: 5 },
5255            UiAnchor::Bottom,
5256            basic_caps(),
5257        );
5258        writer.cursor_saved = true;
5259        writer.in_sync_block = false;
5260
5261        let inner = writer.into_inner().unwrap();
5262        // Cleanup should have written cursor restore
5263        assert!(
5264            inner
5265                .windows(CURSOR_RESTORE.len())
5266                .any(|w| w == CURSOR_RESTORE),
5267            "into_inner should perform cleanup before returning"
5268        );
5269    }
5270
5271    // =========================================================================
5272    // take_render_buffer tests
5273    // =========================================================================
5274
5275    #[test]
5276    fn take_render_buffer_creates_new_when_no_spare() {
5277        let mut writer = TerminalWriter::new(
5278            Vec::new(),
5279            ScreenMode::AltScreen,
5280            UiAnchor::Bottom,
5281            basic_caps(),
5282        );
5283        let buf = writer.take_render_buffer(80, 24);
5284        assert_eq!(buf.width(), 80);
5285        assert_eq!(buf.height(), 24);
5286    }
5287
5288    #[test]
5289    fn take_render_buffer_reuses_spare_on_match() {
5290        let mut writer = TerminalWriter::new(
5291            Vec::new(),
5292            ScreenMode::AltScreen,
5293            UiAnchor::Bottom,
5294            basic_caps(),
5295        );
5296        // Inject a spare buffer
5297        writer.spare_buffer = Some(Buffer::new(80, 24));
5298        assert!(writer.spare_buffer.is_some());
5299
5300        let buf = writer.take_render_buffer(80, 24);
5301        assert_eq!(buf.width(), 80);
5302        assert_eq!(buf.height(), 24);
5303        // Spare should have been taken
5304        assert!(writer.spare_buffer.is_none());
5305    }
5306
5307    #[test]
5308    fn take_render_buffer_ignores_spare_on_size_mismatch() {
5309        let mut writer = TerminalWriter::new(
5310            Vec::new(),
5311            ScreenMode::AltScreen,
5312            UiAnchor::Bottom,
5313            basic_caps(),
5314        );
5315        writer.spare_buffer = Some(Buffer::new(80, 24));
5316
5317        // Request different size - should create new, not reuse
5318        let buf = writer.take_render_buffer(100, 30);
5319        assert_eq!(buf.width(), 100);
5320        assert_eq!(buf.height(), 30);
5321    }
5322
5323    // =========================================================================
5324    // gc tests
5325    // =========================================================================
5326
5327    #[test]
5328    fn gc_with_no_prev_buffer() {
5329        let mut writer = TerminalWriter::new(
5330            Vec::new(),
5331            ScreenMode::AltScreen,
5332            UiAnchor::Bottom,
5333            basic_caps(),
5334        );
5335        assert!(writer.prev_buffer.is_none());
5336        // Should not panic
5337        writer.gc(None);
5338    }
5339
5340    #[test]
5341    fn gc_with_prev_buffer() {
5342        let mut writer = TerminalWriter::new(
5343            Vec::new(),
5344            ScreenMode::AltScreen,
5345            UiAnchor::Bottom,
5346            basic_caps(),
5347        );
5348        writer.prev_buffer = Some(Buffer::new(10, 5));
5349        // Should not panic
5350        writer.gc(None);
5351    }
5352
5353    // =========================================================================
5354    // hide_cursor / show_cursor tests
5355    // =========================================================================
5356
5357    #[test]
5358    fn hide_cursor_emits_sequence() {
5359        let mut output = Vec::new();
5360        {
5361            let mut writer = TerminalWriter::new(
5362                &mut output,
5363                ScreenMode::AltScreen,
5364                UiAnchor::Bottom,
5365                basic_caps(),
5366            );
5367            writer.hide_cursor().unwrap();
5368        }
5369        assert!(
5370            output.windows(6).any(|w| w == b"\x1b[?25l"),
5371            "hide_cursor should emit cursor hide sequence"
5372        );
5373    }
5374
5375    #[test]
5376    fn show_cursor_emits_sequence() {
5377        let mut output = Vec::new();
5378        {
5379            let mut writer = TerminalWriter::new(
5380                &mut output,
5381                ScreenMode::AltScreen,
5382                UiAnchor::Bottom,
5383                basic_caps(),
5384            );
5385            // First hide, then show
5386            writer.hide_cursor().unwrap();
5387            writer.show_cursor().unwrap();
5388        }
5389        assert!(
5390            output.windows(6).any(|w| w == b"\x1b[?25h"),
5391            "show_cursor should emit cursor show sequence"
5392        );
5393    }
5394
5395    #[test]
5396    fn hide_cursor_idempotent() {
5397        // Use Cursor<Vec<u8>> to own the writer
5398        use std::io::Cursor;
5399        let mut writer = TerminalWriter::new(
5400            Cursor::new(Vec::new()),
5401            ScreenMode::AltScreen,
5402            UiAnchor::Bottom,
5403            basic_caps(),
5404        );
5405        writer.hide_cursor().unwrap();
5406        let inner = writer.into_inner().unwrap().into_inner();
5407        let hide_count = inner.windows(6).filter(|w| *w == b"\x1b[?25l").count();
5408        // Should have exactly 1 hide (from hide_cursor) — Drop cleanup shows cursor (?25h)
5409        assert_eq!(
5410            hide_count, 1,
5411            "hide_cursor called once should emit exactly one hide sequence"
5412        );
5413    }
5414
5415    #[test]
5416    fn show_cursor_idempotent_when_already_visible() {
5417        use std::io::Cursor;
5418        let mut writer = TerminalWriter::new(
5419            Cursor::new(Vec::new()),
5420            ScreenMode::AltScreen,
5421            UiAnchor::Bottom,
5422            basic_caps(),
5423        );
5424        // Cursor starts visible — show should be noop
5425        writer.show_cursor().unwrap();
5426        let inner = writer.into_inner().unwrap().into_inner();
5427        // No ?25h should appear from show_cursor (only from cleanup)
5428        let show_count = inner.windows(6).filter(|w| *w == b"\x1b[?25h").count();
5429        assert!(
5430            show_count <= 1,
5431            "show_cursor when already visible should not add extra show sequences"
5432        );
5433    }
5434
5435    // =========================================================================
5436    // pool / links accessor tests
5437    // =========================================================================
5438
5439    #[test]
5440    fn pool_accessor() {
5441        let writer = TerminalWriter::new(
5442            Vec::new(),
5443            ScreenMode::AltScreen,
5444            UiAnchor::Bottom,
5445            basic_caps(),
5446        );
5447        // Pool should be accessible (just testing it doesn't panic)
5448        let _pool = writer.pool();
5449    }
5450
5451    #[test]
5452    fn pool_mut_accessor() {
5453        let mut writer = TerminalWriter::new(
5454            Vec::new(),
5455            ScreenMode::AltScreen,
5456            UiAnchor::Bottom,
5457            basic_caps(),
5458        );
5459        let _pool = writer.pool_mut();
5460    }
5461
5462    #[test]
5463    fn links_accessor() {
5464        let writer = TerminalWriter::new(
5465            Vec::new(),
5466            ScreenMode::AltScreen,
5467            UiAnchor::Bottom,
5468            basic_caps(),
5469        );
5470        let _links = writer.links();
5471    }
5472
5473    #[test]
5474    fn links_mut_accessor() {
5475        let mut writer = TerminalWriter::new(
5476            Vec::new(),
5477            ScreenMode::AltScreen,
5478            UiAnchor::Bottom,
5479            basic_caps(),
5480        );
5481        let _links = writer.links_mut();
5482    }
5483
5484    #[test]
5485    fn pool_and_links_mut_accessor() {
5486        let mut writer = TerminalWriter::new(
5487            Vec::new(),
5488            ScreenMode::AltScreen,
5489            UiAnchor::Bottom,
5490            basic_caps(),
5491        );
5492        let (_pool, _links) = writer.pool_and_links_mut();
5493    }
5494
5495    // =========================================================================
5496    // Helper function tests
5497    // =========================================================================
5498
5499    #[test]
5500    fn sanitize_auto_bounds_normal() {
5501        assert_eq!(sanitize_auto_bounds(3, 10), (3, 10));
5502    }
5503
5504    #[test]
5505    fn sanitize_auto_bounds_zero_min() {
5506        // min=0 should become 1
5507        assert_eq!(sanitize_auto_bounds(0, 10), (1, 10));
5508    }
5509
5510    #[test]
5511    fn sanitize_auto_bounds_max_less_than_min() {
5512        // max < min should be clamped to min
5513        assert_eq!(sanitize_auto_bounds(5, 3), (5, 5));
5514    }
5515
5516    #[test]
5517    fn sanitize_auto_bounds_both_zero() {
5518        assert_eq!(sanitize_auto_bounds(0, 0), (1, 1));
5519    }
5520
5521    #[test]
5522    fn diff_strategy_str_variants() {
5523        assert_eq!(diff_strategy_str(DiffStrategy::Full), "full");
5524        assert_eq!(diff_strategy_str(DiffStrategy::DirtyRows), "dirty");
5525        assert_eq!(diff_strategy_str(DiffStrategy::FullRedraw), "redraw");
5526    }
5527
5528    #[test]
5529    fn ui_anchor_str_variants() {
5530        assert_eq!(ui_anchor_str(UiAnchor::Bottom), "bottom");
5531        assert_eq!(ui_anchor_str(UiAnchor::Top), "top");
5532    }
5533
5534    #[test]
5535    fn json_escape_plain_text() {
5536        assert_eq!(json_escape("hello"), "hello");
5537    }
5538
5539    #[test]
5540    fn json_escape_special_chars() {
5541        assert_eq!(json_escape(r#"a"b"#), r#"a\"b"#);
5542        assert_eq!(json_escape("a\\b"), r#"a\\b"#);
5543        assert_eq!(json_escape("a\nb"), r#"a\nb"#);
5544        assert_eq!(json_escape("a\rb"), r#"a\rb"#);
5545        assert_eq!(json_escape("a\tb"), r#"a\tb"#);
5546    }
5547
5548    #[test]
5549    fn json_escape_control_chars() {
5550        let s = String::from("\x00\x01\x1f");
5551        let escaped = json_escape(&s);
5552        assert!(escaped.contains("\\u0000"));
5553        assert!(escaped.contains("\\u0001"));
5554        assert!(escaped.contains("\\u001F"));
5555    }
5556
5557    #[test]
5558    fn json_escape_unicode_passthrough() {
5559        assert_eq!(json_escape("caf\u{00e9}"), "caf\u{00e9}");
5560        assert_eq!(json_escape("\u{1f600}"), "\u{1f600}");
5561    }
5562
5563    // CountingWriter tests removed — the local CountingWriter was removed
5564    // in favour of ftui_render::counting_writer::CountingWriter (accessed via
5565    // Presenter). The render-crate CountingWriter has its own test suite.
5566
5567    #[test]
5568    fn counting_writer_into_inner() {
5569        let mut cw = CountingWriter::new(Vec::new());
5570        cw.write_all(b"data").unwrap();
5571        let inner = cw.into_inner();
5572        assert_eq!(inner, b"data");
5573    }
5574
5575    // =========================================================================
5576    // estimate_diff_scan_cost tests
5577    // =========================================================================
5578
5579    fn zero_span_stats() -> DirtySpanStats {
5580        DirtySpanStats {
5581            rows_full_dirty: 0,
5582            rows_with_spans: 0,
5583            total_spans: 0,
5584            overflows: 0,
5585            span_coverage_cells: 0,
5586            max_span_len: 0,
5587            max_spans_per_row: 4,
5588        }
5589    }
5590
5591    #[test]
5592    fn estimate_diff_scan_cost_full_strategy() {
5593        let stats = zero_span_stats();
5594        let (cost, label) = estimate_diff_scan_cost(DiffStrategy::Full, 0, 80, 24, &stats, None);
5595        assert_eq!(cost, 80 * 24);
5596        assert_eq!(label, "full_strategy");
5597    }
5598
5599    #[test]
5600    fn estimate_diff_scan_cost_full_redraw() {
5601        let stats = zero_span_stats();
5602        let (cost, label) =
5603            estimate_diff_scan_cost(DiffStrategy::FullRedraw, 5, 80, 24, &stats, None);
5604        assert_eq!(cost, 0);
5605        assert_eq!(label, "full_redraw");
5606    }
5607
5608    #[test]
5609    fn estimate_diff_scan_cost_dirty_rows_no_dirty() {
5610        let stats = zero_span_stats();
5611        let (cost, label) =
5612            estimate_diff_scan_cost(DiffStrategy::DirtyRows, 0, 80, 24, &stats, None);
5613        assert_eq!(cost, 0);
5614        assert_eq!(label, "no_dirty_rows");
5615    }
5616
5617    #[test]
5618    fn estimate_diff_scan_cost_dirty_rows_with_span_coverage() {
5619        let mut stats = zero_span_stats();
5620        stats.span_coverage_cells = 100;
5621        let (cost, label) =
5622            estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5623        assert_eq!(cost, 100);
5624        assert_eq!(label, "none");
5625    }
5626
5627    #[test]
5628    fn estimate_diff_scan_cost_dirty_rows_no_spans() {
5629        let stats = zero_span_stats();
5630        let (cost, label) =
5631            estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5632        assert_eq!(cost, 5 * 80);
5633        assert_eq!(label, "no_spans");
5634    }
5635
5636    #[test]
5637    fn estimate_diff_scan_cost_dirty_rows_overflow_with_span() {
5638        let mut stats = zero_span_stats();
5639        stats.span_coverage_cells = 150;
5640        stats.overflows = 1;
5641        let (cost, label) =
5642            estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5643        assert_eq!(cost, 150);
5644        assert_eq!(label, "span_overflow");
5645    }
5646
5647    #[test]
5648    fn estimate_diff_scan_cost_dirty_rows_overflow_no_span() {
5649        let mut stats = zero_span_stats();
5650        stats.overflows = 1;
5651        let (cost, label) =
5652            estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, None);
5653        assert_eq!(cost, 5 * 80);
5654        assert_eq!(label, "span_overflow");
5655    }
5656
5657    #[test]
5658    fn estimate_diff_scan_cost_tile_skip() {
5659        let stats = zero_span_stats();
5660        let tile = TileDiffStats {
5661            width: 80,
5662            height: 24,
5663            tile_w: 16,
5664            tile_h: 8,
5665            tiles_x: 5,
5666            tiles_y: 3,
5667            total_tiles: 15,
5668            dirty_cells: 10,
5669            dirty_tiles: 2,
5670            dirty_cell_ratio: 0.005,
5671            dirty_tile_ratio: 0.13,
5672            scanned_tiles: 2,
5673            skipped_tiles: 13,
5674            sat_build_cells: 1920,
5675            scan_cells_estimate: 42,
5676            fallback: None,
5677        };
5678        let (cost, label) =
5679            estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5680        assert_eq!(cost, 42);
5681        assert_eq!(label, "tile_skip");
5682    }
5683
5684    #[test]
5685    fn estimate_diff_scan_cost_tile_with_fallback_uses_spans() {
5686        let mut stats = zero_span_stats();
5687        stats.span_coverage_cells = 200;
5688        let tile = TileDiffStats {
5689            width: 80,
5690            height: 24,
5691            tile_w: 16,
5692            tile_h: 8,
5693            tiles_x: 5,
5694            tiles_y: 3,
5695            total_tiles: 15,
5696            dirty_cells: 10,
5697            dirty_tiles: 2,
5698            dirty_cell_ratio: 0.005,
5699            dirty_tile_ratio: 0.13,
5700            scanned_tiles: 2,
5701            skipped_tiles: 13,
5702            sat_build_cells: 1920,
5703            scan_cells_estimate: 42,
5704            fallback: Some(TileDiffFallback::SmallScreen),
5705        };
5706        let (cost, label) =
5707            estimate_diff_scan_cost(DiffStrategy::DirtyRows, 5, 80, 24, &stats, Some(tile));
5708        // Tile has fallback, so falls through to span logic
5709        assert_eq!(cost, 200);
5710        assert_eq!(label, "none");
5711    }
5712
5713    // =========================================================================
5714    // InlineAuto edge cases
5715    // =========================================================================
5716
5717    #[test]
5718    fn inline_auto_bounds_accessor() {
5719        let mut writer = TerminalWriter::new(
5720            Vec::new(),
5721            ScreenMode::InlineAuto {
5722                min_height: 3,
5723                max_height: 10,
5724            },
5725            UiAnchor::Bottom,
5726            basic_caps(),
5727        );
5728        writer.set_size(80, 24);
5729        let bounds = writer.inline_auto_bounds();
5730        assert_eq!(bounds, Some((3, 10)));
5731    }
5732
5733    #[test]
5734    fn inline_auto_bounds_clamped_to_terminal() {
5735        let mut writer = TerminalWriter::new(
5736            Vec::new(),
5737            ScreenMode::InlineAuto {
5738                min_height: 3,
5739                max_height: 50,
5740            },
5741            UiAnchor::Bottom,
5742            basic_caps(),
5743        );
5744        writer.set_size(80, 20);
5745        let bounds = writer.inline_auto_bounds();
5746        assert_eq!(bounds, Some((3, 20)));
5747    }
5748
5749    #[test]
5750    fn inline_auto_bounds_returns_none_for_non_auto() {
5751        let writer = TerminalWriter::new(
5752            Vec::new(),
5753            ScreenMode::Inline { ui_height: 5 },
5754            UiAnchor::Bottom,
5755            basic_caps(),
5756        );
5757        assert_eq!(writer.inline_auto_bounds(), None);
5758
5759        let writer2 = TerminalWriter::new(
5760            Vec::new(),
5761            ScreenMode::AltScreen,
5762            UiAnchor::Bottom,
5763            basic_caps(),
5764        );
5765        assert_eq!(writer2.inline_auto_bounds(), None);
5766    }
5767
5768    #[test]
5769    fn auto_ui_height_returns_none_for_non_auto() {
5770        let writer = TerminalWriter::new(
5771            Vec::new(),
5772            ScreenMode::Inline { ui_height: 5 },
5773            UiAnchor::Bottom,
5774            basic_caps(),
5775        );
5776        assert_eq!(writer.auto_ui_height(), None);
5777    }
5778
5779    #[test]
5780    fn render_height_hint_altscreen() {
5781        let mut writer = TerminalWriter::new(
5782            Vec::new(),
5783            ScreenMode::AltScreen,
5784            UiAnchor::Bottom,
5785            basic_caps(),
5786        );
5787        writer.set_size(80, 24);
5788        assert_eq!(writer.render_height_hint(), 24);
5789    }
5790
5791    #[test]
5792    fn render_height_hint_inline_fixed() {
5793        let writer = TerminalWriter::new(
5794            Vec::new(),
5795            ScreenMode::Inline { ui_height: 7 },
5796            UiAnchor::Bottom,
5797            basic_caps(),
5798        );
5799        assert_eq!(writer.render_height_hint(), 7);
5800    }
5801
5802    // =========================================================================
5803    // RuntimeDiffConfig builder edge cases
5804    // =========================================================================
5805
5806    #[test]
5807    fn runtime_diff_config_tile_skip_toggle() {
5808        let config = RuntimeDiffConfig::new().with_tile_skip_enabled(false);
5809        assert!(!config.tile_diff_config.enabled);
5810    }
5811
5812    #[test]
5813    fn runtime_diff_config_dirty_spans_toggle() {
5814        let config = RuntimeDiffConfig::new().with_dirty_spans_enabled(false);
5815        assert!(!config.dirty_span_config.enabled);
5816    }
5817
5818    // =========================================================================
5819    // present_ui edge cases
5820    // =========================================================================
5821
5822    #[test]
5823    fn present_ui_altscreen_no_cursor_save_restore() {
5824        let mut output = Vec::new();
5825        {
5826            let mut writer = TerminalWriter::new(
5827                &mut output,
5828                ScreenMode::AltScreen,
5829                UiAnchor::Bottom,
5830                basic_caps(),
5831            );
5832            writer.set_size(10, 5);
5833            let buffer = Buffer::new(10, 5);
5834            writer.present_ui(&buffer, None, true).unwrap();
5835        }
5836
5837        // AltScreen should NOT use cursor save/restore (those are inline-mode specific)
5838        let save_count = output
5839            .windows(CURSOR_SAVE.len())
5840            .filter(|w| *w == CURSOR_SAVE)
5841            .count();
5842        assert_eq!(save_count, 0, "AltScreen should not save cursor");
5843    }
5844
5845    #[test]
5846    fn clear_screen_emits_ed2() {
5847        let mut output = Vec::new();
5848        {
5849            let mut writer = TerminalWriter::new(
5850                &mut output,
5851                ScreenMode::AltScreen,
5852                UiAnchor::Bottom,
5853                basic_caps(),
5854            );
5855            writer.clear_screen().unwrap();
5856        }
5857        assert!(
5858            output.windows(4).any(|w| w == b"\x1b[2J"),
5859            "clear_screen should emit ED2 sequence"
5860        );
5861    }
5862
5863    #[test]
5864    fn clear_screen_resets_active_scroll_region_before_clearing() {
5865        let mut output = Vec::new();
5866        {
5867            let mut writer = TerminalWriter::new(
5868                &mut output,
5869                ScreenMode::Inline { ui_height: 5 },
5870                UiAnchor::Bottom,
5871                scroll_region_caps(),
5872            );
5873            writer.set_size(80, 24);
5874
5875            let buffer = Buffer::new(80, 5);
5876            writer.present_ui(&buffer, None, true).unwrap();
5877            assert!(writer.scroll_region_active());
5878
5879            writer.clear_screen().unwrap();
5880            assert!(
5881                !writer.scroll_region_active(),
5882                "clear_screen should leave no active scroll region"
5883            );
5884        }
5885
5886        let reset_idx = output
5887            .windows(b"\x1b[r".len())
5888            .position(|w| w == b"\x1b[r")
5889            .expect("expected scroll-region reset");
5890        let clear_idx = output
5891            .windows(b"\x1b[2J".len())
5892            .position(|w| w == b"\x1b[2J")
5893            .expect("expected full clear");
5894        assert!(
5895            reset_idx < clear_idx,
5896            "clear_screen should reset DECSTBM before full-screen clear"
5897        );
5898    }
5899
5900    #[test]
5901    fn clear_screen_restores_saved_cursor_before_clearing() {
5902        let mut output = Vec::new();
5903        {
5904            let mut writer = TerminalWriter::new(
5905                &mut output,
5906                ScreenMode::Inline { ui_height: 5 },
5907                UiAnchor::Bottom,
5908                basic_caps(),
5909            );
5910            writer.cursor_saved = true;
5911
5912            writer.clear_screen().unwrap();
5913            assert!(
5914                !writer.cursor_saved,
5915                "clear_screen should clear stale saved-cursor state"
5916            );
5917        }
5918
5919        let restore_idx = output
5920            .windows(CURSOR_RESTORE.len())
5921            .position(|w| w == CURSOR_RESTORE)
5922            .expect("expected cursor restore");
5923        let clear_idx = output
5924            .windows(b"\x1b[2J".len())
5925            .position(|w| w == b"\x1b[2J")
5926            .expect("expected full clear");
5927        assert!(
5928            restore_idx < clear_idx,
5929            "clear_screen should restore any saved cursor before clearing"
5930        );
5931    }
5932
5933    #[test]
5934    fn clear_screen_closes_stale_sync_block_before_clearing() {
5935        let mut output = Vec::new();
5936        {
5937            let mut writer = TerminalWriter::new(
5938                &mut output,
5939                ScreenMode::Inline { ui_height: 5 },
5940                UiAnchor::Bottom,
5941                full_caps(),
5942            );
5943            writer.in_sync_block = true;
5944
5945            writer.clear_screen().unwrap();
5946            assert!(
5947                !writer.in_sync_block,
5948                "clear_screen should clear stale sync-block state"
5949            );
5950        }
5951
5952        let sync_end_idx = output
5953            .windows(SYNC_END.len())
5954            .position(|w| w == SYNC_END)
5955            .expect("expected sync end");
5956        let clear_idx = output
5957            .windows(b"\x1b[2J".len())
5958            .position(|w| w == b"\x1b[2J")
5959            .expect("expected full clear");
5960        assert!(
5961            sync_end_idx < clear_idx,
5962            "clear_screen should end any open sync block before clearing"
5963        );
5964    }
5965
5966    #[test]
5967    fn clear_screen_skips_sync_end_in_mux_while_clearing_stale_state() {
5968        let mut output = Vec::new();
5969        {
5970            let mut writer = TerminalWriter::new(
5971                &mut output,
5972                ScreenMode::Inline { ui_height: 5 },
5973                UiAnchor::Bottom,
5974                mux_caps(),
5975            );
5976            writer.in_sync_block = true;
5977
5978            writer.clear_screen().unwrap();
5979            assert!(
5980                !writer.in_sync_block,
5981                "clear_screen should clear stale sync state even when sync output is disabled"
5982            );
5983        }
5984
5985        assert!(
5986            !output.windows(SYNC_END.len()).any(|w| w == SYNC_END),
5987            "clear_screen must not emit sync_end in mux environments"
5988        );
5989        assert!(
5990            output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"),
5991            "clear_screen should still clear the screen"
5992        );
5993    }
5994
5995    #[test]
5996    fn clear_screen_invalidates_cached_state_even_when_flush_fails() {
5997        let state = Rc::new(RefCell::new(FaultState::default()));
5998        let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
5999        let mut writer = TerminalWriter::new(
6000            writer_backend,
6001            ScreenMode::Inline { ui_height: 5 },
6002            UiAnchor::Bottom,
6003            basic_caps(),
6004        );
6005        writer.cursor_saved = true;
6006        writer.prev_buffer = Some(Buffer::new(4, 2));
6007        writer.last_inline_region = Some(InlineRegion {
6008            start: 19,
6009            height: 5,
6010        });
6011        writer.last_diff_strategy = Some(DiffStrategy::DirtyRows);
6012
6013        let err = writer
6014            .clear_screen()
6015            .expect_err("expected injected flush write failure");
6016        assert_eq!(err.kind(), io::ErrorKind::Other);
6017        assert!(state.borrow().injected_failure_triggered);
6018        assert!(
6019            writer.prev_buffer.is_none(),
6020            "clear_screen should invalidate cached frame state after flush failure"
6021        );
6022        assert!(
6023            writer.last_inline_region.is_none(),
6024            "clear_screen should drop inline region cache after flush failure"
6025        );
6026        assert!(
6027            writer.last_diff_strategy.is_none(),
6028            "clear_screen should reset diff strategy after flush failure"
6029        );
6030    }
6031
6032    #[test]
6033    fn present_ui_retry_after_write_failure_forces_repaint() {
6034        let state = Rc::new(RefCell::new(FaultState::default()));
6035        let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
6036        let mut writer = TerminalWriter::new(
6037            writer_backend,
6038            ScreenMode::AltScreen,
6039            UiAnchor::Bottom,
6040            basic_caps(),
6041        );
6042        writer.set_size(4, 2);
6043
6044        let mut buffer = Buffer::new(4, 2);
6045        buffer.set_raw(0, 0, Cell::from_char('A'));
6046
6047        let err = writer
6048            .present_ui(&buffer, None, true)
6049            .expect_err("first present should hit the injected write fault");
6050        assert_eq!(err.kind(), io::ErrorKind::Other);
6051        assert!(
6052            writer.prev_buffer.is_none(),
6053            "failed present must not advance the diff baseline"
6054        );
6055
6056        writer
6057            .present_ui(&buffer, None, true)
6058            .expect("retry after transient failure should succeed");
6059
6060        let bytes = state.borrow().bytes.clone();
6061        assert!(
6062            bytes.contains(&b'A'),
6063            "retry should emit the missing cell content after a failed present"
6064        );
6065    }
6066
6067    #[test]
6068    fn present_ui_write_failure_with_existing_baseline_invalidates_diff_state() {
6069        let state = Rc::new(RefCell::new(FaultState::default()));
6070        let writer_backend = SingleWriteFaultWriter::new(Rc::clone(&state), 1, 1);
6071        let mut writer = TerminalWriter::new(
6072            writer_backend,
6073            ScreenMode::AltScreen,
6074            UiAnchor::Bottom,
6075            basic_caps(),
6076        );
6077        writer.set_size(4, 2);
6078
6079        let mut previous = Buffer::new(4, 2);
6080        previous.set_raw(0, 0, Cell::from_char('A'));
6081        writer.prev_buffer = Some(previous);
6082        writer.last_inline_region = Some(InlineRegion {
6083            start: 0,
6084            height: 2,
6085        });
6086        writer.last_diff_strategy = Some(DiffStrategy::DirtyRows);
6087        writer.frames_since_full_redraw = 7;
6088
6089        let mut buffer = Buffer::new(4, 2);
6090        buffer.set_raw(0, 0, Cell::from_char('B'));
6091
6092        let err = writer
6093            .present_ui(&buffer, None, true)
6094            .expect_err("present should hit the injected write fault");
6095        assert_eq!(err.kind(), io::ErrorKind::Other);
6096        assert!(
6097            writer.prev_buffer.is_none(),
6098            "failed present with a prior baseline must force the next frame to repaint"
6099        );
6100        assert!(
6101            writer.last_inline_region.is_none(),
6102            "failed present must drop inline-region assumptions"
6103        );
6104        assert_eq!(writer.last_diff_strategy(), None);
6105        assert_eq!(writer.frames_since_full_redraw, 0);
6106
6107        writer
6108            .present_ui(&buffer, None, true)
6109            .expect("retry after transient failure should succeed");
6110        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
6111    }
6112
6113    #[test]
6114    fn set_size_resets_scroll_region_and_spare_buffer() {
6115        let output = Vec::new();
6116        let mut writer = TerminalWriter::new(
6117            output,
6118            ScreenMode::Inline { ui_height: 5 },
6119            UiAnchor::Bottom,
6120            basic_caps(),
6121        );
6122        writer.spare_buffer = Some(Buffer::new(80, 24));
6123        writer.set_size(100, 30);
6124        assert!(writer.spare_buffer.is_none());
6125    }
6126
6127    // =========================================================================
6128    // Inline active widgets gauge tests (bd-1q5.15)
6129    // =========================================================================
6130
6131    /// Mutex to serialize gauge tests against concurrent inline writer
6132    /// creation/destruction in other tests.
6133    static GAUGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
6134
6135    #[test]
6136    fn inline_active_widgets_gauge_increments_for_inline_mode() {
6137        let _lock = GAUGE_TEST_LOCK
6138            .lock()
6139            .unwrap_or_else(|err| err.into_inner());
6140
6141        // Other tests may create/drop inline writers concurrently.
6142        // Retry until we observe one uncontended +1/-1 transition.
6143        for _ in 0..64 {
6144            let before = inline_active_widgets();
6145            let writer = TerminalWriter::new(
6146                Vec::new(),
6147                ScreenMode::Inline { ui_height: 5 },
6148                UiAnchor::Bottom,
6149                basic_caps(),
6150            );
6151            let after_create = inline_active_widgets();
6152            drop(writer);
6153            let after_drop = inline_active_widgets();
6154
6155            if after_create == before.saturating_add(1) && after_drop == before {
6156                return;
6157            }
6158            std::thread::yield_now();
6159        }
6160
6161        panic!("failed to observe uncontended inline gauge +1/-1 transition");
6162    }
6163
6164    #[test]
6165    fn inline_active_widgets_gauge_increments_for_inline_auto_mode() {
6166        let _lock = GAUGE_TEST_LOCK
6167            .lock()
6168            .unwrap_or_else(|err| err.into_inner());
6169
6170        for _ in 0..64 {
6171            let before = inline_active_widgets();
6172            let writer = TerminalWriter::new(
6173                Vec::new(),
6174                ScreenMode::InlineAuto {
6175                    min_height: 2,
6176                    max_height: 10,
6177                },
6178                UiAnchor::Bottom,
6179                basic_caps(),
6180            );
6181            let after_create = inline_active_widgets();
6182            drop(writer);
6183            let after_drop = inline_active_widgets();
6184
6185            if after_create == before.saturating_add(1) && after_drop == before {
6186                return;
6187            }
6188            std::thread::yield_now();
6189        }
6190
6191        panic!("failed to observe uncontended inline-auto gauge +1/-1 transition");
6192    }
6193
6194    #[test]
6195    fn inline_active_widgets_gauge_unchanged_for_altscreen() {
6196        let _lock = GAUGE_TEST_LOCK
6197            .lock()
6198            .unwrap_or_else(|err| err.into_inner());
6199
6200        for _ in 0..64 {
6201            let before = inline_active_widgets();
6202            let writer = TerminalWriter::new(
6203                Vec::new(),
6204                ScreenMode::AltScreen,
6205                UiAnchor::Bottom,
6206                basic_caps(),
6207            );
6208            let after_create = inline_active_widgets();
6209            drop(writer);
6210            let after_drop = inline_active_widgets();
6211
6212            if after_create == before && after_drop == before {
6213                return;
6214            }
6215            std::thread::yield_now();
6216        }
6217
6218        panic!("failed to observe stable altscreen gauge behavior");
6219    }
6220
6221    // =========================================================================
6222    // Inline scrollback preservation tests (bd-1q5.16)
6223    // =========================================================================
6224
6225    /// CSI ?1049h — the alternate-screen enter sequence that must NEVER appear
6226    /// in inline mode output.
6227    const ALTSCREEN_ENTER: &[u8] = b"\x1b[?1049h";
6228
6229    /// CSI ?1049l — the alternate-screen exit sequence.
6230    const ALTSCREEN_EXIT: &[u8] = b"\x1b[?1049l";
6231
6232    /// Helper: returns true if `haystack` contains the byte subsequence `needle`.
6233    fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
6234        haystack.windows(needle.len()).any(|w| w == needle)
6235    }
6236
6237    #[test]
6238    fn inline_render_never_emits_altscreen_enter() {
6239        // The defining contract of inline mode: CSI ?1049h must not appear.
6240        let mut output = Vec::new();
6241        {
6242            let mut writer = TerminalWriter::new(
6243                &mut output,
6244                ScreenMode::Inline { ui_height: 5 },
6245                UiAnchor::Bottom,
6246                basic_caps(),
6247            );
6248            writer.set_size(80, 24);
6249
6250            let buffer = Buffer::new(80, 5);
6251            writer.present_ui(&buffer, None, true).unwrap();
6252            writer.write_log("hello\n").unwrap();
6253            // Second present to exercise diff path
6254            writer.present_ui(&buffer, None, true).unwrap();
6255        }
6256
6257        assert!(
6258            !contains_bytes(&output, ALTSCREEN_ENTER),
6259            "inline mode must never emit CSI ?1049h (alternate screen enter)"
6260        );
6261        assert!(
6262            !contains_bytes(&output, ALTSCREEN_EXIT),
6263            "inline mode must never emit CSI ?1049l (alternate screen exit)"
6264        );
6265    }
6266
6267    #[test]
6268    fn inline_auto_render_never_emits_altscreen_enter() {
6269        let mut output = Vec::new();
6270        {
6271            let mut writer = TerminalWriter::new(
6272                &mut output,
6273                ScreenMode::InlineAuto {
6274                    min_height: 3,
6275                    max_height: 10,
6276                },
6277                UiAnchor::Bottom,
6278                basic_caps(),
6279            );
6280            writer.set_size(80, 24);
6281
6282            let buffer = Buffer::new(80, 5);
6283            writer.present_ui(&buffer, None, true).unwrap();
6284        }
6285
6286        assert!(
6287            !contains_bytes(&output, ALTSCREEN_ENTER),
6288            "InlineAuto mode must never emit CSI ?1049h"
6289        );
6290    }
6291
6292    #[test]
6293    fn inline_scrollback_preserved_after_present() {
6294        // Scrollback preservation means log text written before present_ui
6295        // survives the UI render pass. We verify the output buffer contains
6296        // both the log text and cursor save/restore (the contract that
6297        // guarantees scrollback isn't disturbed).
6298        let mut output = Vec::new();
6299        {
6300            let mut writer = TerminalWriter::new(
6301                &mut output,
6302                ScreenMode::Inline { ui_height: 5 },
6303                UiAnchor::Bottom,
6304                basic_caps(),
6305            );
6306            writer.set_size(80, 24);
6307
6308            writer.write_log("scrollback line A\n").unwrap();
6309            writer.write_log("scrollback line B\n").unwrap();
6310
6311            let buffer = Buffer::new(80, 5);
6312            writer.present_ui(&buffer, None, true).unwrap();
6313
6314            // Another log after render should also work
6315            writer.write_log("scrollback line C\n").unwrap();
6316        }
6317
6318        let text = String::from_utf8_lossy(&output);
6319        assert!(text.contains("scrollback line A"), "first log must survive");
6320        assert!(
6321            text.contains("scrollback line B"),
6322            "second log must survive"
6323        );
6324        assert!(
6325            text.contains("scrollback line C"),
6326            "post-render log must survive"
6327        );
6328
6329        // Cursor save/restore must bracket the UI render to leave
6330        // scrollback position untouched.
6331        assert!(
6332            contains_bytes(&output, CURSOR_SAVE),
6333            "present_ui must save cursor to protect scrollback"
6334        );
6335        assert!(
6336            contains_bytes(&output, CURSOR_RESTORE),
6337            "present_ui must restore cursor to protect scrollback"
6338        );
6339    }
6340
6341    #[test]
6342    fn multiple_inline_writers_coexist() {
6343        // Two independent inline writers should each manage their own state
6344        // without interfering. Uses owned Vec writers so each can be
6345        // independently dropped and inspected.
6346        let mut writer_a = TerminalWriter::new(
6347            Vec::new(),
6348            ScreenMode::Inline { ui_height: 3 },
6349            UiAnchor::Bottom,
6350            basic_caps(),
6351        );
6352        writer_a.set_size(40, 12);
6353
6354        let mut writer_b = TerminalWriter::new(
6355            Vec::new(),
6356            ScreenMode::Inline { ui_height: 5 },
6357            UiAnchor::Bottom,
6358            basic_caps(),
6359        );
6360        writer_b.set_size(80, 24);
6361
6362        // Both can render independently without panicking
6363        let buf_a = Buffer::new(40, 3);
6364        let buf_b = Buffer::new(80, 5);
6365        writer_a.present_ui(&buf_a, None, true).unwrap();
6366        writer_b.present_ui(&buf_b, None, true).unwrap();
6367
6368        // Second render pass (diff path) also works
6369        writer_a.present_ui(&buf_a, None, true).unwrap();
6370        writer_b.present_ui(&buf_b, None, true).unwrap();
6371
6372        // Both drop cleanly (no panic, no double-free)
6373        drop(writer_a);
6374        drop(writer_b);
6375    }
6376
6377    #[test]
6378    fn multiple_inline_writers_gauge_tracks_both() {
6379        // Verify the gauge correctly tracks two simultaneous inline writers.
6380        let _lock = GAUGE_TEST_LOCK
6381            .lock()
6382            .unwrap_or_else(|err| err.into_inner());
6383
6384        for _ in 0..64 {
6385            let before = inline_active_widgets();
6386            let writer_a = TerminalWriter::new(
6387                Vec::new(),
6388                ScreenMode::Inline { ui_height: 3 },
6389                UiAnchor::Bottom,
6390                basic_caps(),
6391            );
6392            let after_a = inline_active_widgets();
6393
6394            let writer_b = TerminalWriter::new(
6395                Vec::new(),
6396                ScreenMode::Inline { ui_height: 5 },
6397                UiAnchor::Bottom,
6398                basic_caps(),
6399            );
6400            let after_b = inline_active_widgets();
6401
6402            drop(writer_a);
6403            let after_drop_a = inline_active_widgets();
6404
6405            drop(writer_b);
6406            let after_drop_b = inline_active_widgets();
6407
6408            if after_a == before.saturating_add(1)
6409                && after_b == before.saturating_add(2)
6410                && after_drop_a == before.saturating_add(1)
6411                && after_drop_b == before
6412            {
6413                return;
6414            }
6415            std::thread::yield_now();
6416        }
6417
6418        panic!("failed to observe uncontended two-writer gauge transitions");
6419    }
6420
6421    #[test]
6422    fn resize_during_inline_mode_preserves_scrollback() {
6423        // Resize should re-anchor the UI region without emitting
6424        // alternate screen sequences and should allow continued rendering.
6425        let mut output = Vec::new();
6426        {
6427            let mut writer = TerminalWriter::new(
6428                &mut output,
6429                ScreenMode::Inline { ui_height: 5 },
6430                UiAnchor::Bottom,
6431                basic_caps(),
6432            );
6433            writer.set_size(80, 24);
6434
6435            let buffer = Buffer::new(80, 5);
6436            writer.present_ui(&buffer, None, true).unwrap();
6437
6438            // Simulate resize
6439            writer.set_size(100, 30);
6440            assert_eq!(writer.ui_start_row(), 25); // 30 - 5
6441
6442            // Render again after resize
6443            let buffer2 = Buffer::new(100, 5);
6444            writer.present_ui(&buffer2, None, true).unwrap();
6445
6446            // Log still works after resize
6447            writer.write_log("post-resize log\n").unwrap();
6448        }
6449
6450        let text = String::from_utf8_lossy(&output);
6451        assert!(text.contains("post-resize log"));
6452        assert!(
6453            !contains_bytes(&output, ALTSCREEN_ENTER),
6454            "resize must not trigger alternate screen"
6455        );
6456    }
6457
6458    #[test]
6459    fn resize_shrink_during_inline_mode_clamps_correctly() {
6460        // Shrinking the terminal so UI region overlaps should still work
6461        // without alternate screen sequences.
6462        let mut output = Vec::new();
6463        {
6464            let mut writer = TerminalWriter::new(
6465                &mut output,
6466                ScreenMode::Inline { ui_height: 10 },
6467                UiAnchor::Bottom,
6468                basic_caps(),
6469            );
6470            writer.set_size(80, 24);
6471            assert_eq!(writer.ui_start_row(), 14);
6472
6473            // Shrink terminal to smaller than UI height
6474            writer.set_size(80, 8);
6475            assert_eq!(writer.ui_start_row(), 0); // 8 - 10 would underflow, clamped to 0
6476
6477            // Rendering should still work (height clamped to terminal)
6478            let buffer = Buffer::new(80, 8);
6479            writer.present_ui(&buffer, None, true).unwrap();
6480        }
6481
6482        assert!(
6483            !contains_bytes(&output, ALTSCREEN_ENTER),
6484            "shrunken terminal must not switch to altscreen"
6485        );
6486    }
6487
6488    #[test]
6489    fn inline_render_emits_tracing_span_fields() {
6490        // Verify the inline.render span is entered during present_ui in inline
6491        // mode by checking that the tracing infrastructure is invoked.
6492        // We use a tracing subscriber to capture span creation.
6493        use std::sync::Arc;
6494        use std::sync::atomic::AtomicBool;
6495
6496        struct SpanChecker {
6497            saw_inline_render: Arc<AtomicBool>,
6498        }
6499
6500        impl tracing::Subscriber for SpanChecker {
6501            fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
6502                true
6503            }
6504            fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
6505                if span.metadata().name() == "inline.render" {
6506                    self.saw_inline_render
6507                        .store(true, std::sync::atomic::Ordering::SeqCst);
6508                }
6509                tracing::span::Id::from_u64(1)
6510            }
6511            fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
6512            fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
6513            }
6514            fn event(&self, _event: &tracing::Event<'_>) {}
6515            fn enter(&self, _span: &tracing::span::Id) {}
6516            fn exit(&self, _span: &tracing::span::Id) {}
6517        }
6518
6519        let saw_it = Arc::new(AtomicBool::new(false));
6520
6521        // Span callsite interest caches are process-global while this
6522        // dispatcher is thread-local: a parallel test can race the
6523        // info_span!("inline.render") callsite into caching "not enabled"
6524        // even after a rebuild (bd-r2qx8). Retry with a fresh subscriber and
6525        // a fresh rebuild each round; a neighbor can only win the race
6526        // transiently, never persistently.
6527        for _ in 0..8 {
6528            saw_it.store(false, std::sync::atomic::Ordering::SeqCst);
6529            let subscriber = SpanChecker {
6530                saw_inline_render: Arc::clone(&saw_it),
6531            };
6532
6533            let _guard = tracing::subscriber::set_default(subscriber);
6534            tracing::callsite::rebuild_interest_cache();
6535
6536            let mut output = Vec::new();
6537            {
6538                let mut writer = TerminalWriter::new(
6539                    &mut output,
6540                    ScreenMode::Inline { ui_height: 5 },
6541                    UiAnchor::Bottom,
6542                    basic_caps(),
6543                );
6544                writer.set_size(80, 24);
6545
6546                let buffer = Buffer::new(80, 5);
6547                writer.present_ui(&buffer, None, true).unwrap();
6548            }
6549
6550            if saw_it.load(std::sync::atomic::Ordering::SeqCst) {
6551                return;
6552            }
6553        }
6554
6555        assert!(
6556            saw_it.load(std::sync::atomic::Ordering::SeqCst),
6557            "present_ui in inline mode must emit an inline.render tracing span"
6558        );
6559    }
6560
6561    #[test]
6562    fn inline_render_no_altscreen_with_scroll_region_strategy() {
6563        // Even with scroll region caps, inline mode must not emit altscreen.
6564        let mut output = Vec::new();
6565        {
6566            let mut writer = TerminalWriter::new(
6567                &mut output,
6568                ScreenMode::Inline { ui_height: 5 },
6569                UiAnchor::Bottom,
6570                scroll_region_caps(),
6571            );
6572            writer.set_size(80, 24);
6573
6574            let buffer = Buffer::new(80, 5);
6575            writer.present_ui(&buffer, None, true).unwrap();
6576            writer.present_ui(&buffer, None, true).unwrap();
6577        }
6578
6579        assert!(
6580            !contains_bytes(&output, ALTSCREEN_ENTER),
6581            "scroll region strategy must never emit altscreen enter"
6582        );
6583    }
6584
6585    #[test]
6586    fn inline_render_no_altscreen_with_hybrid_strategy() {
6587        let mut output = Vec::new();
6588        {
6589            let mut writer = TerminalWriter::new(
6590                &mut output,
6591                ScreenMode::Inline { ui_height: 5 },
6592                UiAnchor::Bottom,
6593                hybrid_caps(),
6594            );
6595            writer.set_size(80, 24);
6596
6597            let buffer = Buffer::new(80, 5);
6598            writer.present_ui(&buffer, None, true).unwrap();
6599        }
6600
6601        assert!(
6602            !contains_bytes(&output, ALTSCREEN_ENTER),
6603            "hybrid strategy must never emit altscreen enter"
6604        );
6605    }
6606
6607    #[test]
6608    fn inline_render_no_altscreen_with_mux_strategy() {
6609        let mut output = Vec::new();
6610        {
6611            let mut writer = TerminalWriter::new(
6612                &mut output,
6613                ScreenMode::Inline { ui_height: 5 },
6614                UiAnchor::Bottom,
6615                mux_caps(),
6616            );
6617            writer.set_size(80, 24);
6618
6619            let buffer = Buffer::new(80, 5);
6620            writer.present_ui(&buffer, None, true).unwrap();
6621        }
6622
6623        assert!(
6624            !contains_bytes(&output, ALTSCREEN_ENTER),
6625            "mux (overlay) strategy must never emit altscreen enter"
6626        );
6627    }
6628
6629    #[test]
6630    fn test_altscreen_wide_char_rendering() {
6631        use ftui_render::cell::Cell;
6632        let mut output = Vec::new();
6633        {
6634            let mut writer = TerminalWriter::new(
6635                &mut output,
6636                ScreenMode::AltScreen,
6637                UiAnchor::Bottom,
6638                basic_caps(),
6639            );
6640            writer.set_size(10, 5);
6641
6642            let mut buf = Buffer::new(10, 1);
6643            // Wide char at x=0 (width 2)
6644            buf.set_raw(0, 0, Cell::from_char('中'));
6645            // x=1 is implicitly CONTINUATION from Buffer::new init (or empty)
6646            // Wait, set_raw only sets one cell.
6647            // But Presenter logic depends on Buffer content.
6648            // For the test, we want to simulate the state where we have a wide char.
6649            // The proper way is to use `set` which handles continuations, or manually set them.
6650            buf.set(0, 0, Cell::from_char('中')); // This sets x=0 to '中', x=1 to CONTINUATION
6651
6652            // Force a diff by having a different previous buffer
6653            let prev = Buffer::new(10, 1);
6654            writer.prev_buffer = Some(prev);
6655
6656            writer.present_ui(&buf, None, true).unwrap();
6657        }
6658
6659        let output_str = String::from_utf8_lossy(&output);
6660
6661        // Should contain '中'
6662        assert!(output_str.contains('中'));
6663
6664        // Should NOT contain a space immediately after '中' (if it was treated as orphan)
6665        // Since '中' is e4 b8 ad (3 bytes).
6666        let bytes = output_str.as_bytes();
6667        let pos = bytes.windows(3).position(|w| w == "中".as_bytes());
6668        assert!(pos.is_some());
6669
6670        // Check byte after '中'
6671        let after = pos.unwrap() + 3;
6672        if after < bytes.len() {
6673            // It might be ANSI sequence or nothing. It should NOT be space (0x20).
6674            assert_ne!(
6675                bytes[after], 0x20,
6676                "Wide char continuation clobbered with space"
6677            );
6678        }
6679    }
6680
6681    // =========================================================================
6682    // NON-INTERFERENCE CONTRACT TESTS (bd-1bavy)
6683    //
6684    // These tests verify that terminal mode behavior and ownership semantics
6685    // are preserved under lifecycle changes. The Asupersync migration MUST
6686    // keep all these guarantees intact.
6687    // =========================================================================
6688
6689    /// CONTRACT: INLINE_ACTIVE_WIDGETS gauge increments on inline writer
6690    /// creation and decrements on drop. Net effect of create+drop is zero.
6691    /// Note: Tests use relative deltas because the global counter is shared.
6692    #[test]
6693    fn noninterference_inline_gauge_balanced_across_lifecycle() {
6694        let _lock = GAUGE_TEST_LOCK
6695            .lock()
6696            .unwrap_or_else(|err| err.into_inner());
6697
6698        for _ in 0..64 {
6699            let before = inline_active_widgets();
6700            let writer = TerminalWriter::new(
6701                Vec::new(),
6702                ScreenMode::Inline { ui_height: 3 },
6703                UiAnchor::Bottom,
6704                basic_caps(),
6705            );
6706            let during = inline_active_widgets();
6707            drop(writer);
6708            let after = inline_active_widgets();
6709
6710            if during == before.saturating_add(1) && after == before {
6711                return;
6712            }
6713            std::thread::yield_now();
6714        }
6715
6716        panic!("failed to observe stable inline lifecycle gauge transition");
6717    }
6718
6719    /// CONTRACT: AltScreen writers do NOT affect the inline gauge.
6720    /// Verified by checking the ScreenMode match in the Drop impl.
6721    /// (Note: the global atomic gauge is tested for inline modes in
6722    /// other tests; here we just verify the code path distinction.)
6723    #[test]
6724    fn noninterference_altscreen_does_not_affect_inline_gauge() {
6725        let _lock = GAUGE_TEST_LOCK
6726            .lock()
6727            .unwrap_or_else(|err| err.into_inner());
6728
6729        // The contract is verified structurally: ScreenMode::AltScreen does
6730        // not match the inline pattern in both the constructor (fetch_add)
6731        // and the Drop impl (fetch_sub). We verify this by checking that
6732        // creating and immediately dropping an AltScreen writer round-trips
6733        // without affecting the delta observed from a controlled inline writer.
6734        let mut observed_stable = false;
6735        for _ in 0..64 {
6736            let before = inline_active_widgets();
6737            drop(TerminalWriter::new(
6738                Vec::new(),
6739                ScreenMode::AltScreen,
6740                UiAnchor::Bottom,
6741                basic_caps(),
6742            ));
6743            let after = inline_active_widgets();
6744
6745            if after == before {
6746                observed_stable = true;
6747                break;
6748            }
6749            std::thread::yield_now();
6750        }
6751
6752        assert!(
6753            observed_stable,
6754            "failed to observe stable altscreen lifecycle gauge transition"
6755        );
6756
6757        // Also verify the structural contract directly:
6758        assert!(
6759            !matches!(
6760                ScreenMode::AltScreen,
6761                ScreenMode::Inline { .. } | ScreenMode::InlineAuto { .. }
6762            ),
6763            "AltScreen must not match inline patterns"
6764        );
6765    }
6766
6767    /// CONTRACT: InlineAuto also tracks the inline gauge correctly.
6768    #[test]
6769    fn noninterference_inline_auto_gauge_balanced() {
6770        let _lock = GAUGE_TEST_LOCK
6771            .lock()
6772            .unwrap_or_else(|err| err.into_inner());
6773
6774        for _ in 0..64 {
6775            let before = inline_active_widgets();
6776            let writer = TerminalWriter::new(
6777                Vec::new(),
6778                ScreenMode::InlineAuto {
6779                    min_height: 3,
6780                    max_height: 10,
6781                },
6782                UiAnchor::Bottom,
6783                basic_caps(),
6784            );
6785            let during = inline_active_widgets();
6786            drop(writer);
6787            let after = inline_active_widgets();
6788
6789            if during == before.saturating_add(1) && after == before {
6790                return;
6791            }
6792            std::thread::yield_now();
6793        }
6794
6795        panic!("failed to observe stable inline-auto lifecycle gauge transition");
6796    }
6797
6798    /// CONTRACT: into_inner() performs cleanup before releasing the writer.
6799    /// The returned output must contain cursor-show and flush.
6800    #[test]
6801    fn noninterference_into_inner_performs_cleanup() {
6802        let _lock = GAUGE_TEST_LOCK
6803            .lock()
6804            .unwrap_or_else(|err| err.into_inner());
6805
6806        let cursor_show = b"\x1b[?25h";
6807        for _ in 0..64 {
6808            let before_gauge = inline_active_widgets();
6809
6810            let mut writer = TerminalWriter::new(
6811                Vec::new(),
6812                ScreenMode::Inline { ui_height: 5 },
6813                UiAnchor::Bottom,
6814                basic_caps(),
6815            );
6816            writer.set_size(80, 24);
6817
6818            let during_gauge = inline_active_widgets();
6819            let output = writer.into_inner().expect("should return writer");
6820            let after_gauge = inline_active_widgets();
6821
6822            if during_gauge == before_gauge.saturating_add(1) && after_gauge == before_gauge {
6823                assert!(
6824                    output.windows(cursor_show.len()).any(|w| w == cursor_show),
6825                    "into_inner must emit cursor show during cleanup"
6826                );
6827                return;
6828            }
6829            std::thread::yield_now();
6830        }
6831
6832        panic!("failed to observe stable into_inner gauge transition");
6833    }
6834
6835    /// CONTRACT: Cleanup output from inline mode must contain cursor restore
6836    /// (DEC 8) if cursor was saved during present.
6837    #[test]
6838    fn noninterference_inline_cleanup_restores_cursor_after_present() {
6839        let mut output = Vec::new();
6840        {
6841            let mut writer = TerminalWriter::new(
6842                &mut output,
6843                ScreenMode::Inline { ui_height: 5 },
6844                UiAnchor::Bottom,
6845                basic_caps(),
6846            );
6847            writer.set_size(80, 24);
6848
6849            let buffer = Buffer::new(80, 5);
6850            writer.present_ui(&buffer, None, true).unwrap();
6851
6852            // Writer will be dropped here, triggering cleanup
6853        }
6854
6855        // Count cursor save/restore pairs
6856        let saves = output
6857            .windows(CURSOR_SAVE.len())
6858            .filter(|w| *w == CURSOR_SAVE)
6859            .count();
6860        let restores = output
6861            .windows(CURSOR_RESTORE.len())
6862            .filter(|w| *w == CURSOR_RESTORE)
6863            .count();
6864
6865        assert!(saves > 0, "present must save cursor");
6866        assert!(
6867            restores >= saves,
6868            "cleanup must ensure all cursor saves are restored: {saves} saves, {restores} restores"
6869        );
6870    }
6871
6872    /// CONTRACT: AltScreen cleanup must show cursor. It must NOT emit
6873    /// cursor restore or scroll region reset (those are inline-only).
6874    #[test]
6875    fn noninterference_altscreen_cleanup_minimal() {
6876        let mut output = Vec::new();
6877        {
6878            let mut writer = TerminalWriter::new(
6879                &mut output,
6880                ScreenMode::AltScreen,
6881                UiAnchor::Bottom,
6882                basic_caps(),
6883            );
6884            writer.set_size(80, 24);
6885
6886            let mut buffer = Buffer::new(80, 24);
6887            buffer.set_raw(0, 0, Cell::from_char('A'));
6888            writer.present_ui(&buffer, None, true).unwrap();
6889        }
6890
6891        // Must contain cursor show
6892        let cursor_show = b"\x1b[?25h";
6893        assert!(
6894            output.windows(cursor_show.len()).any(|w| w == cursor_show),
6895            "AltScreen cleanup must show cursor"
6896        );
6897
6898        // Must NOT contain scroll region reset (inline-only)
6899        // (scroll region was never activated for AltScreen)
6900        // This is verified by the scroll_region_active flag being false
6901    }
6902
6903    /// CONTRACT: Rapid present/log interleaving in inline mode must not
6904    /// corrupt the output stream. Each present must be complete and each
6905    /// log must be sanitized.
6906    #[test]
6907    fn noninterference_rapid_present_log_interleave() {
6908        let mut output = Vec::new();
6909        {
6910            let mut writer = TerminalWriter::new(
6911                &mut output,
6912                ScreenMode::Inline { ui_height: 3 },
6913                UiAnchor::Bottom,
6914                basic_caps(),
6915            );
6916            writer.set_size(40, 12);
6917
6918            for i in 0..10 {
6919                let mut buffer = Buffer::new(40, 3);
6920                buffer.set_raw(0, 0, Cell::from_char(char::from(b'A' + (i % 26))));
6921                writer.present_ui(&buffer, None, true).unwrap();
6922                writer.write_log(&format!("log-{i}")).unwrap();
6923            }
6924        }
6925
6926        // Output must contain cursor show (cleanup ran)
6927        let cursor_show = b"\x1b[?25h";
6928        assert!(
6929            output.windows(cursor_show.len()).any(|w| w == cursor_show),
6930            "cleanup must complete after rapid interleaving"
6931        );
6932
6933        // Output must not contain unmatched escape sequences
6934        // (simple check: no bare ESC at end without terminator)
6935        let output_len = output.len();
6936        if output_len > 1 {
6937            let last_esc = output.iter().rposition(|&b| b == 0x1b);
6938            if let Some(pos) = last_esc {
6939                // If the last ESC is within 10 bytes of the end, verify it's a complete sequence
6940                if output_len - pos < 10 {
6941                    // Should be part of cursor show or similar short sequence
6942                    assert!(
6943                        output_len - pos >= 3,
6944                        "truncated escape sequence at end of output"
6945                    );
6946                }
6947            }
6948        }
6949    }
6950
6951    /// CONTRACT: Resize between presents must not leave stale diff state.
6952    /// The first present after resize must produce valid output.
6953    #[test]
6954    fn noninterference_resize_between_presents_clears_diff_state() {
6955        let mut output = Vec::new();
6956        {
6957            let mut writer = TerminalWriter::new(
6958                &mut output,
6959                ScreenMode::Inline { ui_height: 5 },
6960                UiAnchor::Bottom,
6961                basic_caps(),
6962            );
6963            writer.set_size(80, 24);
6964
6965            // First present at 80x5
6966            let buffer1 = Buffer::new(80, 5);
6967            writer.present_ui(&buffer1, None, true).unwrap();
6968
6969            // Resize
6970            writer.set_size(120, 30);
6971            assert!(
6972                writer.prev_buffer.is_none(),
6973                "set_size must clear prev_buffer to invalidate diff"
6974            );
6975
6976            // Second present at 120x5 — must not panic or produce corrupt output
6977            let buffer2 = Buffer::new(120, 5);
6978            writer.present_ui(&buffer2, None, true).unwrap();
6979        }
6980
6981        // If we got here without panic, the resize was handled correctly
6982        let cursor_show = b"\x1b[?25h";
6983        assert!(
6984            output.windows(cursor_show.len()).any(|w| w == cursor_show),
6985            "output must be valid after resize"
6986        );
6987    }
6988
6989    /// CONTRACT: Multiple writers can be created sequentially on the same
6990    /// output without interference. Each writer's cleanup must be complete
6991    /// before the next writer starts.
6992    #[test]
6993    fn noninterference_sequential_writers_clean_handoff() {
6994        let mut output = Vec::new();
6995
6996        // First writer: Inline mode
6997        {
6998            let mut writer = TerminalWriter::new(
6999                &mut output,
7000                ScreenMode::Inline { ui_height: 3 },
7001                UiAnchor::Bottom,
7002                basic_caps(),
7003            );
7004            writer.set_size(40, 12);
7005            let buffer = Buffer::new(40, 3);
7006            writer.present_ui(&buffer, None, true).unwrap();
7007            // Dropped: cleanup runs
7008        }
7009
7010        let inline_end = output.len();
7011
7012        // Second writer: AltScreen mode on same output
7013        {
7014            let mut writer = TerminalWriter::new(
7015                &mut output,
7016                ScreenMode::AltScreen,
7017                UiAnchor::Bottom,
7018                basic_caps(),
7019            );
7020            writer.set_size(40, 12);
7021            let mut buffer = Buffer::new(40, 12);
7022            buffer.set_raw(0, 0, Cell::from_char('Z'));
7023            writer.present_ui(&buffer, None, true).unwrap();
7024            // Dropped: cleanup runs
7025        }
7026
7027        // Both cleanups must have produced cursor show
7028        let cursor_show = b"\x1b[?25h";
7029        let first_show = output[..inline_end]
7030            .windows(cursor_show.len())
7031            .any(|w| w == cursor_show);
7032        let second_show = output[inline_end..]
7033            .windows(cursor_show.len())
7034            .any(|w| w == cursor_show);
7035
7036        assert!(first_show, "first writer must show cursor on cleanup");
7037        assert!(second_show, "second writer must show cursor on cleanup");
7038    }
7039
7040    /// CONTRACT: present_ui_owned must produce identical output to present_ui
7041    /// for the same buffer content. The only difference should be performance.
7042    #[test]
7043    fn noninterference_present_ui_owned_matches_present_ui() {
7044        let mut output_borrowed = Vec::new();
7045        let mut output_owned = Vec::new();
7046
7047        let mut buffer = Buffer::new(20, 5);
7048        buffer.set_raw(0, 0, Cell::from_char('H'));
7049        buffer.set_raw(1, 0, Cell::from_char('i'));
7050
7051        // Borrowed path
7052        {
7053            let mut writer = TerminalWriter::new(
7054                &mut output_borrowed,
7055                ScreenMode::Inline { ui_height: 5 },
7056                UiAnchor::Bottom,
7057                basic_caps(),
7058            );
7059            writer.set_size(20, 10);
7060            writer.present_ui(&buffer, None, true).unwrap();
7061        }
7062
7063        // Owned path
7064        {
7065            let mut writer = TerminalWriter::new(
7066                &mut output_owned,
7067                ScreenMode::Inline { ui_height: 5 },
7068                UiAnchor::Bottom,
7069                basic_caps(),
7070            );
7071            writer.set_size(20, 10);
7072            writer.present_ui_owned(buffer, None, true).unwrap();
7073        }
7074
7075        // Both must contain cursor save/restore
7076        assert!(
7077            output_borrowed
7078                .windows(CURSOR_SAVE.len())
7079                .any(|w| w == CURSOR_SAVE),
7080            "borrowed path must save cursor"
7081        );
7082        assert!(
7083            output_owned
7084                .windows(CURSOR_SAVE.len())
7085                .any(|w| w == CURSOR_SAVE),
7086            "owned path must save cursor"
7087        );
7088
7089        // Both must contain the 'H' character
7090        assert!(
7091            output_borrowed.windows(1).any(|w| w == b"H"),
7092            "borrowed path must render content"
7093        );
7094        assert!(
7095            output_owned.windows(1).any(|w| w == b"H"),
7096            "owned path must render content"
7097        );
7098    }
7099
7100    /// CONTRACT: write_log is a no-op in AltScreen mode but works in inline.
7101    /// This behavioral difference must be preserved.
7102    #[test]
7103    fn noninterference_write_log_mode_behavior_preserved() {
7104        // Inline: write_log produces output
7105        let mut inline_output = Vec::new();
7106        {
7107            let mut writer = TerminalWriter::new(
7108                &mut inline_output,
7109                ScreenMode::Inline { ui_height: 3 },
7110                UiAnchor::Bottom,
7111                basic_caps(),
7112            );
7113            writer.set_size(40, 12);
7114            writer.write_log("hello").unwrap();
7115        }
7116        assert!(
7117            inline_output.windows(5).any(|w| w == b"hello"),
7118            "inline write_log must produce output"
7119        );
7120
7121        // AltScreen: write_log is silent
7122        let mut alt_output = Vec::new();
7123        {
7124            let mut writer = TerminalWriter::new(
7125                &mut alt_output,
7126                ScreenMode::AltScreen,
7127                UiAnchor::Bottom,
7128                basic_caps(),
7129            );
7130            writer.set_size(40, 12);
7131            writer.write_log("hello").unwrap();
7132        }
7133        // The output should not contain "hello" text from write_log
7134        // (it will contain cleanup sequences)
7135        let has_hello = alt_output.windows(5).any(|w| w == b"hello");
7136        assert!(!has_hello, "AltScreen write_log must be silent (no-op)");
7137    }
7138
7139    /// CONTRACT: Sync output sequences are balanced (begin/end) across
7140    /// multiple present calls. An open sync block must never leak.
7141    #[test]
7142    fn noninterference_sync_output_balanced_across_multiple_presents() {
7143        let mut output = Vec::new();
7144        {
7145            let mut writer = TerminalWriter::new(
7146                &mut output,
7147                ScreenMode::Inline { ui_height: 3 },
7148                UiAnchor::Bottom,
7149                full_caps(),
7150            );
7151            writer.set_size(20, 10);
7152
7153            for _ in 0..5 {
7154                let buffer = Buffer::new(20, 3);
7155                writer.present_ui(&buffer, None, true).unwrap();
7156            }
7157            // Writer drop triggers cleanup
7158        }
7159
7160        let begins = output
7161            .windows(SYNC_BEGIN.len())
7162            .filter(|w| *w == SYNC_BEGIN)
7163            .count();
7164        let ends = output
7165            .windows(SYNC_END.len())
7166            .filter(|w| *w == SYNC_END)
7167            .count();
7168
7169        assert!(begins > 0, "sync-capable writer must emit SYNC_BEGIN");
7170        assert_eq!(
7171            begins, ends,
7172            "sync blocks must be balanced: {begins} begins, {ends} ends"
7173        );
7174    }
7175
7176    /// CONTRACT: InlineAuto effective_ui_height is clamped to terminal height.
7177    /// A writer with max_height > terminal height must clamp without panic.
7178    #[test]
7179    fn noninterference_inline_auto_height_clamped_without_panic() {
7180        let mut output = Vec::new();
7181        {
7182            let mut writer = TerminalWriter::new(
7183                &mut output,
7184                ScreenMode::InlineAuto {
7185                    min_height: 3,
7186                    max_height: 100,
7187                },
7188                UiAnchor::Bottom,
7189                basic_caps(),
7190            );
7191            // Terminal is only 10 rows tall
7192            writer.set_size(80, 10);
7193
7194            // effective_ui_height for InlineAuto clamps to term_height
7195            let effective = writer.effective_ui_height();
7196            assert!(
7197                effective <= 10,
7198                "InlineAuto effective_ui_height must clamp to terminal height, got {effective}"
7199            );
7200
7201            let buffer = Buffer::new(80, effective);
7202            writer.present_ui(&buffer, None, true).unwrap();
7203        }
7204    }
7205
7206    /// CONTRACT: Inline mode with ui_height > terminal height must not panic
7207    /// during present. The rendering path handles this gracefully.
7208    #[test]
7209    fn noninterference_inline_oversized_height_no_panic() {
7210        let mut output = Vec::new();
7211        {
7212            let mut writer = TerminalWriter::new(
7213                &mut output,
7214                ScreenMode::Inline { ui_height: 100 },
7215                UiAnchor::Bottom,
7216                basic_caps(),
7217            );
7218            writer.set_size(80, 10);
7219
7220            // Inline effective_ui_height returns raw value without clamping
7221            // but the rendering path must handle this without panic
7222            let buffer = Buffer::new(80, 10);
7223            // This should not panic even though ui_height > term_height
7224            let result = writer.present_ui(&buffer, None, true);
7225            assert!(
7226                result.is_ok(),
7227                "present_ui must not panic with oversized ui_height"
7228            );
7229        }
7230    }
7231
7232    // ── Render-certificate integration (bd-6b9nr) ───────────────────────────
7233
7234    fn present_sequence(certified: bool) -> (Vec<u8>, Vec<String>) {
7235        let config = RuntimeDiffConfig {
7236            certified_skips: certified,
7237            // Deterministic strategy behavior for the byte-equality comparison.
7238            bayesian_enabled: false,
7239            ..RuntimeDiffConfig::default()
7240        };
7241        let mut writer = TerminalWriter::with_diff_config(
7242            Vec::new(),
7243            ScreenMode::Inline { ui_height: 4 },
7244            UiAnchor::Bottom,
7245            full_caps(),
7246            config,
7247        );
7248        writer.set_size(24, 12);
7249
7250        let mut certificates = Vec::new();
7251        for frame in 0..4u16 {
7252            let mut buffer = Buffer::new(24, 4);
7253            if frame > 0 {
7254                buffer.clear_dirty();
7255            }
7256            match frame {
7257                // Frame 0: initial paint (all rows dirty by construction).
7258                0 => {}
7259                // Frame 1: identical content — zero dirty rows.
7260                1 => {}
7261                // Frame 2: sparse update on two rows.
7262                2 => {
7263                    buffer.set(3, 1, ftui_render::cell::Cell::from_char('X'));
7264                    buffer.set(9, 3, ftui_render::cell::Cell::from_char('Y'));
7265                }
7266                // Frame 3: another sparse update.
7267                _ => {
7268                    buffer.set(3, 1, ftui_render::cell::Cell::from_char('Z'));
7269                }
7270            }
7271            writer
7272                .present_ui_owned(buffer, None, false)
7273                .expect("present");
7274            certificates.push(
7275                writer
7276                    .last_render_certificate()
7277                    .map(|c| format!("{}:{:?}", c.level.label(), c.causes.clone()))
7278                    .unwrap_or_else(|| "none".to_string()),
7279            );
7280        }
7281        (writer.into_inner().expect("writer sink"), certificates)
7282    }
7283
7284    #[test]
7285    fn certified_path_is_byte_identical_to_legacy_dirty_path() {
7286        let (certified_bytes, _) = present_sequence(true);
7287        let (legacy_bytes, _) = present_sequence(false);
7288        assert_eq!(
7289            certified_bytes, legacy_bytes,
7290            "certified diff path changed visible output"
7291        );
7292    }
7293
7294    #[test]
7295    fn zero_dirty_frame_earns_a_skip_all_certificate() {
7296        let (_, certificates) = present_sequence(true);
7297        // Frame 1 re-presents identical content with zero dirty rows.
7298        assert!(
7299            certificates[1].starts_with("skip-all"),
7300            "frame 1 certificate was {}",
7301            certificates[1]
7302        );
7303        // Sparse frames narrow to their dirty rows.
7304        assert!(
7305            certificates[2].starts_with("narrow-to-dirty"),
7306            "frame 2 certificate was {}",
7307            certificates[2]
7308        );
7309    }
7310
7311    #[test]
7312    fn certificates_name_full_work_causes() {
7313        let (_, certificates) = present_sequence(true);
7314        // Frame 0 has no previous buffer: full redraw with a named cause.
7315        assert!(
7316            certificates[0].contains("no-previous-frame"),
7317            "frame 0 certificate was {}",
7318            certificates[0]
7319        );
7320    }
7321
7322    #[test]
7323    fn legacy_path_records_no_certificate() {
7324        let (_, certificates) = present_sequence(false);
7325        // The dirty-rows frames record None when certified skips are off
7326        // (frame 0/resize paths still certify full work explicitly).
7327        assert_eq!(certificates[2], "none");
7328        assert_eq!(certificates[3], "none");
7329    }
7330}