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