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