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