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