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