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::time::Instant;
52
53use crate::evidence_sink::EvidenceSink;
54use crate::evidence_telemetry::{DiffDecisionSnapshot, set_diff_snapshot};
55use crate::render_trace::{
56    RenderTraceFrame, RenderTraceRecorder, build_diff_runs_payload, build_full_buffer_payload,
57};
58use ftui_core::inline_mode::InlineStrategy;
59use ftui_core::terminal_capabilities::TerminalCapabilities;
60use ftui_render::buffer::{Buffer, DirtySpanConfig, DirtySpanStats};
61use ftui_render::diff::{BufferDiff, TileDiffConfig, TileDiffFallback, TileDiffStats};
62use ftui_render::diff_strategy::{DiffStrategy, DiffStrategyConfig, DiffStrategySelector};
63use ftui_render::grapheme_pool::GraphemePool;
64use ftui_render::link_registry::LinkRegistry;
65use tracing::{debug_span, info_span, trace};
66
67/// Size of the internal write buffer (64KB).
68const BUFFER_CAPACITY: usize = 64 * 1024;
69
70/// DEC cursor save (ESC 7) - more portable than CSI s.
71const CURSOR_SAVE: &[u8] = b"\x1b7";
72
73/// DEC cursor restore (ESC 8) - more portable than CSI u.
74const CURSOR_RESTORE: &[u8] = b"\x1b8";
75
76/// Synchronized output begin (DEC 2026).
77const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
78
79/// Synchronized output end (DEC 2026).
80const SYNC_END: &[u8] = b"\x1b[?2026l";
81
82/// Erase entire line (CSI 2 K).
83const ERASE_LINE: &[u8] = b"\x1b[2K";
84
85/// How often to probe with a real diff when FullRedraw is selected.
86#[allow(dead_code)] // API for future diff strategy integration
87const FULL_REDRAW_PROBE_INTERVAL: u64 = 60;
88
89/// Writer wrapper that can count bytes written when enabled.
90struct CountingWriter<W: Write> {
91    inner: W,
92    count_enabled: bool,
93    bytes_written: u64,
94}
95
96impl<W: Write> CountingWriter<W> {
97    fn new(inner: W) -> Self {
98        Self {
99            inner,
100            count_enabled: false,
101            bytes_written: 0,
102        }
103    }
104
105    #[allow(dead_code)]
106    fn enable_counting(&mut self) {
107        self.count_enabled = true;
108        self.bytes_written = 0;
109    }
110
111    #[allow(dead_code)]
112    fn disable_counting(&mut self) {
113        self.count_enabled = false;
114    }
115
116    #[allow(dead_code)]
117    fn take_count(&mut self) -> u64 {
118        let count = self.bytes_written;
119        self.bytes_written = 0;
120        count
121    }
122
123    fn into_inner(self) -> W {
124        self.inner
125    }
126}
127
128impl<W: Write> Write for CountingWriter<W> {
129    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
130        let written = self.inner.write(buf)?;
131        if self.count_enabled {
132            self.bytes_written = self.bytes_written.saturating_add(written as u64);
133        }
134        Ok(written)
135    }
136
137    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
138        self.inner.write_all(buf)?;
139        if self.count_enabled {
140            self.bytes_written = self.bytes_written.saturating_add(buf.len() as u64);
141        }
142        Ok(())
143    }
144
145    fn flush(&mut self) -> io::Result<()> {
146        self.inner.flush()
147    }
148}
149
150fn default_diff_run_id() -> String {
151    format!("diff-{}", std::process::id())
152}
153
154fn diff_strategy_str(strategy: DiffStrategy) -> &'static str {
155    match strategy {
156        DiffStrategy::Full => "full",
157        DiffStrategy::DirtyRows => "dirty",
158        DiffStrategy::FullRedraw => "redraw",
159    }
160}
161
162fn ui_anchor_str(anchor: UiAnchor) -> &'static str {
163    match anchor {
164        UiAnchor::Bottom => "bottom",
165        UiAnchor::Top => "top",
166    }
167}
168
169#[allow(dead_code)]
170#[inline]
171fn json_escape(value: &str) -> String {
172    let mut out = String::with_capacity(value.len());
173    for ch in value.chars() {
174        match ch {
175            '"' => out.push_str("\\\""),
176            '\\' => out.push_str("\\\\"),
177            '\n' => out.push_str("\\n"),
178            '\r' => out.push_str("\\r"),
179            '\t' => out.push_str("\\t"),
180            c if c.is_control() => {
181                use std::fmt::Write as _;
182                let _ = write!(out, "\\u{:04X}", c as u32);
183            }
184            _ => out.push(ch),
185        }
186    }
187    out
188}
189
190#[allow(dead_code)]
191fn estimate_diff_scan_cost(
192    strategy: DiffStrategy,
193    dirty_rows: usize,
194    width: usize,
195    height: usize,
196    span_stats: &DirtySpanStats,
197    tile_stats: Option<TileDiffStats>,
198) -> (usize, &'static str) {
199    match strategy {
200        DiffStrategy::Full => (width.saturating_mul(height), "full_strategy"),
201        DiffStrategy::FullRedraw => (0, "full_redraw"),
202        DiffStrategy::DirtyRows => {
203            if dirty_rows == 0 {
204                return (0, "no_dirty_rows");
205            }
206            if let Some(tile_stats) = tile_stats
207                && tile_stats.fallback.is_none()
208            {
209                return (tile_stats.scan_cells_estimate, "tile_skip");
210            }
211            let span_cells = span_stats.span_coverage_cells;
212            if span_stats.overflows > 0 {
213                let estimate = if span_cells > 0 {
214                    span_cells
215                } else {
216                    dirty_rows.saturating_mul(width)
217                };
218                return (estimate, "span_overflow");
219            }
220            if span_cells > 0 {
221                (span_cells, "none")
222            } else {
223                (dirty_rows.saturating_mul(width), "no_spans")
224            }
225        }
226    }
227}
228
229fn sanitize_auto_bounds(min_height: u16, max_height: u16) -> (u16, u16) {
230    let min = min_height.max(1);
231    let max = max_height.max(min);
232    (min, max)
233}
234
235/// Screen mode determines whether we use alternate screen or inline mode.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub enum ScreenMode {
238    /// Inline mode preserves scrollback. UI is anchored at bottom/top.
239    Inline {
240        /// Height of the UI region in rows.
241        ui_height: u16,
242    },
243    /// Inline mode with automatic UI height based on rendered content.
244    ///
245    /// The measured height is clamped between `min_height` and `max_height`.
246    InlineAuto {
247        /// Minimum UI height in rows.
248        min_height: u16,
249        /// Maximum UI height in rows.
250        max_height: u16,
251    },
252    /// Alternate screen mode for full-screen applications.
253    #[default]
254    AltScreen,
255}
256
257/// Where the UI region is anchored in inline mode.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
259pub enum UiAnchor {
260    /// UI at bottom of terminal (default for agent harness).
261    #[default]
262    Bottom,
263    /// UI at top of terminal.
264    Top,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268struct InlineRegion {
269    start: u16,
270    height: u16,
271}
272
273struct DiffDecision {
274    #[allow(dead_code)] // reserved for future diff strategy introspection
275    strategy: DiffStrategy,
276    has_diff: bool,
277}
278
279#[derive(Debug, Clone, Copy)]
280#[allow(dead_code)]
281struct EmitStats {
282    diff_cells: usize,
283    diff_runs: usize,
284}
285
286#[derive(Debug, Clone, Copy)]
287#[allow(dead_code)]
288struct FrameEmitStats {
289    diff_strategy: DiffStrategy,
290    diff_cells: usize,
291    diff_runs: usize,
292    ui_height: u16,
293}
294
295#[derive(Debug, Clone, Copy)]
296#[allow(dead_code)]
297pub struct PresentTimings {
298    pub diff_us: u64,
299}
300
301// =============================================================================
302// Runtime Diff Configuration
303// =============================================================================
304
305/// Runtime-level configuration for diff strategy selection.
306///
307/// This wraps [`DiffStrategyConfig`] and adds runtime-specific toggles
308/// for enabling/disabling features and controlling reset policies.
309///
310/// # Example
311///
312/// ```
313/// use ftui_runtime::{RuntimeDiffConfig, DiffStrategyConfig};
314///
315/// // Use defaults (Bayesian selection enabled, dirty-rows enabled)
316/// let config = RuntimeDiffConfig::default();
317///
318/// // Disable Bayesian selection (always use dirty-rows if available)
319/// let config = RuntimeDiffConfig::default()
320///     .with_bayesian_enabled(false);
321///
322/// // Custom cost model
323/// let config = RuntimeDiffConfig::default()
324///     .with_strategy_config(DiffStrategyConfig {
325///         c_emit: 10.0,  // Higher I/O cost
326///         ..Default::default()
327///     });
328/// ```
329#[derive(Debug, Clone)]
330pub struct RuntimeDiffConfig {
331    /// Enable Bayesian strategy selection.
332    ///
333    /// When enabled, the selector uses a Beta posterior over the change rate
334    /// to choose between Full, DirtyRows, and FullRedraw strategies.
335    ///
336    /// When disabled, always uses DirtyRows if dirty tracking is available,
337    /// otherwise Full.
338    ///
339    /// Default: true
340    pub bayesian_enabled: bool,
341
342    /// Enable dirty-row optimization.
343    ///
344    /// When enabled, the DirtyRows strategy is available for selection.
345    /// When disabled, the selector chooses between Full and FullRedraw only.
346    ///
347    /// Default: true
348    pub dirty_rows_enabled: bool,
349
350    /// Dirty-span tracking configuration (thresholds + feature flags).
351    ///
352    /// Controls span merging, guard bands, and enable/disable behavior.
353    pub dirty_span_config: DirtySpanConfig,
354
355    /// Tile-based diff skipping configuration (thresholds + feature flags).
356    ///
357    /// Controls SAT tile size, thresholds, and enable/disable behavior.
358    pub tile_diff_config: TileDiffConfig,
359
360    /// Reset posterior on dimension change.
361    ///
362    /// When true, the Bayesian posterior resets to priors when the buffer
363    /// dimensions change (e.g., terminal resize).
364    ///
365    /// Default: true
366    pub reset_on_resize: bool,
367
368    /// Reset posterior on buffer invalidation.
369    ///
370    /// When true, resets to priors when the previous buffer becomes invalid
371    /// (e.g., mode switch, scroll region change).
372    ///
373    /// Default: true
374    pub reset_on_invalidation: bool,
375
376    /// Underlying strategy configuration.
377    ///
378    /// Contains cost model constants, prior parameters, and decay settings.
379    pub strategy_config: DiffStrategyConfig,
380}
381
382impl Default for RuntimeDiffConfig {
383    fn default() -> Self {
384        Self {
385            bayesian_enabled: true,
386            dirty_rows_enabled: true,
387            dirty_span_config: DirtySpanConfig::default(),
388            tile_diff_config: TileDiffConfig::default(),
389            reset_on_resize: true,
390            reset_on_invalidation: true,
391            strategy_config: DiffStrategyConfig::default(),
392        }
393    }
394}
395
396impl RuntimeDiffConfig {
397    /// Create a new config with all defaults.
398    pub fn new() -> Self {
399        Self::default()
400    }
401
402    /// Set whether Bayesian strategy selection is enabled.
403    pub fn with_bayesian_enabled(mut self, enabled: bool) -> Self {
404        self.bayesian_enabled = enabled;
405        self
406    }
407
408    /// Set whether dirty-row optimization is enabled.
409    pub fn with_dirty_rows_enabled(mut self, enabled: bool) -> Self {
410        self.dirty_rows_enabled = enabled;
411        self
412    }
413
414    /// Set whether dirty-span tracking is enabled.
415    pub fn with_dirty_spans_enabled(mut self, enabled: bool) -> Self {
416        self.dirty_span_config = self.dirty_span_config.with_enabled(enabled);
417        self
418    }
419
420    /// Set the dirty-span tracking configuration.
421    pub fn with_dirty_span_config(mut self, config: DirtySpanConfig) -> Self {
422        self.dirty_span_config = config;
423        self
424    }
425
426    /// Toggle tile-based skipping.
427    pub fn with_tile_skip_enabled(mut self, enabled: bool) -> Self {
428        self.tile_diff_config = self.tile_diff_config.with_enabled(enabled);
429        self
430    }
431
432    /// Set the tile-based diff configuration.
433    pub fn with_tile_diff_config(mut self, config: TileDiffConfig) -> Self {
434        self.tile_diff_config = config;
435        self
436    }
437
438    /// Set whether to reset posterior on resize.
439    pub fn with_reset_on_resize(mut self, enabled: bool) -> Self {
440        self.reset_on_resize = enabled;
441        self
442    }
443
444    /// Set whether to reset posterior on invalidation.
445    pub fn with_reset_on_invalidation(mut self, enabled: bool) -> Self {
446        self.reset_on_invalidation = enabled;
447        self
448    }
449
450    /// Set the underlying strategy configuration.
451    pub fn with_strategy_config(mut self, config: DiffStrategyConfig) -> Self {
452        self.strategy_config = config;
453        self
454    }
455}
456
457/// Unified terminal output coordinator.
458///
459/// Enforces the one-writer rule and implements inline mode correctly.
460/// All terminal output should go through this struct.
461pub struct TerminalWriter<W: Write> {
462    /// Buffered writer for efficient output. Option allows moving out for into_inner().
463    writer: Option<CountingWriter<BufWriter<W>>>,
464    /// Current screen mode.
465    screen_mode: ScreenMode,
466    /// Last computed auto UI height (inline auto mode only).
467    auto_ui_height: Option<u16>,
468    /// Where UI is anchored in inline mode.
469    ui_anchor: UiAnchor,
470    /// Previous buffer for diffing.
471    prev_buffer: Option<Buffer>,
472    /// Spare buffer for reuse as the next render target.
473    spare_buffer: Option<Buffer>,
474    /// Grapheme pool for complex characters.
475    pool: GraphemePool,
476    /// Link registry for hyperlinks.
477    links: LinkRegistry,
478    /// Terminal capabilities.
479    capabilities: TerminalCapabilities,
480    /// Terminal width in columns.
481    term_width: u16,
482    /// Terminal height in rows.
483    term_height: u16,
484    /// Whether we're in the middle of a sync block.
485    in_sync_block: bool,
486    /// Whether cursor has been saved.
487    cursor_saved: bool,
488    /// Current cursor visibility state (best-effort).
489    cursor_visible: bool,
490    /// Inline mode rendering strategy (selected from capabilities).
491    inline_strategy: InlineStrategy,
492    /// Whether a scroll region is currently active.
493    scroll_region_active: bool,
494    /// Last inline UI region for clearing on shrink.
495    last_inline_region: Option<InlineRegion>,
496    /// Bayesian diff strategy selector.
497    diff_strategy: DiffStrategySelector,
498    /// Reusable diff buffer to avoid per-frame allocations.
499    diff_scratch: BufferDiff,
500    /// Frames since last diff probe while in FullRedraw.
501    full_redraw_probe: u64,
502    /// Runtime diff configuration.
503    #[allow(dead_code)] // runtime toggles wired up in follow-up work
504    diff_config: RuntimeDiffConfig,
505    /// Evidence JSONL sink for diff decisions.
506    evidence_sink: Option<EvidenceSink>,
507    /// Run identifier for diff decision evidence.
508    #[allow(dead_code)]
509    diff_evidence_run_id: String,
510    /// Monotonic event index for diff decision evidence.
511    #[allow(dead_code)]
512    diff_evidence_idx: u64,
513    /// Last diff strategy selected during present.
514    last_diff_strategy: Option<DiffStrategy>,
515    /// Render-trace recorder (optional).
516    render_trace: Option<RenderTraceRecorder>,
517    /// Whether per-frame timing capture is enabled.
518    timing_enabled: bool,
519    /// Last present timings (diff compute duration).
520    last_present_timings: Option<PresentTimings>,
521}
522
523impl<W: Write> TerminalWriter<W> {
524    /// Create a new terminal writer.
525    ///
526    /// # Arguments
527    ///
528    /// * `writer` - Output destination (takes ownership for one-writer rule)
529    /// * `screen_mode` - Inline or alternate screen mode
530    /// * `ui_anchor` - Where to anchor UI in inline mode
531    /// * `capabilities` - Terminal capabilities
532    pub fn new(
533        writer: W,
534        screen_mode: ScreenMode,
535        ui_anchor: UiAnchor,
536        capabilities: TerminalCapabilities,
537    ) -> Self {
538        Self::with_diff_config(
539            writer,
540            screen_mode,
541            ui_anchor,
542            capabilities,
543            RuntimeDiffConfig::default(),
544        )
545    }
546
547    /// Create a new terminal writer with custom diff strategy configuration.
548    ///
549    /// # Arguments
550    ///
551    /// * `writer` - Output destination (takes ownership for one-writer rule)
552    /// * `screen_mode` - Inline or alternate screen mode
553    /// * `ui_anchor` - Where to anchor UI in inline mode
554    /// * `capabilities` - Terminal capabilities
555    /// * `diff_config` - Configuration for diff strategy selection
556    ///
557    /// # Example
558    ///
559    /// ```ignore
560    /// use ftui_runtime::{TerminalWriter, ScreenMode, UiAnchor, RuntimeDiffConfig};
561    /// use ftui_core::terminal_capabilities::TerminalCapabilities;
562    ///
563    /// // Disable Bayesian selection for deterministic diffing
564    /// let config = RuntimeDiffConfig::default()
565    ///     .with_bayesian_enabled(false);
566    ///
567    /// let writer = TerminalWriter::with_diff_config(
568    ///     std::io::stdout(),
569    ///     ScreenMode::AltScreen,
570    ///     UiAnchor::Bottom,
571    ///     TerminalCapabilities::detect(),
572    ///     config,
573    /// );
574    /// ```
575    pub fn with_diff_config(
576        writer: W,
577        screen_mode: ScreenMode,
578        ui_anchor: UiAnchor,
579        capabilities: TerminalCapabilities,
580        diff_config: RuntimeDiffConfig,
581    ) -> Self {
582        let inline_strategy = InlineStrategy::select(&capabilities);
583        let auto_ui_height = None;
584        let diff_strategy = DiffStrategySelector::new(diff_config.strategy_config.clone());
585        let mut diff_scratch = BufferDiff::new();
586        diff_scratch
587            .tile_config_mut()
588            .clone_from(&diff_config.tile_diff_config);
589        Self {
590            writer: Some(CountingWriter::new(BufWriter::with_capacity(
591                BUFFER_CAPACITY,
592                writer,
593            ))),
594            screen_mode,
595            auto_ui_height,
596            ui_anchor,
597            prev_buffer: None,
598            spare_buffer: None,
599            pool: GraphemePool::new(),
600            links: LinkRegistry::new(),
601            capabilities,
602            term_width: 80,
603            term_height: 24,
604            in_sync_block: false,
605            cursor_saved: false,
606            cursor_visible: true,
607            inline_strategy,
608            scroll_region_active: false,
609            last_inline_region: None,
610            diff_strategy,
611            diff_scratch,
612            full_redraw_probe: 0,
613            diff_config,
614            evidence_sink: None,
615            diff_evidence_run_id: default_diff_run_id(),
616            diff_evidence_idx: 0,
617            last_diff_strategy: None,
618            render_trace: None,
619            timing_enabled: false,
620            last_present_timings: None,
621        }
622    }
623
624    /// Get a mutable reference to the internal writer.
625    ///
626    /// # Panics
627    ///
628    /// Panics if the writer has been taken (via `into_inner`).
629    #[inline]
630    fn writer(&mut self) -> &mut CountingWriter<BufWriter<W>> {
631        self.writer.as_mut().expect("writer has been consumed")
632    }
633
634    /// Reset diff strategy state when the previous buffer is invalidated.
635    fn reset_diff_strategy(&mut self) {
636        if self.diff_config.reset_on_invalidation {
637            self.diff_strategy.reset();
638        }
639        self.full_redraw_probe = 0;
640        self.last_diff_strategy = None;
641    }
642
643    /// Reset diff strategy state on terminal resize.
644    #[allow(dead_code)] // used by upcoming resize-aware diff strategy work
645    fn reset_diff_on_resize(&mut self) {
646        if self.diff_config.reset_on_resize {
647            self.diff_strategy.reset();
648        }
649        self.full_redraw_probe = 0;
650        self.last_diff_strategy = None;
651    }
652
653    /// Get the current diff configuration.
654    pub fn diff_config(&self) -> &RuntimeDiffConfig {
655        &self.diff_config
656    }
657
658    /// Enable or disable per-frame timing capture.
659    pub(crate) fn set_timing_enabled(&mut self, enabled: bool) {
660        self.timing_enabled = enabled;
661        if !enabled {
662            self.last_present_timings = None;
663        }
664    }
665
666    /// Take the last present timings (if available).
667    pub(crate) fn take_last_present_timings(&mut self) -> Option<PresentTimings> {
668        self.last_present_timings.take()
669    }
670
671    /// Attach an evidence sink for diff decision logging.
672    #[must_use]
673    pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
674        self.evidence_sink = Some(sink);
675        self
676    }
677
678    /// Set the evidence JSONL sink for diff decision logging.
679    pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
680        self.evidence_sink = sink;
681    }
682
683    /// Attach a render-trace recorder.
684    #[must_use]
685    pub fn with_render_trace(mut self, recorder: RenderTraceRecorder) -> Self {
686        self.render_trace = Some(recorder);
687        self
688    }
689
690    /// Set the render-trace recorder.
691    pub fn set_render_trace(&mut self, recorder: Option<RenderTraceRecorder>) {
692        self.render_trace = recorder;
693    }
694
695    /// Get mutable access to the diff strategy selector.
696    ///
697    /// Useful for advanced scenarios like manual posterior updates.
698    pub fn diff_strategy_mut(&mut self) -> &mut DiffStrategySelector {
699        &mut self.diff_strategy
700    }
701
702    /// Get the diff strategy selector (read-only).
703    pub fn diff_strategy(&self) -> &DiffStrategySelector {
704        &self.diff_strategy
705    }
706
707    /// Get the last diff strategy selected during present, if any.
708    pub fn last_diff_strategy(&self) -> Option<DiffStrategy> {
709        self.last_diff_strategy
710    }
711
712    /// Set the terminal size.
713    ///
714    /// Call this when the terminal is resized.
715    pub fn set_size(&mut self, width: u16, height: u16) {
716        self.term_width = width;
717        self.term_height = height;
718        if matches!(self.screen_mode, ScreenMode::InlineAuto { .. }) {
719            self.auto_ui_height = None;
720        }
721        // Clear prev_buffer to force full redraw after resize
722        self.prev_buffer = None;
723        self.spare_buffer = None;
724        self.reset_diff_on_resize();
725        // Reset scroll region on resize; it will be re-established on next present
726        if self.scroll_region_active {
727            let _ = self.deactivate_scroll_region();
728        }
729    }
730
731    /// Take a reusable render buffer sized for the current frame.
732    ///
733    /// Uses a spare buffer when available to avoid per-frame allocation.
734    pub fn take_render_buffer(&mut self, width: u16, height: u16) -> Buffer {
735        if let Some(mut buffer) = self.spare_buffer.take()
736            && buffer.width() == width
737            && buffer.height() == height
738        {
739            buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
740            buffer.reset_for_frame();
741            return buffer;
742        }
743
744        let mut buffer = Buffer::new(width, height);
745        buffer.set_dirty_span_config(self.diff_config.dirty_span_config);
746        buffer
747    }
748
749    /// Get the current terminal width.
750    pub fn width(&self) -> u16 {
751        self.term_width
752    }
753
754    /// Get the current terminal height.
755    pub fn height(&self) -> u16 {
756        self.term_height
757    }
758
759    /// Get the current screen mode.
760    pub fn screen_mode(&self) -> ScreenMode {
761        self.screen_mode
762    }
763
764    /// Height to use for rendering a frame.
765    ///
766    /// In inline auto mode, this returns the configured maximum (clamped to
767    /// terminal height) so measurement can determine actual UI height.
768    pub fn render_height_hint(&self) -> u16 {
769        match self.screen_mode {
770            ScreenMode::Inline { ui_height } => ui_height,
771            ScreenMode::InlineAuto {
772                min_height,
773                max_height,
774            } => {
775                let (min, max) = sanitize_auto_bounds(min_height, max_height);
776                let max = max.min(self.term_height);
777                let min = min.min(max);
778                if let Some(current) = self.auto_ui_height {
779                    current.clamp(min, max).min(self.term_height).max(min)
780                } else {
781                    max.max(min)
782                }
783            }
784            ScreenMode::AltScreen => self.term_height,
785        }
786    }
787
788    /// Get sanitized min/max bounds for inline auto mode (clamped to terminal height).
789    pub fn inline_auto_bounds(&self) -> Option<(u16, u16)> {
790        match self.screen_mode {
791            ScreenMode::InlineAuto {
792                min_height,
793                max_height,
794            } => {
795                let (min, max) = sanitize_auto_bounds(min_height, max_height);
796                Some((min.min(self.term_height), max.min(self.term_height)))
797            }
798            _ => None,
799        }
800    }
801
802    /// Get the cached auto UI height (inline auto mode only).
803    pub fn auto_ui_height(&self) -> Option<u16> {
804        match self.screen_mode {
805            ScreenMode::InlineAuto { .. } => self.auto_ui_height,
806            _ => None,
807        }
808    }
809
810    /// Update the computed height for inline auto mode.
811    pub fn set_auto_ui_height(&mut self, height: u16) {
812        if let ScreenMode::InlineAuto {
813            min_height,
814            max_height,
815        } = self.screen_mode
816        {
817            let (min, max) = sanitize_auto_bounds(min_height, max_height);
818            let max = max.min(self.term_height);
819            let min = min.min(max);
820            let clamped = height.clamp(min, max);
821            let previous_effective = self.auto_ui_height.unwrap_or(min);
822            if self.auto_ui_height != Some(clamped) {
823                self.auto_ui_height = Some(clamped);
824                if clamped != previous_effective {
825                    self.prev_buffer = None;
826                    self.reset_diff_strategy();
827                    if self.scroll_region_active {
828                        let _ = self.deactivate_scroll_region();
829                    }
830                }
831            }
832        }
833    }
834
835    /// Clear the cached auto UI height (inline auto mode only).
836    pub fn clear_auto_ui_height(&mut self) {
837        if matches!(self.screen_mode, ScreenMode::InlineAuto { .. })
838            && self.auto_ui_height.is_some()
839        {
840            self.auto_ui_height = None;
841            self.prev_buffer = None;
842            self.reset_diff_strategy();
843            if self.scroll_region_active {
844                let _ = self.deactivate_scroll_region();
845            }
846        }
847    }
848
849    fn effective_ui_height(&self) -> u16 {
850        match self.screen_mode {
851            ScreenMode::Inline { ui_height } => ui_height,
852            ScreenMode::InlineAuto {
853                min_height,
854                max_height,
855            } => {
856                let (min, max) = sanitize_auto_bounds(min_height, max_height);
857                let current = self.auto_ui_height.unwrap_or(min);
858                current.clamp(min, max).min(self.term_height)
859            }
860            ScreenMode::AltScreen => self.term_height,
861        }
862    }
863
864    /// Get the UI height for the current mode.
865    pub fn ui_height(&self) -> u16 {
866        self.effective_ui_height()
867    }
868
869    /// Calculate the row where the UI starts (0-indexed).
870    fn ui_start_row(&self) -> u16 {
871        let ui_height = self.effective_ui_height().min(self.term_height);
872        match (self.screen_mode, self.ui_anchor) {
873            (ScreenMode::Inline { .. }, UiAnchor::Bottom)
874            | (ScreenMode::InlineAuto { .. }, UiAnchor::Bottom) => {
875                self.term_height.saturating_sub(ui_height)
876            }
877            (ScreenMode::Inline { .. }, UiAnchor::Top)
878            | (ScreenMode::InlineAuto { .. }, UiAnchor::Top) => 0,
879            (ScreenMode::AltScreen, _) => 0,
880        }
881    }
882
883    /// Get the inline mode rendering strategy.
884    pub fn inline_strategy(&self) -> InlineStrategy {
885        self.inline_strategy
886    }
887
888    /// Check if a scroll region is currently active.
889    pub fn scroll_region_active(&self) -> bool {
890        self.scroll_region_active
891    }
892
893    /// Activate the scroll region for inline mode.
894    ///
895    /// Sets DECSTBM to constrain scrolling to the log region:
896    /// - Bottom-anchored UI: log region is above the UI.
897    /// - Top-anchored UI: log region is below the UI.
898    ///
899    /// Only called when the strategy permits scroll-region usage.
900    fn activate_scroll_region(&mut self, ui_height: u16) -> io::Result<()> {
901        if self.scroll_region_active {
902            return Ok(());
903        }
904
905        let ui_height = ui_height.min(self.term_height);
906        if ui_height >= self.term_height {
907            return Ok(());
908        }
909
910        match self.ui_anchor {
911            UiAnchor::Bottom => {
912                let term_height = self.term_height;
913                let log_bottom = term_height.saturating_sub(ui_height);
914                if log_bottom > 0 {
915                    // DECSTBM: set scroll region to rows 1..log_bottom (1-indexed)
916                    write!(self.writer(), "\x1b[1;{}r", log_bottom)?;
917                    self.scroll_region_active = true;
918                }
919            }
920            UiAnchor::Top => {
921                let term_height = self.term_height;
922                let log_top = ui_height.saturating_add(1);
923                if log_top <= term_height {
924                    // DECSTBM: set scroll region to rows log_top..term_height (1-indexed)
925                    write!(self.writer(), "\x1b[{};{}r", log_top, term_height)?;
926                    self.scroll_region_active = true;
927                    // DECSTBM moves cursor to home; for top-anchored UI we must
928                    // move it into the log region so restored cursor stays below UI.
929                    write!(self.writer(), "\x1b[{};1H", log_top)?;
930                }
931            }
932        }
933        Ok(())
934    }
935
936    /// Deactivate the scroll region, resetting to full screen.
937    fn deactivate_scroll_region(&mut self) -> io::Result<()> {
938        if self.scroll_region_active {
939            self.writer().write_all(b"\x1b[r")?;
940            self.scroll_region_active = false;
941        }
942        Ok(())
943    }
944
945    fn clear_rows(&mut self, start_row: u16, height: u16) -> io::Result<()> {
946        let start_row = start_row.min(self.term_height);
947        let end_row = start_row.saturating_add(height).min(self.term_height);
948        for row in start_row..end_row {
949            write!(self.writer(), "\x1b[{};1H", row.saturating_add(1))?;
950            self.writer().write_all(ERASE_LINE)?;
951        }
952        Ok(())
953    }
954
955    fn clear_inline_region_diff(&mut self, current: InlineRegion) -> io::Result<()> {
956        let Some(previous) = self.last_inline_region else {
957            return Ok(());
958        };
959
960        let prev_start = previous.start.min(self.term_height);
961        let prev_end = previous
962            .start
963            .saturating_add(previous.height)
964            .min(self.term_height);
965        if prev_start >= prev_end {
966            return Ok(());
967        }
968
969        let curr_start = current.start.min(self.term_height);
970        let curr_end = current
971            .start
972            .saturating_add(current.height)
973            .min(self.term_height);
974
975        if curr_start > prev_start {
976            let clear_end = curr_start.min(prev_end);
977            if clear_end > prev_start {
978                self.clear_rows(prev_start, clear_end - prev_start)?;
979            }
980        }
981
982        if curr_end < prev_end {
983            let clear_start = curr_end.max(prev_start);
984            if prev_end > clear_start {
985                self.clear_rows(clear_start, prev_end - clear_start)?;
986            }
987        }
988
989        Ok(())
990    }
991
992    /// Present a UI frame.
993    ///
994    /// In inline mode, this:
995    /// 1. Begins synchronized output (if supported)
996    /// 2. Saves cursor position
997    /// 3. Moves to UI region and clears it
998    /// 4. Renders the buffer using the presenter
999    /// 5. Restores cursor position
1000    /// 6. Moves cursor to requested UI position (if any)
1001    /// 7. Applies cursor visibility
1002    /// 8. Ends synchronized output
1003    ///
1004    /// In AltScreen mode, this just renders the buffer and positions cursor.
1005    pub fn present_ui(
1006        &mut self,
1007        buffer: &Buffer,
1008        cursor: Option<(u16, u16)>,
1009        cursor_visible: bool,
1010    ) -> io::Result<()> {
1011        let mode_str = match self.screen_mode {
1012            ScreenMode::Inline { .. } => "inline",
1013            ScreenMode::InlineAuto { .. } => "inline_auto",
1014            ScreenMode::AltScreen => "altscreen",
1015        };
1016        let trace_enabled = self.render_trace.is_some();
1017        if trace_enabled {
1018            self.writer().enable_counting();
1019        }
1020        let present_start = if trace_enabled {
1021            Some(std::time::Instant::now())
1022        } else {
1023            None
1024        };
1025        let _span = info_span!(
1026            "ftui.render.present",
1027            mode = mode_str,
1028            width = buffer.width(),
1029            height = buffer.height(),
1030        )
1031        .entered();
1032
1033        let result = match self.screen_mode {
1034            ScreenMode::Inline { ui_height } => {
1035                self.present_inline(buffer, ui_height, cursor, cursor_visible)
1036            }
1037            ScreenMode::InlineAuto { .. } => {
1038                let ui_height = self.effective_ui_height();
1039                self.present_inline(buffer, ui_height, cursor, cursor_visible)
1040            }
1041            ScreenMode::AltScreen => self.present_altscreen(buffer, cursor, cursor_visible),
1042        };
1043
1044        let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1045        let present_bytes = if trace_enabled {
1046            Some(self.writer().take_count())
1047        } else {
1048            None
1049        };
1050        if trace_enabled {
1051            self.writer().disable_counting();
1052        }
1053
1054        if let Ok(stats) = result {
1055            self.spare_buffer = self.prev_buffer.take();
1056            self.prev_buffer = Some(buffer.clone());
1057
1058            if let Some(ref mut trace) = self.render_trace {
1059                let payload_info = match stats.diff_strategy {
1060                    DiffStrategy::FullRedraw => {
1061                        let payload = build_full_buffer_payload(buffer, &self.pool);
1062                        trace.write_payload(&payload).ok()
1063                    }
1064                    _ => {
1065                        let payload =
1066                            build_diff_runs_payload(buffer, &self.diff_scratch, &self.pool);
1067                        trace.write_payload(&payload).ok()
1068                    }
1069                };
1070                let (payload_kind, payload_path) = match payload_info {
1071                    Some(info) => (info.kind, Some(info.path)),
1072                    None => ("none", None),
1073                };
1074                let payload_path_ref = payload_path.as_deref();
1075                let diff_strategy = diff_strategy_str(stats.diff_strategy);
1076                let ui_anchor = ui_anchor_str(self.ui_anchor);
1077                let frame = RenderTraceFrame {
1078                    cols: buffer.width(),
1079                    rows: buffer.height(),
1080                    mode: mode_str,
1081                    ui_height: stats.ui_height,
1082                    ui_anchor,
1083                    diff_strategy,
1084                    diff_cells: stats.diff_cells,
1085                    diff_runs: stats.diff_runs,
1086                    present_bytes: present_bytes.unwrap_or(0),
1087                    render_us: None,
1088                    present_us,
1089                    payload_kind,
1090                    payload_path: payload_path_ref,
1091                    trace_us: None,
1092                };
1093                let _ = trace.record_frame(frame, buffer, &self.pool);
1094            }
1095            return Ok(());
1096        }
1097
1098        result.map(|_| ())
1099    }
1100
1101    /// Present a UI frame, taking ownership of the buffer (O(1) — no clone).
1102    ///
1103    /// Prefer this over [`present_ui`] when the caller has an owned buffer
1104    /// that won't be reused, as it avoids an O(width × height) clone.
1105    pub fn present_ui_owned(
1106        &mut self,
1107        buffer: Buffer,
1108        cursor: Option<(u16, u16)>,
1109        cursor_visible: bool,
1110    ) -> io::Result<()> {
1111        let mode_str = match self.screen_mode {
1112            ScreenMode::Inline { .. } => "inline",
1113            ScreenMode::InlineAuto { .. } => "inline_auto",
1114            ScreenMode::AltScreen => "altscreen",
1115        };
1116        let trace_enabled = self.render_trace.is_some();
1117        if trace_enabled {
1118            self.writer().enable_counting();
1119        }
1120        let present_start = if trace_enabled {
1121            Some(std::time::Instant::now())
1122        } else {
1123            None
1124        };
1125        let _span = info_span!(
1126            "ftui.render.present",
1127            mode = mode_str,
1128            width = buffer.width(),
1129            height = buffer.height(),
1130        )
1131        .entered();
1132
1133        let result = match self.screen_mode {
1134            ScreenMode::Inline { ui_height } => {
1135                self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1136            }
1137            ScreenMode::InlineAuto { .. } => {
1138                let ui_height = self.effective_ui_height();
1139                self.present_inline(&buffer, ui_height, cursor, cursor_visible)
1140            }
1141            ScreenMode::AltScreen => self.present_altscreen(&buffer, cursor, cursor_visible),
1142        };
1143
1144        let present_us = present_start.map(|start| start.elapsed().as_micros() as u64);
1145        let present_bytes = if trace_enabled {
1146            Some(self.writer().take_count())
1147        } else {
1148            None
1149        };
1150        if trace_enabled {
1151            self.writer().disable_counting();
1152        }
1153
1154        if let Ok(stats) = result {
1155            if let Some(ref mut trace) = self.render_trace {
1156                let payload_info = match stats.diff_strategy {
1157                    DiffStrategy::FullRedraw => {
1158                        let payload = build_full_buffer_payload(&buffer, &self.pool);
1159                        trace.write_payload(&payload).ok()
1160                    }
1161                    _ => {
1162                        let payload =
1163                            build_diff_runs_payload(&buffer, &self.diff_scratch, &self.pool);
1164                        trace.write_payload(&payload).ok()
1165                    }
1166                };
1167                let (payload_kind, payload_path) = match payload_info {
1168                    Some(info) => (info.kind, Some(info.path)),
1169                    None => ("none", None),
1170                };
1171                let payload_path_ref = payload_path.as_deref();
1172                let diff_strategy = diff_strategy_str(stats.diff_strategy);
1173                let ui_anchor = ui_anchor_str(self.ui_anchor);
1174                let frame = RenderTraceFrame {
1175                    cols: buffer.width(),
1176                    rows: buffer.height(),
1177                    mode: mode_str,
1178                    ui_height: stats.ui_height,
1179                    ui_anchor,
1180                    diff_strategy,
1181                    diff_cells: stats.diff_cells,
1182                    diff_runs: stats.diff_runs,
1183                    present_bytes: present_bytes.unwrap_or(0),
1184                    render_us: None,
1185                    present_us,
1186                    payload_kind,
1187                    payload_path: payload_path_ref,
1188                    trace_us: None,
1189                };
1190                let _ = trace.record_frame(frame, &buffer, &self.pool);
1191            }
1192
1193            self.spare_buffer = self.prev_buffer.take();
1194            self.prev_buffer = Some(buffer);
1195            return Ok(());
1196        }
1197
1198        result.map(|_| ())
1199    }
1200
1201    fn decide_diff(&mut self, buffer: &Buffer) -> DiffDecision {
1202        let prev_dims = self
1203            .prev_buffer
1204            .as_ref()
1205            .map(|prev| (prev.width(), prev.height()));
1206        if prev_dims.is_none() || prev_dims != Some((buffer.width(), buffer.height())) {
1207            self.full_redraw_probe = 0;
1208            self.last_diff_strategy = Some(DiffStrategy::FullRedraw);
1209            return DiffDecision {
1210                strategy: DiffStrategy::FullRedraw,
1211                has_diff: false,
1212            };
1213        }
1214
1215        let dirty_rows = buffer.dirty_row_count();
1216        let width = buffer.width() as usize;
1217        let height = buffer.height() as usize;
1218        let mut span_stats_snapshot: Option<DirtySpanStats> = None;
1219        let mut dirty_scan_cells_estimate = dirty_rows.saturating_mul(width);
1220
1221        if self.diff_config.bayesian_enabled {
1222            let span_stats = buffer.dirty_span_stats();
1223            if span_stats.span_coverage_cells > 0 {
1224                dirty_scan_cells_estimate = span_stats.span_coverage_cells;
1225            }
1226            span_stats_snapshot = Some(span_stats);
1227        }
1228
1229        // Select strategy based on config
1230        let mut strategy = if self.diff_config.bayesian_enabled {
1231            // Use Bayesian selector
1232            self.diff_strategy.select_with_scan_estimate(
1233                buffer.width(),
1234                buffer.height(),
1235                dirty_rows,
1236                dirty_scan_cells_estimate,
1237            )
1238        } else {
1239            // Simple heuristic: use DirtyRows if few rows dirty, else Full
1240            if self.diff_config.dirty_rows_enabled && dirty_rows < buffer.height() as usize {
1241                DiffStrategy::DirtyRows
1242            } else {
1243                DiffStrategy::Full
1244            }
1245        };
1246
1247        // Enforce dirty_rows_enabled toggle
1248        if !self.diff_config.dirty_rows_enabled && strategy == DiffStrategy::DirtyRows {
1249            strategy = DiffStrategy::Full;
1250            if self.diff_config.bayesian_enabled {
1251                self.diff_strategy
1252                    .override_last_strategy(strategy, "dirty_rows_disabled");
1253            }
1254        }
1255
1256        // Periodic probe when FullRedraw is selected (to update posterior)
1257        if strategy == DiffStrategy::FullRedraw {
1258            if self.full_redraw_probe >= FULL_REDRAW_PROBE_INTERVAL {
1259                self.full_redraw_probe = 0;
1260                let probed = if self.diff_config.dirty_rows_enabled
1261                    && dirty_rows < buffer.height() as usize
1262                {
1263                    DiffStrategy::DirtyRows
1264                } else {
1265                    DiffStrategy::Full
1266                };
1267                if probed != strategy {
1268                    strategy = probed;
1269                    if self.diff_config.bayesian_enabled {
1270                        self.diff_strategy
1271                            .override_last_strategy(strategy, "full_redraw_probe");
1272                    }
1273                }
1274            } else {
1275                self.full_redraw_probe = self.full_redraw_probe.saturating_add(1);
1276            }
1277        } else {
1278            self.full_redraw_probe = 0;
1279        }
1280
1281        let mut has_diff = false;
1282        match strategy {
1283            DiffStrategy::Full => {
1284                let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1285                self.diff_scratch.compute_into(prev, buffer);
1286                has_diff = true;
1287            }
1288            DiffStrategy::DirtyRows => {
1289                let prev = self.prev_buffer.as_ref().expect("prev buffer must exist");
1290                self.diff_scratch.compute_dirty_into(prev, buffer);
1291                has_diff = true;
1292            }
1293            DiffStrategy::FullRedraw => {}
1294        }
1295
1296        let mut scan_cost_estimate = 0usize;
1297        let mut fallback_reason: &'static str = "none";
1298        let tile_stats = if strategy == DiffStrategy::DirtyRows {
1299            self.diff_scratch.last_tile_stats()
1300        } else {
1301            None
1302        };
1303
1304        // Update posterior if Bayesian mode is enabled
1305        if self.diff_config.bayesian_enabled && has_diff {
1306            let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1307            let (scan_cost, reason) = estimate_diff_scan_cost(
1308                strategy,
1309                dirty_rows,
1310                width,
1311                height,
1312                &span_stats,
1313                tile_stats,
1314            );
1315            let scanned_cells = scan_cost.max(self.diff_scratch.len());
1316            self.diff_strategy
1317                .observe(scanned_cells, self.diff_scratch.len());
1318            span_stats_snapshot = Some(span_stats);
1319            scan_cost_estimate = scan_cost;
1320            fallback_reason = reason;
1321        }
1322
1323        if let Some(evidence) = self.diff_strategy.last_evidence() {
1324            let span_stats = span_stats_snapshot.unwrap_or_else(|| buffer.dirty_span_stats());
1325            let (scan_cost, reason) = if span_stats_snapshot.is_some() {
1326                (scan_cost_estimate, fallback_reason)
1327            } else {
1328                estimate_diff_scan_cost(
1329                    strategy,
1330                    dirty_rows,
1331                    width,
1332                    height,
1333                    &span_stats,
1334                    tile_stats,
1335                )
1336            };
1337            let span_coverage_pct = if evidence.total_cells == 0 {
1338                0.0
1339            } else {
1340                (span_stats.span_coverage_cells as f64 / evidence.total_cells as f64) * 100.0
1341            };
1342            let span_count = span_stats.total_spans;
1343            let max_span_len = span_stats.max_span_len;
1344            let event_idx = self.diff_evidence_idx;
1345            self.diff_evidence_idx = self.diff_evidence_idx.saturating_add(1);
1346            let tile_used = tile_stats.is_some_and(|stats| stats.fallback.is_none());
1347            let tile_fallback = tile_stats
1348                .and_then(|stats| stats.fallback)
1349                .map(TileDiffFallback::as_str)
1350                .unwrap_or("none");
1351            let run_id = json_escape(&self.diff_evidence_run_id);
1352            let strategy_json = json_escape(&strategy.to_string());
1353            let guard_reason_json = json_escape(evidence.guard_reason);
1354            let fallback_reason_json = json_escape(reason);
1355            let tile_fallback_json = json_escape(tile_fallback);
1356            let schema_version = crate::evidence_sink::EVIDENCE_SCHEMA_VERSION;
1357            let screen_mode = match self.screen_mode {
1358                ScreenMode::Inline { .. } => "inline",
1359                ScreenMode::InlineAuto { .. } => "inline_auto",
1360                ScreenMode::AltScreen => "altscreen",
1361            };
1362            let (
1363                tile_w,
1364                tile_h,
1365                tiles_x,
1366                tiles_y,
1367                dirty_tiles,
1368                dirty_cells,
1369                dirty_tile_ratio,
1370                dirty_cell_ratio,
1371                scanned_tiles,
1372                skipped_tiles,
1373                scan_cells_estimate,
1374                sat_build_cells,
1375            ) = if let Some(stats) = tile_stats {
1376                (
1377                    stats.tile_w,
1378                    stats.tile_h,
1379                    stats.tiles_x,
1380                    stats.tiles_y,
1381                    stats.dirty_tiles,
1382                    stats.dirty_cells,
1383                    stats.dirty_tile_ratio,
1384                    stats.dirty_cell_ratio,
1385                    stats.scanned_tiles,
1386                    stats.skipped_tiles,
1387                    stats.scan_cells_estimate,
1388                    stats.sat_build_cells,
1389                )
1390            } else {
1391                (0, 0, 0, 0, 0, 0, 0.0, 0.0, 0, 0, 0, 0)
1392            };
1393            let tile_size = tile_w as usize * tile_h as usize;
1394            let dirty_tile_count = dirty_tiles;
1395            let skipped_tile_count = skipped_tiles;
1396            let sat_build_cost_est = sat_build_cells;
1397
1398            set_diff_snapshot(Some(DiffDecisionSnapshot {
1399                event_idx,
1400                screen_mode: screen_mode.to_string(),
1401                cols: u16::try_from(width).unwrap_or(u16::MAX),
1402                rows: u16::try_from(height).unwrap_or(u16::MAX),
1403                evidence: evidence.clone(),
1404                span_count,
1405                span_coverage_pct,
1406                max_span_len,
1407                scan_cost_estimate: scan_cost,
1408                fallback_reason: reason.to_string(),
1409                tile_used,
1410                tile_fallback: tile_fallback.to_string(),
1411                strategy_used: strategy,
1412            }));
1413
1414            trace!(
1415                strategy = %strategy,
1416                selected = %evidence.strategy,
1417                cost_full = evidence.cost_full,
1418                cost_dirty = evidence.cost_dirty,
1419                cost_redraw = evidence.cost_redraw,
1420                dirty_rows = evidence.dirty_rows,
1421                total_rows = evidence.total_rows,
1422                total_cells = evidence.total_cells,
1423                bayesian_enabled = self.diff_config.bayesian_enabled,
1424                dirty_rows_enabled = self.diff_config.dirty_rows_enabled,
1425                "diff strategy selected"
1426            );
1427            if let Some(ref sink) = self.evidence_sink {
1428                let line = format!(
1429                    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":{}}}"#,
1430                    schema_version,
1431                    run_id,
1432                    event_idx,
1433                    screen_mode,
1434                    width,
1435                    height,
1436                    strategy_json,
1437                    evidence.cost_full,
1438                    evidence.cost_dirty,
1439                    evidence.cost_redraw,
1440                    evidence.posterior_mean,
1441                    evidence.posterior_variance,
1442                    evidence.alpha,
1443                    evidence.beta,
1444                    guard_reason_json,
1445                    evidence.hysteresis_applied,
1446                    evidence.hysteresis_ratio,
1447                    evidence.dirty_rows,
1448                    evidence.total_rows,
1449                    evidence.total_cells,
1450                    span_count,
1451                    span_coverage_pct,
1452                    max_span_len,
1453                    fallback_reason_json,
1454                    scan_cost,
1455                    tile_used,
1456                    tile_fallback_json,
1457                    tile_w,
1458                    tile_h,
1459                    tile_size,
1460                    tiles_x,
1461                    tiles_y,
1462                    dirty_tiles,
1463                    dirty_tile_count,
1464                    dirty_cells,
1465                    dirty_tile_ratio,
1466                    dirty_cell_ratio,
1467                    scanned_tiles,
1468                    skipped_tiles,
1469                    skipped_tile_count,
1470                    scan_cells_estimate,
1471                    sat_build_cost_est,
1472                    self.diff_config.bayesian_enabled,
1473                    self.diff_config.dirty_rows_enabled,
1474                );
1475                let _ = sink.write_jsonl(&line);
1476            }
1477        }
1478
1479        self.last_diff_strategy = Some(strategy);
1480        DiffDecision { strategy, has_diff }
1481    }
1482
1483    /// Present UI in inline mode with cursor save/restore.
1484    ///
1485    /// When the scroll-region strategy is active, DECSTBM is set to constrain
1486    /// log scrolling to the region above the UI. This prevents log output from
1487    /// overwriting the UI, reducing redraw work.
1488    fn present_inline(
1489        &mut self,
1490        buffer: &Buffer,
1491        ui_height: u16,
1492        cursor: Option<(u16, u16)>,
1493        cursor_visible: bool,
1494    ) -> io::Result<FrameEmitStats> {
1495        let visible_height = ui_height.min(self.term_height);
1496        let ui_y_start = self.ui_start_row();
1497        let current_region = InlineRegion {
1498            start: ui_y_start,
1499            height: visible_height,
1500        };
1501
1502        // Activate scroll region if strategy calls for it
1503        {
1504            let _span = debug_span!("ftui.render.scroll_region").entered();
1505            if visible_height > 0 {
1506                match self.inline_strategy {
1507                    InlineStrategy::ScrollRegion => {
1508                        self.activate_scroll_region(visible_height)?;
1509                    }
1510                    InlineStrategy::Hybrid => {
1511                        self.activate_scroll_region(visible_height)?;
1512                    }
1513                    InlineStrategy::OverlayRedraw => {}
1514                }
1515            } else if self.scroll_region_active {
1516                self.deactivate_scroll_region()?;
1517            }
1518        }
1519
1520        // Begin sync output if available
1521        if self.capabilities.sync_output && !self.in_sync_block {
1522            self.writer().write_all(SYNC_BEGIN)?;
1523            self.in_sync_block = true;
1524        }
1525
1526        // Save cursor (DEC save)
1527        self.writer().write_all(CURSOR_SAVE)?;
1528        self.cursor_saved = true;
1529
1530        self.clear_inline_region_diff(current_region)?;
1531
1532        let mut diff_strategy = DiffStrategy::FullRedraw;
1533        let mut diff_us = 0u64;
1534        let mut emit_stats = EmitStats {
1535            diff_cells: 0,
1536            diff_runs: 0,
1537        };
1538
1539        if visible_height > 0 {
1540            // If this is a full redraw (no previous buffer), we must clear the
1541            // entire UI region first to ensure we aren't diffing against garbage.
1542            if self.prev_buffer.is_none() {
1543                self.clear_rows(ui_y_start, visible_height)?;
1544            } else {
1545                // If the buffer is shorter than the visible height, clear the remaining rows
1546                // to prevent ghosting from previous larger buffers.
1547                let buf_height = buffer.height().min(visible_height);
1548                if buf_height < visible_height {
1549                    let clear_start = ui_y_start.saturating_add(buf_height);
1550                    let clear_height = visible_height.saturating_sub(buf_height);
1551                    self.clear_rows(clear_start, clear_height)?;
1552                }
1553            }
1554
1555            // Compute diff
1556            let diff_start = if self.timing_enabled {
1557                Some(Instant::now())
1558            } else {
1559                None
1560            };
1561            let decision = {
1562                let _span = debug_span!("ftui.render.diff_compute").entered();
1563                self.decide_diff(buffer)
1564            };
1565            if let Some(start) = diff_start {
1566                diff_us = start.elapsed().as_micros() as u64;
1567            }
1568            diff_strategy = decision.strategy;
1569
1570            // Emit diff
1571            {
1572                let _span = debug_span!("ftui.render.emit").entered();
1573                if decision.has_diff {
1574                    let diff = std::mem::take(&mut self.diff_scratch);
1575                    let result = self.emit_diff(buffer, &diff, Some(visible_height), ui_y_start);
1576                    self.diff_scratch = diff;
1577                    emit_stats = result?;
1578                } else {
1579                    emit_stats = self.emit_full_redraw(buffer, Some(visible_height), ui_y_start)?;
1580                }
1581            }
1582        }
1583
1584        // Reset style so subsequent log output doesn't inherit UI styling.
1585        self.writer().write_all(b"\x1b[0m")?;
1586
1587        // Restore cursor
1588        self.writer().write_all(CURSOR_RESTORE)?;
1589        self.cursor_saved = false;
1590
1591        if cursor_visible {
1592            // Apply requested cursor position (relative to UI)
1593            if let Some((cx, cy)) = cursor
1594                && cy < visible_height
1595            {
1596                // Move to UI start + cursor y
1597                let abs_y = ui_y_start.saturating_add(cy);
1598                write!(
1599                    self.writer(),
1600                    "\x1b[{};{}H",
1601                    abs_y.saturating_add(1),
1602                    cx.saturating_add(1)
1603                )?;
1604            }
1605            self.set_cursor_visibility(true)?;
1606        } else {
1607            self.set_cursor_visibility(false)?;
1608        }
1609
1610        // End sync output
1611        if self.in_sync_block {
1612            self.writer().write_all(SYNC_END)?;
1613            self.in_sync_block = false;
1614        }
1615
1616        self.writer().flush()?;
1617        self.last_inline_region = if visible_height > 0 {
1618            Some(current_region)
1619        } else {
1620            None
1621        };
1622
1623        if self.timing_enabled {
1624            self.last_present_timings = Some(PresentTimings { diff_us });
1625        }
1626
1627        Ok(FrameEmitStats {
1628            diff_strategy,
1629            diff_cells: emit_stats.diff_cells,
1630            diff_runs: emit_stats.diff_runs,
1631            ui_height: visible_height,
1632        })
1633    }
1634
1635    /// Present UI in alternate screen mode (simpler, no cursor gymnastics).
1636    fn present_altscreen(
1637        &mut self,
1638        buffer: &Buffer,
1639        cursor: Option<(u16, u16)>,
1640        cursor_visible: bool,
1641    ) -> io::Result<FrameEmitStats> {
1642        let diff_start = if self.timing_enabled {
1643            Some(Instant::now())
1644        } else {
1645            None
1646        };
1647        let decision = {
1648            let _span = debug_span!("ftui.render.diff_compute").entered();
1649            self.decide_diff(buffer)
1650        };
1651        let diff_us = diff_start
1652            .map(|start| start.elapsed().as_micros() as u64)
1653            .unwrap_or(0);
1654
1655        // Begin sync if available
1656        if self.capabilities.sync_output {
1657            self.writer().write_all(SYNC_BEGIN)?;
1658        }
1659
1660        let emit_stats = {
1661            let _span = debug_span!("ftui.render.emit").entered();
1662            if decision.has_diff {
1663                let diff = std::mem::take(&mut self.diff_scratch);
1664                let result = self.emit_diff(buffer, &diff, None, 0);
1665                self.diff_scratch = diff;
1666                result?
1667            } else {
1668                self.emit_full_redraw(buffer, None, 0)?
1669            }
1670        };
1671
1672        // Reset style at end
1673        self.writer().write_all(b"\x1b[0m")?;
1674
1675        if cursor_visible {
1676            // Apply requested cursor position
1677            if let Some((cx, cy)) = cursor {
1678                write!(
1679                    self.writer(),
1680                    "\x1b[{};{}H",
1681                    cy.saturating_add(1),
1682                    cx.saturating_add(1)
1683                )?;
1684            }
1685            self.set_cursor_visibility(true)?;
1686        } else {
1687            self.set_cursor_visibility(false)?;
1688        }
1689
1690        if self.capabilities.sync_output {
1691            self.writer().write_all(SYNC_END)?;
1692        }
1693
1694        self.writer().flush()?;
1695
1696        if self.timing_enabled {
1697            self.last_present_timings = Some(PresentTimings { diff_us });
1698        }
1699
1700        Ok(FrameEmitStats {
1701            diff_strategy: decision.strategy,
1702            diff_cells: emit_stats.diff_cells,
1703            diff_runs: emit_stats.diff_runs,
1704            ui_height: 0,
1705        })
1706    }
1707
1708    /// Emit a diff directly to the writer.
1709    fn emit_diff(
1710        &mut self,
1711        buffer: &Buffer,
1712        diff: &BufferDiff,
1713        max_height: Option<u16>,
1714        ui_y_start: u16,
1715    ) -> io::Result<EmitStats> {
1716        use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
1717
1718        let runs = diff.runs();
1719        let diff_runs = runs.len();
1720        let diff_cells = diff.len();
1721        let _span = debug_span!("ftui.render.emit_diff", run_count = runs.len()).entered();
1722
1723        let mut current_style: Option<(
1724            ftui_render::cell::PackedRgba,
1725            ftui_render::cell::PackedRgba,
1726            StyleFlags,
1727        )> = None;
1728        let mut current_link: Option<u32> = None;
1729        let default_cell = Cell::default();
1730
1731        // Borrow writer once
1732        let writer = self.writer.as_mut().expect("writer has been consumed");
1733
1734        for run in runs {
1735            if let Some(limit) = max_height
1736                && run.y >= limit
1737            {
1738                continue;
1739            }
1740            // Move cursor to run start
1741            write!(
1742                writer,
1743                "\x1b[{};{}H",
1744                ui_y_start.saturating_add(run.y).saturating_add(1),
1745                run.x0.saturating_add(1)
1746            )?;
1747
1748            // Emit cells in the run
1749            let mut cursor_x = run.x0;
1750            for x in run.x0..=run.x1 {
1751                let cell = buffer.get_unchecked(x, run.y);
1752
1753                // Skip continuation cells unless they are orphaned.
1754                let is_orphan = cell.is_continuation() && cursor_x <= x;
1755                if cell.is_continuation() && !is_orphan {
1756                    continue;
1757                }
1758                let effective_cell = if is_orphan { &default_cell } else { cell };
1759
1760                // Check if style changed
1761                let cell_style = (
1762                    effective_cell.fg,
1763                    effective_cell.bg,
1764                    effective_cell.attrs.flags(),
1765                );
1766                if current_style != Some(cell_style) {
1767                    // Reset and apply new style
1768                    writer.write_all(b"\x1b[0m")?;
1769
1770                    // Apply attributes
1771                    if !cell_style.2.is_empty() {
1772                        Self::emit_style_flags(writer, cell_style.2)?;
1773                    }
1774
1775                    // Apply colors
1776                    if cell_style.0.a() > 0 {
1777                        write!(
1778                            writer,
1779                            "\x1b[38;2;{};{};{}m",
1780                            cell_style.0.r(),
1781                            cell_style.0.g(),
1782                            cell_style.0.b()
1783                        )?;
1784                    }
1785                    if cell_style.1.a() > 0 {
1786                        write!(
1787                            writer,
1788                            "\x1b[48;2;{};{};{}m",
1789                            cell_style.1.r(),
1790                            cell_style.1.g(),
1791                            cell_style.1.b()
1792                        )?;
1793                    }
1794
1795                    current_style = Some(cell_style);
1796                }
1797
1798                // Check if link changed
1799                let raw_link_id = effective_cell.attrs.link_id();
1800                let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1801                    None
1802                } else {
1803                    Some(raw_link_id)
1804                };
1805
1806                if current_link != new_link {
1807                    // Close current link
1808                    if current_link.is_some() {
1809                        writer.write_all(b"\x1b]8;;\x1b\\")?;
1810                    }
1811                    // Open new link if present and resolvable
1812                    let actually_opened = if let Some(link_id) = new_link
1813                        && let Some(url) = self.links.get(link_id)
1814                    {
1815                        write!(writer, "\x1b]8;;{}\x1b\\", url)?;
1816                        true
1817                    } else {
1818                        false
1819                    };
1820                    current_link = if actually_opened { new_link } else { None };
1821                }
1822
1823                let raw_width = effective_cell.content.width();
1824                let is_zero_width_content = raw_width == 0
1825                    && !effective_cell.is_empty()
1826                    && !effective_cell.is_continuation();
1827
1828                // Emit content
1829                if is_zero_width_content {
1830                    writer.write_all(b"\xEF\xBF\xBD")?;
1831                } else if let Some(ch) = effective_cell.content.as_char() {
1832                    let mut buf = [0u8; 4];
1833                    let encoded = ch.encode_utf8(&mut buf);
1834                    writer.write_all(encoded.as_bytes())?;
1835                } else if let Some(gid) = effective_cell.content.grapheme_id() {
1836                    // Use pool directly with writer (no clone needed)
1837                    if let Some(text) = self.pool.get(gid) {
1838                        writer.write_all(text.as_bytes())?;
1839                    } else {
1840                        // Fallback: emit placeholder cells to preserve width.
1841                        for _ in 0..raw_width.max(1) {
1842                            writer.write_all(b"?")?;
1843                        }
1844                    }
1845                } else {
1846                    writer.write_all(b" ")?;
1847                }
1848
1849                let advance = if effective_cell.is_empty() || is_zero_width_content {
1850                    1
1851                } else {
1852                    raw_width.max(1)
1853                };
1854                cursor_x = cursor_x.saturating_add(advance as u16);
1855            }
1856        }
1857
1858        // Reset style
1859        writer.write_all(b"\x1b[0m")?;
1860
1861        // Close any open link
1862        if current_link.is_some() {
1863            writer.write_all(b"\x1b]8;;\x1b\\")?;
1864        }
1865
1866        trace!("emit_diff complete");
1867        Ok(EmitStats {
1868            diff_cells,
1869            diff_runs,
1870        })
1871    }
1872
1873    /// Emit a full redraw without computing a diff.
1874    fn emit_full_redraw(
1875        &mut self,
1876        buffer: &Buffer,
1877        max_height: Option<u16>,
1878        ui_y_start: u16,
1879    ) -> io::Result<EmitStats> {
1880        use ftui_render::cell::{Cell, CellAttrs, StyleFlags};
1881
1882        let height = max_height.unwrap_or(buffer.height()).min(buffer.height());
1883        let width = buffer.width();
1884        let diff_cells = width as usize * height as usize;
1885        let diff_runs = height as usize;
1886
1887        let _span = debug_span!("ftui.render.emit_full_redraw").entered();
1888
1889        let mut current_style: Option<(
1890            ftui_render::cell::PackedRgba,
1891            ftui_render::cell::PackedRgba,
1892            StyleFlags,
1893        )> = None;
1894        let mut current_link: Option<u32> = None;
1895        let default_cell = Cell::default();
1896
1897        // Borrow writer once
1898        let writer = self.writer.as_mut().expect("writer has been consumed");
1899
1900        for y in 0..height {
1901            write!(
1902                writer,
1903                "\x1b[{};{}H",
1904                ui_y_start.saturating_add(y).saturating_add(1),
1905                1
1906            )?;
1907
1908            let mut cursor_x = 0u16;
1909            for x in 0..width {
1910                let cell = buffer.get_unchecked(x, y);
1911
1912                // Skip continuation cells unless they are orphaned.
1913                let is_orphan = cell.is_continuation() && cursor_x <= x;
1914                if cell.is_continuation() && !is_orphan {
1915                    continue;
1916                }
1917                let effective_cell = if is_orphan { &default_cell } else { cell };
1918
1919                // Check if style changed
1920                let cell_style = (
1921                    effective_cell.fg,
1922                    effective_cell.bg,
1923                    effective_cell.attrs.flags(),
1924                );
1925                if current_style != Some(cell_style) {
1926                    // Reset and apply new style
1927                    writer.write_all(b"\x1b[0m")?;
1928
1929                    // Apply attributes
1930                    if !cell_style.2.is_empty() {
1931                        Self::emit_style_flags(writer, cell_style.2)?;
1932                    }
1933
1934                    // Apply colors
1935                    if cell_style.0.a() > 0 {
1936                        write!(
1937                            writer,
1938                            "\x1b[38;2;{};{};{}m",
1939                            cell_style.0.r(),
1940                            cell_style.0.g(),
1941                            cell_style.0.b()
1942                        )?;
1943                    }
1944                    if cell_style.1.a() > 0 {
1945                        write!(
1946                            writer,
1947                            "\x1b[48;2;{};{};{}m",
1948                            cell_style.1.r(),
1949                            cell_style.1.g(),
1950                            cell_style.1.b()
1951                        )?;
1952                    }
1953
1954                    current_style = Some(cell_style);
1955                }
1956
1957                // Check if link changed
1958                let raw_link_id = effective_cell.attrs.link_id();
1959                let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1960                    None
1961                } else {
1962                    Some(raw_link_id)
1963                };
1964
1965                if current_link != new_link {
1966                    // Close current link
1967                    if current_link.is_some() {
1968                        writer.write_all(b"\x1b]8;;\x1b\\")?;
1969                    }
1970                    // Open new link if present and resolvable
1971                    let actually_opened = if let Some(link_id) = new_link
1972                        && let Some(url) = self.links.get(link_id)
1973                    {
1974                        write!(writer, "\x1b]8;;{}\x1b\\", url)?;
1975                        true
1976                    } else {
1977                        false
1978                    };
1979                    current_link = if actually_opened { new_link } else { None };
1980                }
1981
1982                let raw_width = effective_cell.content.width();
1983                let is_zero_width_content = raw_width == 0
1984                    && !effective_cell.is_empty()
1985                    && !effective_cell.is_continuation();
1986
1987                // Emit content
1988                if is_zero_width_content {
1989                    writer.write_all(b"\xEF\xBF\xBD")?;
1990                } else if let Some(ch) = effective_cell.content.as_char() {
1991                    let mut buf = [0u8; 4];
1992                    let encoded = ch.encode_utf8(&mut buf);
1993                    writer.write_all(encoded.as_bytes())?;
1994                } else if let Some(gid) = effective_cell.content.grapheme_id() {
1995                    // Use pool directly with writer (no clone needed)
1996                    if let Some(text) = self.pool.get(gid) {
1997                        writer.write_all(text.as_bytes())?;
1998                    } else {
1999                        // Fallback: emit placeholder cells to preserve width.
2000                        for _ in 0..raw_width.max(1) {
2001                            writer.write_all(b"?")?;
2002                        }
2003                    }
2004                } else {
2005                    writer.write_all(b" ")?;
2006                }
2007
2008                let advance = if effective_cell.is_empty() || is_zero_width_content {
2009                    1
2010                } else {
2011                    raw_width.max(1)
2012                };
2013                cursor_x = cursor_x.saturating_add(advance as u16);
2014            }
2015        }
2016
2017        // Reset style
2018        writer.write_all(b"\x1b[0m")?;
2019
2020        // Close any open link
2021        if current_link.is_some() {
2022            writer.write_all(b"\x1b]8;;\x1b\\")?;
2023        }
2024
2025        trace!("emit_full_redraw complete");
2026        Ok(EmitStats {
2027            diff_cells,
2028            diff_runs,
2029        })
2030    }
2031
2032    /// Emit SGR flags.
2033    fn emit_style_flags(
2034        writer: &mut impl Write,
2035        flags: ftui_render::cell::StyleFlags,
2036    ) -> io::Result<()> {
2037        use ftui_render::cell::StyleFlags;
2038
2039        let mut codes = Vec::with_capacity(8);
2040
2041        if flags.contains(StyleFlags::BOLD) {
2042            codes.push("1");
2043        }
2044        if flags.contains(StyleFlags::DIM) {
2045            codes.push("2");
2046        }
2047        if flags.contains(StyleFlags::ITALIC) {
2048            codes.push("3");
2049        }
2050        if flags.contains(StyleFlags::UNDERLINE) {
2051            codes.push("4");
2052        }
2053        if flags.contains(StyleFlags::BLINK) {
2054            codes.push("5");
2055        }
2056        if flags.contains(StyleFlags::REVERSE) {
2057            codes.push("7");
2058        }
2059        if flags.contains(StyleFlags::HIDDEN) {
2060            codes.push("8");
2061        }
2062        if flags.contains(StyleFlags::STRIKETHROUGH) {
2063            codes.push("9");
2064        }
2065
2066        if !codes.is_empty() {
2067            write!(writer, "\x1b[{}m", codes.join(";"))?;
2068        }
2069
2070        Ok(())
2071    }
2072
2073    /// Create a full-screen diff (marks all cells as changed).
2074    #[allow(dead_code)] // API for future diff strategy integration
2075    fn create_full_diff(&self, buffer: &Buffer) -> BufferDiff {
2076        BufferDiff::full(buffer.width(), buffer.height())
2077    }
2078
2079    /// Write log output (goes to scrollback region in inline mode).
2080    ///
2081    /// In inline mode, this writes to the log region (above UI for bottom-anchored,
2082    /// below UI for top-anchored). The cursor is explicitly positioned in the log
2083    /// region before writing to prevent UI corruption.
2084    ///
2085    /// In AltScreen mode, logs are typically not shown (returns Ok silently).
2086    pub fn write_log(&mut self, text: &str) -> io::Result<()> {
2087        match self.screen_mode {
2088            ScreenMode::Inline { ui_height } => {
2089                // Invalidate state if we are not using a scroll region, as the log write
2090                // might scroll the terminal and shift/corrupt the UI region.
2091                if !self.scroll_region_active {
2092                    self.prev_buffer = None;
2093                    self.last_inline_region = None;
2094                    self.reset_diff_strategy();
2095                }
2096
2097                // Position cursor in the log region before writing.
2098                // This ensures log output never corrupts the UI region.
2099                self.position_cursor_for_log(ui_height)?;
2100                self.writer().write_all(text.as_bytes())?;
2101                self.writer().flush()
2102            }
2103            ScreenMode::InlineAuto { .. } => {
2104                // Invalidate state if we are not using a scroll region.
2105                if !self.scroll_region_active {
2106                    self.prev_buffer = None;
2107                    self.last_inline_region = None;
2108                    self.reset_diff_strategy();
2109                }
2110
2111                // InlineAuto: use effective_ui_height for positioning.
2112                let ui_height = self.effective_ui_height();
2113                self.position_cursor_for_log(ui_height)?;
2114                self.writer().write_all(text.as_bytes())?;
2115                self.writer().flush()
2116            }
2117            ScreenMode::AltScreen => {
2118                // AltScreen: no scrollback, logs are typically handled differently
2119                // (e.g., written to a log pane or file)
2120                Ok(())
2121            }
2122        }
2123    }
2124
2125    /// Position cursor at the bottom of the log region for writing.
2126    ///
2127    /// For bottom-anchored UI: log region is above the UI (rows 1 to term_height - ui_height).
2128    /// For top-anchored UI: log region is below the UI (rows ui_height + 1 to term_height).
2129    ///
2130    /// Positions at the bottom row of the log region so newlines cause scrolling.
2131    fn position_cursor_for_log(&mut self, ui_height: u16) -> io::Result<()> {
2132        let visible_height = ui_height.min(self.term_height);
2133        if visible_height >= self.term_height {
2134            // No log region available when UI fills the terminal
2135            return Ok(());
2136        }
2137
2138        let log_row = match self.ui_anchor {
2139            UiAnchor::Bottom => {
2140                // Log region is above UI: rows 1 to (term_height - ui_height)
2141                // Position at the bottom of the log region
2142                self.term_height.saturating_sub(visible_height)
2143            }
2144            UiAnchor::Top => {
2145                // Log region is below UI: rows (ui_height + 1) to term_height
2146                // Position at the bottom of the log region (last row)
2147                self.term_height
2148            }
2149        };
2150
2151        // Move to the target row, column 1 (1-indexed)
2152        write!(self.writer(), "\x1b[{};1H", log_row)?;
2153        Ok(())
2154    }
2155
2156    /// Clear the screen.
2157    pub fn clear_screen(&mut self) -> io::Result<()> {
2158        self.writer().write_all(b"\x1b[2J\x1b[1;1H")?;
2159        self.writer().flush()?;
2160        self.prev_buffer = None;
2161        self.last_inline_region = None;
2162        self.reset_diff_strategy();
2163        Ok(())
2164    }
2165
2166    fn set_cursor_visibility(&mut self, visible: bool) -> io::Result<()> {
2167        if self.cursor_visible == visible {
2168            return Ok(());
2169        }
2170        self.cursor_visible = visible;
2171        if visible {
2172            self.writer().write_all(b"\x1b[?25h")?;
2173        } else {
2174            self.writer().write_all(b"\x1b[?25l")?;
2175        }
2176        Ok(())
2177    }
2178
2179    /// Hide the cursor.
2180    pub fn hide_cursor(&mut self) -> io::Result<()> {
2181        self.set_cursor_visibility(false)?;
2182        self.writer().flush()
2183    }
2184
2185    /// Show the cursor.
2186    pub fn show_cursor(&mut self) -> io::Result<()> {
2187        self.set_cursor_visibility(true)?;
2188        self.writer().flush()
2189    }
2190
2191    /// Flush any buffered output.
2192    pub fn flush(&mut self) -> io::Result<()> {
2193        self.writer().flush()
2194    }
2195
2196    /// Get the grapheme pool for interning complex characters.
2197    pub fn pool(&self) -> &GraphemePool {
2198        &self.pool
2199    }
2200
2201    /// Get mutable access to the grapheme pool.
2202    pub fn pool_mut(&mut self) -> &mut GraphemePool {
2203        &mut self.pool
2204    }
2205
2206    /// Get the link registry.
2207    pub fn links(&self) -> &LinkRegistry {
2208        &self.links
2209    }
2210
2211    /// Get mutable access to the link registry.
2212    pub fn links_mut(&mut self) -> &mut LinkRegistry {
2213        &mut self.links
2214    }
2215
2216    /// Borrow the grapheme pool and link registry together.
2217    ///
2218    /// This avoids double-borrowing `self` at call sites that need both.
2219    pub fn pool_and_links_mut(&mut self) -> (&mut GraphemePool, &mut LinkRegistry) {
2220        (&mut self.pool, &mut self.links)
2221    }
2222
2223    /// Get the terminal capabilities.
2224    pub fn capabilities(&self) -> &TerminalCapabilities {
2225        &self.capabilities
2226    }
2227
2228    /// Consume the writer and return the underlying writer.
2229    ///
2230    /// Performs cleanup operations before returning.
2231    /// Returns `None` if the buffer could not be flushed.
2232    pub fn into_inner(mut self) -> Option<W> {
2233        self.cleanup();
2234        // Take the writer before Drop runs (Drop will see None and skip cleanup)
2235        self.writer.take()?.into_inner().into_inner().ok()
2236    }
2237
2238    /// Perform garbage collection on the grapheme pool.
2239    ///
2240    /// Frees graphemes that are not referenced by the current front buffer (`prev_buffer`).
2241    /// This should be called periodically (e.g. every N frames) to prevent memory leaks
2242    /// in long-running applications with dynamic content (e.g. streaming logs with emoji).
2243    pub fn gc(&mut self) {
2244        let buffers = if let Some(ref buf) = self.prev_buffer {
2245            vec![buf]
2246        } else {
2247            vec![]
2248        };
2249        self.pool.gc(&buffers);
2250    }
2251
2252    /// Internal cleanup on drop.
2253    fn cleanup(&mut self) {
2254        let Some(ref mut writer) = self.writer else {
2255            return; // Writer already taken (via into_inner)
2256        };
2257
2258        // End any pending sync block
2259        if self.in_sync_block {
2260            let _ = writer.write_all(SYNC_END);
2261            self.in_sync_block = false;
2262        }
2263
2264        // Restore cursor if saved
2265        if self.cursor_saved {
2266            let _ = writer.write_all(CURSOR_RESTORE);
2267            self.cursor_saved = false;
2268        }
2269
2270        // Reset scroll region if active
2271        if self.scroll_region_active {
2272            let _ = writer.write_all(b"\x1b[r");
2273            self.scroll_region_active = false;
2274        }
2275
2276        // Reset style
2277        let _ = writer.write_all(b"\x1b[0m");
2278
2279        // Show cursor
2280        let _ = writer.write_all(b"\x1b[?25h");
2281        self.cursor_visible = true;
2282
2283        // Flush
2284        let _ = writer.flush();
2285
2286        if let Some(ref mut trace) = self.render_trace {
2287            let _ = trace.finish(None);
2288        }
2289    }
2290}
2291
2292impl<W: Write> Drop for TerminalWriter<W> {
2293    fn drop(&mut self) {
2294        self.cleanup();
2295    }
2296}
2297
2298#[cfg(test)]
2299mod tests {
2300    use super::*;
2301    use ftui_render::cell::{Cell, PackedRgba};
2302    use std::path::PathBuf;
2303    use std::sync::atomic::{AtomicUsize, Ordering};
2304
2305    fn max_cursor_row(output: &[u8]) -> u16 {
2306        let mut max_row = 0u16;
2307        let mut i = 0;
2308        while i + 2 < output.len() {
2309            if output[i] == 0x1b && output[i + 1] == b'[' {
2310                let mut j = i + 2;
2311                let mut row: u16 = 0;
2312                let mut saw_row = false;
2313                while j < output.len() && output[j].is_ascii_digit() {
2314                    saw_row = true;
2315                    row = row
2316                        .saturating_mul(10)
2317                        .saturating_add((output[j] - b'0') as u16);
2318                    j += 1;
2319                }
2320                if saw_row && j < output.len() && output[j] == b';' {
2321                    j += 1;
2322                    let mut saw_col = false;
2323                    while j < output.len() && output[j].is_ascii_digit() {
2324                        saw_col = true;
2325                        j += 1;
2326                    }
2327                    if saw_col && j < output.len() && output[j] == b'H' {
2328                        max_row = max_row.max(row);
2329                    }
2330                }
2331            }
2332            i += 1;
2333        }
2334        max_row
2335    }
2336
2337    fn basic_caps() -> TerminalCapabilities {
2338        TerminalCapabilities::basic()
2339    }
2340
2341    fn full_caps() -> TerminalCapabilities {
2342        let mut caps = TerminalCapabilities::basic();
2343        caps.true_color = true;
2344        caps.sync_output = true;
2345        caps
2346    }
2347
2348    fn find_nth(haystack: &[u8], needle: &[u8], nth: usize) -> Option<usize> {
2349        if nth == 0 {
2350            return None;
2351        }
2352        let mut count = 0;
2353        let mut i = 0;
2354        while i + needle.len() <= haystack.len() {
2355            if &haystack[i..i + needle.len()] == needle {
2356                count += 1;
2357                if count == nth {
2358                    return Some(i);
2359                }
2360            }
2361            i += 1;
2362        }
2363        None
2364    }
2365
2366    fn temp_evidence_path(label: &str) -> PathBuf {
2367        static COUNTER: AtomicUsize = AtomicUsize::new(0);
2368        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2369        let mut path = std::env::temp_dir();
2370        path.push(format!(
2371            "ftui_{}_{}_{}.jsonl",
2372            label,
2373            std::process::id(),
2374            id
2375        ));
2376        path
2377    }
2378
2379    #[test]
2380    fn new_creates_writer() {
2381        let output = Vec::new();
2382        let writer = TerminalWriter::new(
2383            output,
2384            ScreenMode::Inline { ui_height: 10 },
2385            UiAnchor::Bottom,
2386            basic_caps(),
2387        );
2388        assert_eq!(writer.ui_height(), 10);
2389    }
2390
2391    #[test]
2392    fn ui_start_row_bottom_anchor() {
2393        let output = Vec::new();
2394        let mut writer = TerminalWriter::new(
2395            output,
2396            ScreenMode::Inline { ui_height: 10 },
2397            UiAnchor::Bottom,
2398            basic_caps(),
2399        );
2400        writer.set_size(80, 24);
2401        assert_eq!(writer.ui_start_row(), 14); // 24 - 10 = 14
2402    }
2403
2404    #[test]
2405    fn ui_start_row_top_anchor() {
2406        let output = Vec::new();
2407        let mut writer = TerminalWriter::new(
2408            output,
2409            ScreenMode::Inline { ui_height: 10 },
2410            UiAnchor::Top,
2411            basic_caps(),
2412        );
2413        writer.set_size(80, 24);
2414        assert_eq!(writer.ui_start_row(), 0);
2415    }
2416
2417    #[test]
2418    fn ui_start_row_altscreen() {
2419        let output = Vec::new();
2420        let mut writer = TerminalWriter::new(
2421            output,
2422            ScreenMode::AltScreen,
2423            UiAnchor::Bottom,
2424            basic_caps(),
2425        );
2426        writer.set_size(80, 24);
2427        assert_eq!(writer.ui_start_row(), 0);
2428    }
2429
2430    #[test]
2431    fn present_ui_inline_saves_restores_cursor() {
2432        let mut output = Vec::new();
2433        {
2434            let mut writer = TerminalWriter::new(
2435                &mut output,
2436                ScreenMode::Inline { ui_height: 5 },
2437                UiAnchor::Bottom,
2438                basic_caps(),
2439            );
2440            writer.set_size(10, 10);
2441
2442            let buffer = Buffer::new(10, 5);
2443            writer.present_ui(&buffer, None, true).unwrap();
2444        }
2445
2446        // Should contain cursor save and restore
2447        assert!(output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE));
2448        assert!(
2449            output
2450                .windows(CURSOR_RESTORE.len())
2451                .any(|w| w == CURSOR_RESTORE)
2452        );
2453    }
2454
2455    #[test]
2456    fn present_ui_with_sync_output() {
2457        let mut output = Vec::new();
2458        {
2459            let mut writer = TerminalWriter::new(
2460                &mut output,
2461                ScreenMode::Inline { ui_height: 5 },
2462                UiAnchor::Bottom,
2463                full_caps(),
2464            );
2465            writer.set_size(10, 10);
2466
2467            let buffer = Buffer::new(10, 5);
2468            writer.present_ui(&buffer, None, true).unwrap();
2469        }
2470
2471        // Should contain sync begin and end
2472        assert!(output.windows(SYNC_BEGIN.len()).any(|w| w == SYNC_BEGIN));
2473        assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2474    }
2475
2476    #[test]
2477    fn present_ui_hides_cursor_when_requested() {
2478        let mut output = Vec::new();
2479        {
2480            let mut writer = TerminalWriter::new(
2481                &mut output,
2482                ScreenMode::AltScreen,
2483                UiAnchor::Bottom,
2484                basic_caps(),
2485            );
2486            writer.set_size(10, 5);
2487
2488            let buffer = Buffer::new(10, 5);
2489            writer.present_ui(&buffer, None, false).unwrap();
2490        }
2491
2492        assert!(
2493            output.windows(6).any(|w| w == b"\x1b[?25l"),
2494            "expected cursor hide sequence"
2495        );
2496    }
2497
2498    #[test]
2499    fn present_ui_visible_does_not_hide_cursor() {
2500        let mut output = Vec::new();
2501        {
2502            let mut writer = TerminalWriter::new(
2503                &mut output,
2504                ScreenMode::AltScreen,
2505                UiAnchor::Bottom,
2506                basic_caps(),
2507            );
2508            writer.set_size(10, 5);
2509
2510            let buffer = Buffer::new(10, 5);
2511            writer.present_ui(&buffer, None, true).unwrap();
2512        }
2513
2514        assert!(
2515            !output.windows(6).any(|w| w == b"\x1b[?25l"),
2516            "did not expect cursor hide sequence"
2517        );
2518    }
2519
2520    #[test]
2521    fn write_log_in_inline_mode() {
2522        let mut output = Vec::new();
2523        {
2524            let mut writer = TerminalWriter::new(
2525                &mut output,
2526                ScreenMode::Inline { ui_height: 5 },
2527                UiAnchor::Bottom,
2528                basic_caps(),
2529            );
2530            writer.write_log("test log\n").unwrap();
2531        }
2532
2533        let output_str = String::from_utf8_lossy(&output);
2534        assert!(output_str.contains("test log"));
2535    }
2536
2537    #[test]
2538    fn write_log_in_altscreen_is_noop() {
2539        let mut output = Vec::new();
2540        {
2541            let mut writer = TerminalWriter::new(
2542                &mut output,
2543                ScreenMode::AltScreen,
2544                UiAnchor::Bottom,
2545                basic_caps(),
2546            );
2547            writer.write_log("test log\n").unwrap();
2548        }
2549
2550        // Should not contain log text (altscreen drops logs)
2551        let output_str = String::from_utf8_lossy(&output);
2552        assert!(!output_str.contains("test log"));
2553    }
2554
2555    #[test]
2556    fn clear_screen_resets_prev_buffer() {
2557        let mut output = Vec::new();
2558        let mut writer = TerminalWriter::new(
2559            &mut output,
2560            ScreenMode::AltScreen,
2561            UiAnchor::Bottom,
2562            basic_caps(),
2563        );
2564
2565        // Present a buffer
2566        let buffer = Buffer::new(10, 5);
2567        writer.present_ui(&buffer, None, true).unwrap();
2568        assert!(writer.prev_buffer.is_some());
2569
2570        // Clear screen should reset
2571        writer.clear_screen().unwrap();
2572        assert!(writer.prev_buffer.is_none());
2573    }
2574
2575    #[test]
2576    fn set_size_clears_prev_buffer() {
2577        let output = Vec::new();
2578        let mut writer = TerminalWriter::new(
2579            output,
2580            ScreenMode::AltScreen,
2581            UiAnchor::Bottom,
2582            basic_caps(),
2583        );
2584
2585        writer.prev_buffer = Some(Buffer::new(10, 10));
2586        writer.set_size(20, 20);
2587
2588        assert!(writer.prev_buffer.is_none());
2589    }
2590
2591    #[test]
2592    fn inline_auto_resize_clears_cached_height() {
2593        let output = Vec::new();
2594        let mut writer = TerminalWriter::new(
2595            output,
2596            ScreenMode::InlineAuto {
2597                min_height: 3,
2598                max_height: 8,
2599            },
2600            UiAnchor::Bottom,
2601            basic_caps(),
2602        );
2603
2604        writer.set_size(80, 24);
2605        writer.set_auto_ui_height(6);
2606        assert_eq!(writer.auto_ui_height(), Some(6));
2607        assert_eq!(writer.render_height_hint(), 6);
2608
2609        writer.set_size(100, 30);
2610        assert_eq!(writer.auto_ui_height(), None);
2611        assert_eq!(writer.render_height_hint(), 8);
2612    }
2613
2614    #[test]
2615    fn drop_cleanup_restores_cursor() {
2616        let mut output = Vec::new();
2617        {
2618            let mut writer = TerminalWriter::new(
2619                &mut output,
2620                ScreenMode::Inline { ui_height: 5 },
2621                UiAnchor::Bottom,
2622                basic_caps(),
2623            );
2624            writer.cursor_saved = true;
2625            // Dropped here
2626        }
2627
2628        // Should contain cursor restore
2629        assert!(
2630            output
2631                .windows(CURSOR_RESTORE.len())
2632                .any(|w| w == CURSOR_RESTORE)
2633        );
2634    }
2635
2636    #[test]
2637    fn drop_cleanup_ends_sync_block() {
2638        let mut output = Vec::new();
2639        {
2640            let mut writer = TerminalWriter::new(
2641                &mut output,
2642                ScreenMode::Inline { ui_height: 5 },
2643                UiAnchor::Bottom,
2644                full_caps(),
2645            );
2646            writer.in_sync_block = true;
2647            // Dropped here
2648        }
2649
2650        // Should contain sync end
2651        assert!(output.windows(SYNC_END.len()).any(|w| w == SYNC_END));
2652    }
2653
2654    #[test]
2655    fn present_multiple_frames_uses_diff() {
2656        use std::io::Cursor;
2657
2658        // Use Cursor<Vec<u8>> which allows us to track position
2659        let output = Cursor::new(Vec::new());
2660        let mut writer = TerminalWriter::new(
2661            output,
2662            ScreenMode::AltScreen,
2663            UiAnchor::Bottom,
2664            basic_caps(),
2665        );
2666        writer.set_size(10, 5);
2667
2668        // First frame - full draw
2669        let mut buffer1 = Buffer::new(10, 5);
2670        buffer1.set_raw(0, 0, Cell::from_char('A'));
2671        writer.present_ui(&buffer1, None, true).unwrap();
2672
2673        // Second frame - same content (diff is empty, minimal output)
2674        writer.present_ui(&buffer1, None, true).unwrap();
2675
2676        // Third frame - change one cell
2677        let mut buffer2 = buffer1.clone();
2678        buffer2.set_raw(1, 0, Cell::from_char('B'));
2679        writer.present_ui(&buffer2, None, true).unwrap();
2680
2681        // Test passes if it doesn't panic - the diffing is working
2682        // (Detailed output length verification would require more complex setup)
2683    }
2684
2685    #[test]
2686    fn cell_content_rendered_correctly() {
2687        let mut output = Vec::new();
2688        {
2689            let mut writer = TerminalWriter::new(
2690                &mut output,
2691                ScreenMode::AltScreen,
2692                UiAnchor::Bottom,
2693                basic_caps(),
2694            );
2695            writer.set_size(10, 5);
2696
2697            let mut buffer = Buffer::new(10, 5);
2698            buffer.set_raw(0, 0, Cell::from_char('H'));
2699            buffer.set_raw(1, 0, Cell::from_char('i'));
2700            buffer.set_raw(2, 0, Cell::from_char('!'));
2701            writer.present_ui(&buffer, None, true).unwrap();
2702        }
2703
2704        let output_str = String::from_utf8_lossy(&output);
2705        assert!(output_str.contains('H'));
2706        assert!(output_str.contains('i'));
2707        assert!(output_str.contains('!'));
2708    }
2709
2710    #[test]
2711    fn resize_reanchors_ui_region() {
2712        let output = Vec::new();
2713        let mut writer = TerminalWriter::new(
2714            output,
2715            ScreenMode::Inline { ui_height: 10 },
2716            UiAnchor::Bottom,
2717            basic_caps(),
2718        );
2719
2720        // Initial size: 80x24, UI at row 14 (24 - 10)
2721        writer.set_size(80, 24);
2722        assert_eq!(writer.ui_start_row(), 14);
2723
2724        // After resize to 80x40, UI should be at row 30 (40 - 10)
2725        writer.set_size(80, 40);
2726        assert_eq!(writer.ui_start_row(), 30);
2727
2728        // After resize to smaller 80x15, UI at row 5 (15 - 10)
2729        writer.set_size(80, 15);
2730        assert_eq!(writer.ui_start_row(), 5);
2731    }
2732
2733    #[test]
2734    fn inline_auto_height_clamps_and_uses_max_for_render() {
2735        let output = Vec::new();
2736        let mut writer = TerminalWriter::new(
2737            output,
2738            ScreenMode::InlineAuto {
2739                min_height: 3,
2740                max_height: 8,
2741            },
2742            UiAnchor::Bottom,
2743            basic_caps(),
2744        );
2745        writer.set_size(80, 24);
2746
2747        // Default to min height until measured.
2748        assert_eq!(writer.ui_height(), 3);
2749        assert_eq!(writer.auto_ui_height(), None);
2750
2751        // render_height_hint uses max to allow measurement when cache is empty.
2752        assert_eq!(writer.render_height_hint(), 8);
2753
2754        // Cache hit: render_height_hint uses cached height.
2755        writer.set_auto_ui_height(6);
2756        assert_eq!(writer.render_height_hint(), 6);
2757
2758        // Cache miss: clearing restores max hint.
2759        writer.clear_auto_ui_height();
2760        assert_eq!(writer.render_height_hint(), 8);
2761
2762        // Cache should still set when clamped to min.
2763        writer.set_auto_ui_height(3);
2764        assert_eq!(writer.auto_ui_height(), Some(3));
2765        assert_eq!(writer.ui_height(), 3);
2766
2767        writer.clear_auto_ui_height();
2768        assert_eq!(writer.render_height_hint(), 8);
2769
2770        // Clamp to max.
2771        writer.set_auto_ui_height(10);
2772        assert_eq!(writer.ui_height(), 8);
2773
2774        // Clamp to min.
2775        writer.set_auto_ui_height(1);
2776        assert_eq!(writer.ui_height(), 3);
2777    }
2778
2779    #[test]
2780    fn resize_with_top_anchor_stays_at_zero() {
2781        let output = Vec::new();
2782        let mut writer = TerminalWriter::new(
2783            output,
2784            ScreenMode::Inline { ui_height: 10 },
2785            UiAnchor::Top,
2786            basic_caps(),
2787        );
2788
2789        writer.set_size(80, 24);
2790        assert_eq!(writer.ui_start_row(), 0);
2791
2792        writer.set_size(80, 40);
2793        assert_eq!(writer.ui_start_row(), 0);
2794    }
2795
2796    #[test]
2797    fn inline_mode_never_clears_full_screen() {
2798        let mut output = Vec::new();
2799        {
2800            let mut writer = TerminalWriter::new(
2801                &mut output,
2802                ScreenMode::Inline { ui_height: 5 },
2803                UiAnchor::Bottom,
2804                basic_caps(),
2805            );
2806            writer.set_size(10, 10);
2807
2808            let buffer = Buffer::new(10, 5);
2809            writer.present_ui(&buffer, None, true).unwrap();
2810        }
2811
2812        // Should NOT contain full screen clear (ED2 = "\x1b[2J")
2813        let has_ed2 = output.windows(4).any(|w| w == b"\x1b[2J");
2814        assert!(!has_ed2, "Inline mode should never use full screen clear");
2815
2816        // Should contain individual line clears (EL = "\x1b[2K")
2817        assert!(output.windows(ERASE_LINE.len()).any(|w| w == ERASE_LINE));
2818    }
2819
2820    #[test]
2821    fn present_after_log_maintains_cursor_position() {
2822        let mut output = Vec::new();
2823        {
2824            let mut writer = TerminalWriter::new(
2825                &mut output,
2826                ScreenMode::Inline { ui_height: 5 },
2827                UiAnchor::Bottom,
2828                basic_caps(),
2829            );
2830            writer.set_size(10, 10);
2831
2832            // Present UI first
2833            let buffer = Buffer::new(10, 5);
2834            writer.present_ui(&buffer, None, true).unwrap();
2835
2836            // Write a log
2837            writer.write_log("log line\n").unwrap();
2838
2839            // Present UI again
2840            writer.present_ui(&buffer, None, true).unwrap();
2841        }
2842
2843        // Should have cursor save before each UI present
2844        let save_count = output
2845            .windows(CURSOR_SAVE.len())
2846            .filter(|w| *w == CURSOR_SAVE)
2847            .count();
2848        assert_eq!(save_count, 2, "Should have saved cursor twice");
2849
2850        // Should have cursor restore after each UI present
2851        let restore_count = output
2852            .windows(CURSOR_RESTORE.len())
2853            .filter(|w| *w == CURSOR_RESTORE)
2854            .count();
2855        // At least 2 from presents, plus 1 from drop cleanup = 3
2856        assert!(
2857            restore_count >= 2,
2858            "Should have restored cursor at least twice"
2859        );
2860    }
2861
2862    #[test]
2863    fn ui_height_bounds_check() {
2864        let output = Vec::new();
2865        let mut writer = TerminalWriter::new(
2866            output,
2867            ScreenMode::Inline { ui_height: 100 },
2868            UiAnchor::Bottom,
2869            basic_caps(),
2870        );
2871
2872        // Terminal smaller than UI height
2873        writer.set_size(80, 10);
2874
2875        // Should saturate to 0, not underflow
2876        assert_eq!(writer.ui_start_row(), 0);
2877    }
2878
2879    #[test]
2880    fn inline_ui_height_clamped_to_terminal_height() {
2881        let mut output = Vec::new();
2882        {
2883            let mut writer = TerminalWriter::new(
2884                &mut output,
2885                ScreenMode::Inline { ui_height: 10 },
2886                UiAnchor::Bottom,
2887                basic_caps(),
2888            );
2889            writer.set_size(8, 3);
2890            let buffer = Buffer::new(8, 10);
2891            writer.present_ui(&buffer, None, true).unwrap();
2892        }
2893
2894        let max_row = max_cursor_row(&output);
2895        assert!(
2896            max_row <= 3,
2897            "cursor row {} exceeds terminal height",
2898            max_row
2899        );
2900    }
2901
2902    #[test]
2903    fn inline_shrink_clears_stale_rows() {
2904        let mut output = Vec::new();
2905        {
2906            let mut writer = TerminalWriter::new(
2907                &mut output,
2908                ScreenMode::InlineAuto {
2909                    min_height: 1,
2910                    max_height: 6,
2911                },
2912                UiAnchor::Bottom,
2913                basic_caps(),
2914            );
2915            writer.set_size(10, 10);
2916
2917            let buffer = Buffer::new(10, 6);
2918            writer.set_auto_ui_height(6);
2919            writer.present_ui(&buffer, None, true).unwrap();
2920
2921            writer.set_auto_ui_height(3);
2922            writer.present_ui(&buffer, None, true).unwrap();
2923        }
2924
2925        let second_save = find_nth(&output, CURSOR_SAVE, 2).expect("expected second cursor save");
2926        let after_save = &output[second_save..];
2927        let restore_idx = after_save
2928            .windows(CURSOR_RESTORE.len())
2929            .position(|w| w == CURSOR_RESTORE)
2930            .expect("expected cursor restore after second save");
2931        let segment = &after_save[..restore_idx];
2932        let erase_count = segment
2933            .windows(ERASE_LINE.len())
2934            .filter(|w| *w == ERASE_LINE)
2935            .count();
2936
2937        assert_eq!(erase_count, 6, "expected clears for stale + new rows");
2938    }
2939
2940    // --- Scroll-region optimization tests ---
2941
2942    /// Capabilities that enable scroll-region strategy (no mux, scroll_region + sync_output).
2943    fn scroll_region_caps() -> TerminalCapabilities {
2944        let mut caps = TerminalCapabilities::basic();
2945        caps.scroll_region = true;
2946        caps.sync_output = true;
2947        caps
2948    }
2949
2950    /// Capabilities for hybrid strategy (scroll_region but no sync_output).
2951    fn hybrid_caps() -> TerminalCapabilities {
2952        let mut caps = TerminalCapabilities::basic();
2953        caps.scroll_region = true;
2954        caps
2955    }
2956
2957    /// Capabilities that force overlay (in tmux even with scroll_region).
2958    fn mux_caps() -> TerminalCapabilities {
2959        let mut caps = TerminalCapabilities::basic();
2960        caps.scroll_region = true;
2961        caps.sync_output = true;
2962        caps.in_tmux = true;
2963        caps
2964    }
2965
2966    #[test]
2967    fn scroll_region_bounds_bottom_anchor() {
2968        let mut output = Vec::new();
2969        {
2970            let mut writer = TerminalWriter::new(
2971                &mut output,
2972                ScreenMode::Inline { ui_height: 5 },
2973                UiAnchor::Bottom,
2974                scroll_region_caps(),
2975            );
2976            writer.set_size(10, 10);
2977            let buffer = Buffer::new(10, 5);
2978            writer.present_ui(&buffer, None, true).unwrap();
2979        }
2980
2981        let seq = b"\x1b[1;5r";
2982        assert!(
2983            output.windows(seq.len()).any(|w| w == seq),
2984            "expected scroll region for bottom anchor"
2985        );
2986    }
2987
2988    #[test]
2989    fn scroll_region_bounds_top_anchor() {
2990        let mut output = Vec::new();
2991        {
2992            let mut writer = TerminalWriter::new(
2993                &mut output,
2994                ScreenMode::Inline { ui_height: 5 },
2995                UiAnchor::Top,
2996                scroll_region_caps(),
2997            );
2998            writer.set_size(10, 10);
2999            let buffer = Buffer::new(10, 5);
3000            writer.present_ui(&buffer, None, true).unwrap();
3001        }
3002
3003        let seq = b"\x1b[6;10r";
3004        assert!(
3005            output.windows(seq.len()).any(|w| w == seq),
3006            "expected scroll region for top anchor"
3007        );
3008        let cursor_seq = b"\x1b[6;1H";
3009        assert!(
3010            output.windows(cursor_seq.len()).any(|w| w == cursor_seq),
3011            "expected cursor move into log region for top anchor"
3012        );
3013    }
3014
3015    #[test]
3016    fn present_ui_inline_resets_style_before_cursor_restore() {
3017        let mut output = Vec::new();
3018        {
3019            let mut writer = TerminalWriter::new(
3020                &mut output,
3021                ScreenMode::Inline { ui_height: 2 },
3022                UiAnchor::Bottom,
3023                basic_caps(),
3024            );
3025            writer.set_size(5, 5);
3026            let mut buffer = Buffer::new(5, 2);
3027            buffer.set_raw(0, 0, Cell::from_char('X').with_fg(PackedRgba::RED));
3028            writer.present_ui(&buffer, None, true).unwrap();
3029        }
3030
3031        let seq = b"\x1b[0m\x1b8";
3032        assert!(
3033            output.windows(seq.len()).any(|w| w == seq),
3034            "expected SGR reset before cursor restore in inline mode"
3035        );
3036    }
3037
3038    #[test]
3039    fn strategy_selected_from_capabilities() {
3040        // No capabilities → OverlayRedraw
3041        let w = TerminalWriter::new(
3042            Vec::new(),
3043            ScreenMode::Inline { ui_height: 5 },
3044            UiAnchor::Bottom,
3045            basic_caps(),
3046        );
3047        assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3048
3049        // scroll_region + sync_output → ScrollRegion
3050        let w = TerminalWriter::new(
3051            Vec::new(),
3052            ScreenMode::Inline { ui_height: 5 },
3053            UiAnchor::Bottom,
3054            scroll_region_caps(),
3055        );
3056        assert_eq!(w.inline_strategy(), InlineStrategy::ScrollRegion);
3057
3058        // scroll_region only → Hybrid
3059        let w = TerminalWriter::new(
3060            Vec::new(),
3061            ScreenMode::Inline { ui_height: 5 },
3062            UiAnchor::Bottom,
3063            hybrid_caps(),
3064        );
3065        assert_eq!(w.inline_strategy(), InlineStrategy::Hybrid);
3066
3067        // In mux → OverlayRedraw even with all caps
3068        let w = TerminalWriter::new(
3069            Vec::new(),
3070            ScreenMode::Inline { ui_height: 5 },
3071            UiAnchor::Bottom,
3072            mux_caps(),
3073        );
3074        assert_eq!(w.inline_strategy(), InlineStrategy::OverlayRedraw);
3075    }
3076
3077    #[test]
3078    fn scroll_region_activated_on_present() {
3079        let mut output = Vec::new();
3080        {
3081            let mut writer = TerminalWriter::new(
3082                &mut output,
3083                ScreenMode::Inline { ui_height: 5 },
3084                UiAnchor::Bottom,
3085                scroll_region_caps(),
3086            );
3087            writer.set_size(80, 24);
3088            assert!(!writer.scroll_region_active());
3089
3090            let buffer = Buffer::new(80, 5);
3091            writer.present_ui(&buffer, None, true).unwrap();
3092            assert!(writer.scroll_region_active());
3093        }
3094
3095        // Should contain DECSTBM: ESC [ 1 ; 19 r (rows 1-19 are log region)
3096        let expected = b"\x1b[1;19r";
3097        assert!(
3098            output.windows(expected.len()).any(|w| w == expected),
3099            "Should set scroll region to rows 1-19"
3100        );
3101    }
3102
3103    #[test]
3104    fn scroll_region_not_activated_for_overlay() {
3105        let mut output = Vec::new();
3106        {
3107            let mut writer = TerminalWriter::new(
3108                &mut output,
3109                ScreenMode::Inline { ui_height: 5 },
3110                UiAnchor::Bottom,
3111                basic_caps(),
3112            );
3113            writer.set_size(80, 24);
3114
3115            let buffer = Buffer::new(80, 5);
3116            writer.present_ui(&buffer, None, true).unwrap();
3117            assert!(!writer.scroll_region_active());
3118        }
3119
3120        // Should NOT contain any scroll region setup
3121        let decstbm = b"\x1b[1;19r";
3122        assert!(
3123            !output.windows(decstbm.len()).any(|w| w == decstbm),
3124            "OverlayRedraw should not set scroll region"
3125        );
3126    }
3127
3128    #[test]
3129    fn scroll_region_not_activated_in_mux() {
3130        let mut output = Vec::new();
3131        {
3132            let mut writer = TerminalWriter::new(
3133                &mut output,
3134                ScreenMode::Inline { ui_height: 5 },
3135                UiAnchor::Bottom,
3136                mux_caps(),
3137            );
3138            writer.set_size(80, 24);
3139
3140            let buffer = Buffer::new(80, 5);
3141            writer.present_ui(&buffer, None, true).unwrap();
3142            assert!(!writer.scroll_region_active());
3143        }
3144
3145        // Should NOT contain scroll region setup despite having the capability
3146        let decstbm = b"\x1b[1;19r";
3147        assert!(
3148            !output.windows(decstbm.len()).any(|w| w == decstbm),
3149            "Mux environment should not use scroll region"
3150        );
3151    }
3152
3153    #[test]
3154    fn scroll_region_reset_on_cleanup() {
3155        let mut output = Vec::new();
3156        {
3157            let mut writer = TerminalWriter::new(
3158                &mut output,
3159                ScreenMode::Inline { ui_height: 5 },
3160                UiAnchor::Bottom,
3161                scroll_region_caps(),
3162            );
3163            writer.set_size(80, 24);
3164
3165            let buffer = Buffer::new(80, 5);
3166            writer.present_ui(&buffer, None, true).unwrap();
3167            // Dropped here - cleanup should reset scroll region
3168        }
3169
3170        // Should contain scroll region reset: ESC [ r
3171        let reset = b"\x1b[r";
3172        assert!(
3173            output.windows(reset.len()).any(|w| w == reset),
3174            "Cleanup should reset scroll region"
3175        );
3176    }
3177
3178    #[test]
3179    fn scroll_region_reset_on_resize() {
3180        let output = Vec::new();
3181        let mut writer = TerminalWriter::new(
3182            output,
3183            ScreenMode::Inline { ui_height: 5 },
3184            UiAnchor::Bottom,
3185            scroll_region_caps(),
3186        );
3187        writer.set_size(80, 24);
3188
3189        // Manually activate scroll region
3190        writer.activate_scroll_region(5).unwrap();
3191        assert!(writer.scroll_region_active());
3192
3193        // Resize should deactivate it
3194        writer.set_size(80, 40);
3195        assert!(!writer.scroll_region_active());
3196    }
3197
3198    #[test]
3199    fn scroll_region_reactivated_after_resize() {
3200        let mut output = Vec::new();
3201        {
3202            let mut writer = TerminalWriter::new(
3203                &mut output,
3204                ScreenMode::Inline { ui_height: 5 },
3205                UiAnchor::Bottom,
3206                scroll_region_caps(),
3207            );
3208            writer.set_size(80, 24);
3209
3210            // First present activates scroll region
3211            let buffer = Buffer::new(80, 5);
3212            writer.present_ui(&buffer, None, true).unwrap();
3213            assert!(writer.scroll_region_active());
3214
3215            // Resize deactivates
3216            writer.set_size(80, 40);
3217            assert!(!writer.scroll_region_active());
3218
3219            // Next present re-activates with new dimensions
3220            let buffer2 = Buffer::new(80, 5);
3221            writer.present_ui(&buffer2, None, true).unwrap();
3222            assert!(writer.scroll_region_active());
3223        }
3224
3225        // Should contain the new scroll region: ESC [ 1 ; 35 r (40 - 5 = 35)
3226        let new_region = b"\x1b[1;35r";
3227        assert!(
3228            output.windows(new_region.len()).any(|w| w == new_region),
3229            "Should set scroll region to new dimensions after resize"
3230        );
3231    }
3232
3233    #[test]
3234    fn hybrid_strategy_activates_scroll_region() {
3235        let mut output = Vec::new();
3236        {
3237            let mut writer = TerminalWriter::new(
3238                &mut output,
3239                ScreenMode::Inline { ui_height: 5 },
3240                UiAnchor::Bottom,
3241                hybrid_caps(),
3242            );
3243            writer.set_size(80, 24);
3244
3245            let buffer = Buffer::new(80, 5);
3246            writer.present_ui(&buffer, None, true).unwrap();
3247            assert!(writer.scroll_region_active());
3248        }
3249
3250        // Hybrid uses scroll region as internal optimization
3251        let expected = b"\x1b[1;19r";
3252        assert!(
3253            output.windows(expected.len()).any(|w| w == expected),
3254            "Hybrid should activate scroll region as optimization"
3255        );
3256    }
3257
3258    #[test]
3259    fn altscreen_does_not_activate_scroll_region() {
3260        let output = Vec::new();
3261        let mut writer = TerminalWriter::new(
3262            output,
3263            ScreenMode::AltScreen,
3264            UiAnchor::Bottom,
3265            scroll_region_caps(),
3266        );
3267        writer.set_size(80, 24);
3268
3269        let buffer = Buffer::new(80, 24);
3270        writer.present_ui(&buffer, None, true).unwrap();
3271        assert!(!writer.scroll_region_active());
3272    }
3273
3274    #[test]
3275    fn scroll_region_still_saves_restores_cursor() {
3276        let mut output = Vec::new();
3277        {
3278            let mut writer = TerminalWriter::new(
3279                &mut output,
3280                ScreenMode::Inline { ui_height: 5 },
3281                UiAnchor::Bottom,
3282                scroll_region_caps(),
3283            );
3284            writer.set_size(80, 24);
3285
3286            let buffer = Buffer::new(80, 5);
3287            writer.present_ui(&buffer, None, true).unwrap();
3288        }
3289
3290        // Even with scroll region, cursor save/restore is used for UI presents
3291        assert!(
3292            output.windows(CURSOR_SAVE.len()).any(|w| w == CURSOR_SAVE),
3293            "Scroll region mode should still save cursor"
3294        );
3295        assert!(
3296            output
3297                .windows(CURSOR_RESTORE.len())
3298                .any(|w| w == CURSOR_RESTORE),
3299            "Scroll region mode should still restore cursor"
3300        );
3301    }
3302
3303    // --- Log write cursor positioning tests (bd-xh8s) ---
3304
3305    #[test]
3306    fn write_log_positions_cursor_bottom_anchor() {
3307        // Verify log writes position cursor at the bottom of the log region
3308        // for bottom-anchored UI (log region is above UI).
3309        let mut output = Vec::new();
3310        {
3311            let mut writer = TerminalWriter::new(
3312                &mut output,
3313                ScreenMode::Inline { ui_height: 5 },
3314                UiAnchor::Bottom,
3315                basic_caps(),
3316            );
3317            writer.set_size(80, 24);
3318            writer.write_log("test log\n").unwrap();
3319        }
3320
3321        // For bottom-anchored with ui_height=5, term_height=24:
3322        // Log region is rows 1-19 (24-5=19 rows)
3323        // Cursor should be positioned at row 19 (bottom of log region)
3324        let expected_pos = b"\x1b[19;1H";
3325        assert!(
3326            output
3327                .windows(expected_pos.len())
3328                .any(|w| w == expected_pos),
3329            "Log write should position cursor at row 19 for bottom anchor"
3330        );
3331    }
3332
3333    #[test]
3334    fn write_log_positions_cursor_top_anchor() {
3335        // Verify log writes position cursor at the bottom of the log region
3336        // for top-anchored UI (log region is below UI).
3337        let mut output = Vec::new();
3338        {
3339            let mut writer = TerminalWriter::new(
3340                &mut output,
3341                ScreenMode::Inline { ui_height: 5 },
3342                UiAnchor::Top,
3343                basic_caps(),
3344            );
3345            writer.set_size(80, 24);
3346            writer.write_log("test log\n").unwrap();
3347        }
3348
3349        // For top-anchored with ui_height=5, term_height=24:
3350        // Log region is rows 6-24 (below UI)
3351        // Cursor should be positioned at row 24 (bottom of log region)
3352        let expected_pos = b"\x1b[24;1H";
3353        assert!(
3354            output
3355                .windows(expected_pos.len())
3356                .any(|w| w == expected_pos),
3357            "Log write should position cursor at row 24 for top anchor"
3358        );
3359    }
3360
3361    #[test]
3362    fn write_log_contains_text() {
3363        // Verify the log text is actually written after cursor positioning.
3364        let mut output = Vec::new();
3365        {
3366            let mut writer = TerminalWriter::new(
3367                &mut output,
3368                ScreenMode::Inline { ui_height: 5 },
3369                UiAnchor::Bottom,
3370                basic_caps(),
3371            );
3372            writer.set_size(80, 24);
3373            writer.write_log("hello world\n").unwrap();
3374        }
3375
3376        let output_str = String::from_utf8_lossy(&output);
3377        assert!(output_str.contains("hello world"));
3378    }
3379
3380    #[test]
3381    fn write_log_multiple_writes_position_each_time() {
3382        // Verify cursor is positioned for each log write.
3383        let mut output = Vec::new();
3384        {
3385            let mut writer = TerminalWriter::new(
3386                &mut output,
3387                ScreenMode::Inline { ui_height: 5 },
3388                UiAnchor::Bottom,
3389                basic_caps(),
3390            );
3391            writer.set_size(80, 24);
3392            writer.write_log("first\n").unwrap();
3393            writer.write_log("second\n").unwrap();
3394        }
3395
3396        // Should have cursor positioning twice
3397        let expected_pos = b"\x1b[19;1H";
3398        let count = output
3399            .windows(expected_pos.len())
3400            .filter(|w| *w == expected_pos)
3401            .count();
3402        assert_eq!(count, 2, "Should position cursor for each log write");
3403    }
3404
3405    #[test]
3406    fn write_log_after_present_ui_works_correctly() {
3407        // Verify log writes work correctly after UI presentation.
3408        let mut output = Vec::new();
3409        {
3410            let mut writer = TerminalWriter::new(
3411                &mut output,
3412                ScreenMode::Inline { ui_height: 5 },
3413                UiAnchor::Bottom,
3414                basic_caps(),
3415            );
3416            writer.set_size(80, 24);
3417
3418            // Present UI first
3419            let buffer = Buffer::new(80, 5);
3420            writer.present_ui(&buffer, None, true).unwrap();
3421
3422            // Then write log
3423            writer.write_log("after UI\n").unwrap();
3424        }
3425
3426        let output_str = String::from_utf8_lossy(&output);
3427        assert!(output_str.contains("after UI"));
3428
3429        // Log write should still position cursor
3430        let expected_pos = b"\x1b[19;1H";
3431        // Find position after cursor restore (log write happens after present_ui)
3432        assert!(
3433            output
3434                .windows(expected_pos.len())
3435                .any(|w| w == expected_pos),
3436            "Log write after present_ui should position cursor"
3437        );
3438    }
3439
3440    #[test]
3441    fn write_log_ui_fills_terminal_is_noop() {
3442        // When UI fills the entire terminal, there's no log region.
3443        let mut output = Vec::new();
3444        {
3445            let mut writer = TerminalWriter::new(
3446                &mut output,
3447                ScreenMode::Inline { ui_height: 24 },
3448                UiAnchor::Bottom,
3449                basic_caps(),
3450            );
3451            writer.set_size(80, 24);
3452            writer.write_log("should still write\n").unwrap();
3453        }
3454
3455        // Text should still be written (no positioning since no log region)
3456        let output_str = String::from_utf8_lossy(&output);
3457        assert!(output_str.contains("should still write"));
3458    }
3459
3460    #[test]
3461    fn write_log_with_scroll_region_active() {
3462        // Verify log writes work correctly when scroll region is active.
3463        let mut output = Vec::new();
3464        {
3465            let mut writer = TerminalWriter::new(
3466                &mut output,
3467                ScreenMode::Inline { ui_height: 5 },
3468                UiAnchor::Bottom,
3469                scroll_region_caps(),
3470            );
3471            writer.set_size(80, 24);
3472
3473            // Present UI to activate scroll region
3474            let buffer = Buffer::new(80, 5);
3475            writer.present_ui(&buffer, None, true).unwrap();
3476            assert!(writer.scroll_region_active());
3477
3478            // Log write should still position cursor
3479            writer.write_log("with scroll region\n").unwrap();
3480        }
3481
3482        let output_str = String::from_utf8_lossy(&output);
3483        assert!(output_str.contains("with scroll region"));
3484    }
3485
3486    #[test]
3487    fn log_write_cursor_position_not_in_ui_region_bottom_anchor() {
3488        // Verify the cursor position for log writes is never in the UI region.
3489        // For bottom-anchored with ui_height=5, term_height=24:
3490        // UI region is rows 20-24 (1-indexed)
3491        // Log region is rows 1-19
3492        // Log cursor should be at row 19 (bottom of log region)
3493        let mut output = Vec::new();
3494        {
3495            let mut writer = TerminalWriter::new(
3496                &mut output,
3497                ScreenMode::Inline { ui_height: 5 },
3498                UiAnchor::Bottom,
3499                basic_caps(),
3500            );
3501            writer.set_size(80, 24);
3502            writer.write_log("test\n").unwrap();
3503        }
3504
3505        // Parse cursor position commands in output
3506        // Looking for ESC [ row ; col H patterns
3507        let mut found_row = None;
3508        let mut i = 0;
3509        while i + 2 < output.len() {
3510            if output[i] == 0x1b && output[i + 1] == b'[' {
3511                let mut j = i + 2;
3512                let mut row: u16 = 0;
3513                while j < output.len() && output[j].is_ascii_digit() {
3514                    row = row * 10 + (output[j] - b'0') as u16;
3515                    j += 1;
3516                }
3517                if j < output.len() && output[j] == b';' {
3518                    j += 1;
3519                    while j < output.len() && output[j].is_ascii_digit() {
3520                        j += 1;
3521                    }
3522                    if j < output.len() && output[j] == b'H' {
3523                        found_row = Some(row);
3524                    }
3525                }
3526            }
3527            i += 1;
3528        }
3529
3530        if let Some(row) = found_row {
3531            // UI region starts at row 20 (24 - 5 + 1 = 20)
3532            assert!(
3533                row < 20,
3534                "Log cursor row {} should be below UI start row 20",
3535                row
3536            );
3537        }
3538    }
3539
3540    #[test]
3541    fn log_write_cursor_position_not_in_ui_region_top_anchor() {
3542        // Verify the cursor position for log writes is never in the UI region.
3543        // For top-anchored with ui_height=5, term_height=24:
3544        // UI region is rows 1-5 (1-indexed)
3545        // Log region is rows 6-24
3546        // Log cursor should be at row 24 (bottom of log region)
3547        let mut output = Vec::new();
3548        {
3549            let mut writer = TerminalWriter::new(
3550                &mut output,
3551                ScreenMode::Inline { ui_height: 5 },
3552                UiAnchor::Top,
3553                basic_caps(),
3554            );
3555            writer.set_size(80, 24);
3556            writer.write_log("test\n").unwrap();
3557        }
3558
3559        // Parse cursor position commands in output
3560        let mut found_row = None;
3561        let mut i = 0;
3562        while i + 2 < output.len() {
3563            if output[i] == 0x1b && output[i + 1] == b'[' {
3564                let mut j = i + 2;
3565                let mut row: u16 = 0;
3566                while j < output.len() && output[j].is_ascii_digit() {
3567                    row = row * 10 + (output[j] - b'0') as u16;
3568                    j += 1;
3569                }
3570                if j < output.len() && output[j] == b';' {
3571                    j += 1;
3572                    while j < output.len() && output[j].is_ascii_digit() {
3573                        j += 1;
3574                    }
3575                    if j < output.len() && output[j] == b'H' {
3576                        found_row = Some(row);
3577                    }
3578                }
3579            }
3580            i += 1;
3581        }
3582
3583        if let Some(row) = found_row {
3584            // UI region is rows 1-5
3585            assert!(
3586                row > 5,
3587                "Log cursor row {} should be above UI end row 5",
3588                row
3589            );
3590        }
3591    }
3592
3593    #[test]
3594    fn present_ui_positions_cursor_after_restore() {
3595        let mut output = Vec::new();
3596        {
3597            let mut writer = TerminalWriter::new(
3598                &mut output,
3599                ScreenMode::Inline { ui_height: 5 },
3600                UiAnchor::Bottom,
3601                basic_caps(),
3602            );
3603            writer.set_size(80, 24);
3604
3605            let buffer = Buffer::new(80, 5);
3606            // Request cursor at (2, 1) in UI coordinates
3607            writer.present_ui(&buffer, Some((2, 1)), true).unwrap();
3608        }
3609
3610        // UI starts at row 20 (24 - 5 + 1 = 20) (1-indexed)
3611        // Cursor requested at relative (2, 1) -> (x=3, y=2) (1-indexed)
3612        // Absolute position: y = 20 + 1 = 21. x = 3.
3613        let expected_pos = b"\x1b[21;3H";
3614
3615        // Find restore
3616        let restore_idx = find_nth(&output, CURSOR_RESTORE, 1).expect("expected cursor restore");
3617        let after_restore = &output[restore_idx..];
3618
3619        // Ensure cursor positioning happens *after* restore
3620        assert!(
3621            after_restore
3622                .windows(expected_pos.len())
3623                .any(|w| w == expected_pos),
3624            "Cursor positioning should happen after restore"
3625        );
3626    }
3627
3628    // =========================================================================
3629    // RuntimeDiffConfig tests
3630    // =========================================================================
3631
3632    #[test]
3633    fn runtime_diff_config_default() {
3634        let config = RuntimeDiffConfig::default();
3635        assert!(config.bayesian_enabled);
3636        assert!(config.dirty_rows_enabled);
3637        assert!(config.dirty_span_config.enabled);
3638        assert!(config.tile_diff_config.enabled);
3639        assert!(config.reset_on_resize);
3640        assert!(config.reset_on_invalidation);
3641    }
3642
3643    #[test]
3644    fn runtime_diff_config_builder() {
3645        let custom_span = DirtySpanConfig::default().with_max_spans_per_row(8);
3646        let tile_config = TileDiffConfig::default()
3647            .with_enabled(false)
3648            .with_tile_size(24, 12)
3649            .with_dense_tile_ratio(0.75)
3650            .with_max_tiles(2048);
3651        let config = RuntimeDiffConfig::new()
3652            .with_bayesian_enabled(false)
3653            .with_dirty_rows_enabled(false)
3654            .with_dirty_span_config(custom_span)
3655            .with_dirty_spans_enabled(false)
3656            .with_tile_diff_config(tile_config)
3657            .with_reset_on_resize(false)
3658            .with_reset_on_invalidation(false);
3659
3660        assert!(!config.bayesian_enabled);
3661        assert!(!config.dirty_rows_enabled);
3662        assert!(!config.dirty_span_config.enabled);
3663        assert_eq!(config.dirty_span_config.max_spans_per_row, 8);
3664        assert!(!config.tile_diff_config.enabled);
3665        assert_eq!(config.tile_diff_config.tile_w, 24);
3666        assert_eq!(config.tile_diff_config.tile_h, 12);
3667        assert_eq!(config.tile_diff_config.max_tiles, 2048);
3668        assert!(!config.reset_on_resize);
3669        assert!(!config.reset_on_invalidation);
3670    }
3671
3672    #[test]
3673    fn with_diff_config_applies_strategy_config() {
3674        use ftui_render::diff_strategy::DiffStrategyConfig;
3675
3676        let strategy_config = DiffStrategyConfig {
3677            prior_alpha: 5.0,
3678            prior_beta: 5.0,
3679            ..Default::default()
3680        };
3681
3682        let runtime_config =
3683            RuntimeDiffConfig::default().with_strategy_config(strategy_config.clone());
3684
3685        let writer = TerminalWriter::with_diff_config(
3686            Vec::<u8>::new(),
3687            ScreenMode::AltScreen,
3688            UiAnchor::Bottom,
3689            basic_caps(),
3690            runtime_config,
3691        );
3692
3693        // Verify the strategy config was applied
3694        let (alpha, beta) = writer.diff_strategy().posterior_params();
3695        assert!((alpha - 5.0).abs() < 0.001);
3696        assert!((beta - 5.0).abs() < 0.001);
3697    }
3698
3699    #[test]
3700    fn with_diff_config_applies_tile_config() {
3701        let tile_config = TileDiffConfig::default()
3702            .with_enabled(false)
3703            .with_tile_size(32, 16)
3704            .with_max_tiles(1024);
3705        let runtime_config = RuntimeDiffConfig::default().with_tile_diff_config(tile_config);
3706
3707        let mut writer = TerminalWriter::with_diff_config(
3708            Vec::<u8>::new(),
3709            ScreenMode::AltScreen,
3710            UiAnchor::Bottom,
3711            basic_caps(),
3712            runtime_config,
3713        );
3714
3715        let applied = writer.diff_scratch.tile_config_mut();
3716        assert!(!applied.enabled);
3717        assert_eq!(applied.tile_w, 32);
3718        assert_eq!(applied.tile_h, 16);
3719        assert_eq!(applied.max_tiles, 1024);
3720    }
3721
3722    #[test]
3723    fn diff_config_accessor() {
3724        let config = RuntimeDiffConfig::default().with_bayesian_enabled(false);
3725
3726        let writer = TerminalWriter::with_diff_config(
3727            Vec::<u8>::new(),
3728            ScreenMode::AltScreen,
3729            UiAnchor::Bottom,
3730            basic_caps(),
3731            config,
3732        );
3733
3734        assert!(!writer.diff_config().bayesian_enabled);
3735    }
3736
3737    #[test]
3738    fn last_diff_strategy_updates_after_present() {
3739        let mut output = Vec::new();
3740        let mut writer = TerminalWriter::with_diff_config(
3741            &mut output,
3742            ScreenMode::AltScreen,
3743            UiAnchor::Bottom,
3744            basic_caps(),
3745            RuntimeDiffConfig::default(),
3746        );
3747        writer.set_size(10, 3);
3748
3749        let mut buffer = Buffer::new(10, 3);
3750        buffer.set_raw(0, 0, Cell::from_char('X'));
3751
3752        assert!(writer.last_diff_strategy().is_none());
3753        writer.present_ui(&buffer, None, false).unwrap();
3754        assert_eq!(writer.last_diff_strategy(), Some(DiffStrategy::FullRedraw));
3755
3756        buffer.set_raw(1, 1, Cell::from_char('Y'));
3757        writer.present_ui(&buffer, None, false).unwrap();
3758        assert!(writer.last_diff_strategy().is_some());
3759    }
3760
3761    #[test]
3762    fn diff_decision_evidence_schema_includes_span_fields() {
3763        let evidence_path = temp_evidence_path("diff_decision_schema");
3764        let sink = EvidenceSink::from_config(
3765            &crate::evidence_sink::EvidenceSinkConfig::enabled_file(&evidence_path),
3766        )
3767        .expect("evidence sink config")
3768        .expect("evidence sink enabled");
3769
3770        let mut writer = TerminalWriter::with_diff_config(
3771            Vec::<u8>::new(),
3772            ScreenMode::AltScreen,
3773            UiAnchor::Bottom,
3774            basic_caps(),
3775            RuntimeDiffConfig::default(),
3776        )
3777        .with_evidence_sink(sink);
3778        writer.set_size(10, 3);
3779
3780        let mut buffer = Buffer::new(10, 3);
3781        buffer.set_raw(0, 0, Cell::from_char('X'));
3782        writer.present_ui(&buffer, None, false).unwrap();
3783
3784        buffer.set_raw(1, 1, Cell::from_char('Y'));
3785        writer.present_ui(&buffer, None, false).unwrap();
3786
3787        let jsonl = std::fs::read_to_string(&evidence_path).expect("read evidence jsonl");
3788        let line = jsonl
3789            .lines()
3790            .find(|line| line.contains("\"event\":\"diff_decision\""))
3791            .expect("diff_decision line");
3792        let value: serde_json::Value = serde_json::from_str(line).expect("valid json");
3793
3794        assert_eq!(
3795            value["schema_version"],
3796            crate::evidence_sink::EVIDENCE_SCHEMA_VERSION
3797        );
3798        assert_eq!(value["event"], "diff_decision");
3799        assert!(
3800            value["run_id"]
3801                .as_str()
3802                .map(|s| !s.is_empty())
3803                .unwrap_or(false),
3804            "run_id should be a non-empty string"
3805        );
3806        assert!(
3807            value["event_idx"].is_number(),
3808            "event_idx should be numeric"
3809        );
3810        assert_eq!(value["screen_mode"], "altscreen");
3811        assert!(value["cols"].is_number(), "cols should be numeric");
3812        assert!(value["rows"].is_number(), "rows should be numeric");
3813        assert!(
3814            value["span_count"].is_number(),
3815            "span_count should be numeric"
3816        );
3817        assert!(
3818            value["span_coverage_pct"].is_number(),
3819            "span_coverage_pct should be numeric"
3820        );
3821        assert!(
3822            value["tile_size"].is_number(),
3823            "tile_size should be numeric"
3824        );
3825        assert!(
3826            value["dirty_tile_count"].is_number(),
3827            "dirty_tile_count should be numeric"
3828        );
3829        assert!(
3830            value["skipped_tile_count"].is_number(),
3831            "skipped_tile_count should be numeric"
3832        );
3833        assert!(
3834            value["sat_build_cost_est"].is_number(),
3835            "sat_build_cost_est should be numeric"
3836        );
3837        assert!(
3838            value["fallback_reason"].is_string(),
3839            "fallback_reason should be string"
3840        );
3841        assert!(
3842            value["scan_cost_estimate"].is_number(),
3843            "scan_cost_estimate should be numeric"
3844        );
3845        assert!(
3846            value["max_span_len"].is_number(),
3847            "max_span_len should be numeric"
3848        );
3849        assert!(
3850            value["guard_reason"].is_string(),
3851            "guard_reason should be a string"
3852        );
3853        assert!(
3854            value["hysteresis_applied"].is_boolean(),
3855            "hysteresis_applied should be boolean"
3856        );
3857        assert!(
3858            value["hysteresis_ratio"].is_number(),
3859            "hysteresis_ratio should be numeric"
3860        );
3861        assert!(
3862            value["fallback_reason"].is_string(),
3863            "fallback_reason should be a string"
3864        );
3865        assert!(
3866            value["scan_cost_estimate"].is_number(),
3867            "scan_cost_estimate should be numeric"
3868        );
3869    }
3870
3871    #[test]
3872    fn diff_strategy_posterior_updates_with_total_cells() {
3873        let mut output = Vec::new();
3874        let mut writer = TerminalWriter::with_diff_config(
3875            &mut output,
3876            ScreenMode::AltScreen,
3877            UiAnchor::Bottom,
3878            basic_caps(),
3879            RuntimeDiffConfig::default(),
3880        );
3881        writer.set_size(10, 10);
3882
3883        let mut buffer = Buffer::new(10, 10);
3884        buffer.set_raw(0, 0, Cell::from_char('A'));
3885        writer.present_ui(&buffer, None, false).unwrap();
3886
3887        let mut buffer2 = Buffer::new(10, 10);
3888        for x in 0..10u16 {
3889            buffer2.set_raw(x, 0, Cell::from_char('X'));
3890        }
3891        writer.present_ui(&buffer2, None, false).unwrap();
3892
3893        let config = writer.diff_strategy().config().clone();
3894        let total_cells = 10usize * 10usize;
3895        let changed = 10usize;
3896        let alpha = config.prior_alpha * config.decay + changed as f64;
3897        let beta = config.prior_beta * config.decay + (total_cells - changed) as f64;
3898        let expected = alpha / (alpha + beta);
3899        let mean = writer.diff_strategy().posterior_mean();
3900        assert!(
3901            (mean - expected).abs() < 1e-9,
3902            "posterior mean should use total_cells; got {mean:.6}, expected {expected:.6}"
3903        );
3904    }
3905
3906    #[test]
3907    fn log_write_without_scroll_region_resets_diff_strategy() {
3908        // When log writes occur without scroll region protection,
3909        // the diff strategy posterior should be reset to priors.
3910        let mut output = Vec::new();
3911        {
3912            let config = RuntimeDiffConfig::default();
3913            let mut writer = TerminalWriter::with_diff_config(
3914                &mut output,
3915                ScreenMode::Inline { ui_height: 5 },
3916                UiAnchor::Bottom,
3917                basic_caps(), // no scroll region support
3918                config,
3919            );
3920            writer.set_size(80, 24);
3921
3922            // Present a frame and observe some changes to modify posterior
3923            let mut buffer = Buffer::new(80, 5);
3924            buffer.set_raw(0, 0, Cell::from_char('X'));
3925            writer.present_ui(&buffer, None, false).unwrap();
3926
3927            // Posterior should have been updated from initial priors
3928            let (_alpha_before, _) = writer.diff_strategy().posterior_params();
3929
3930            // Present another frame
3931            buffer.set_raw(1, 1, Cell::from_char('Y'));
3932            writer.present_ui(&buffer, None, false).unwrap();
3933
3934            // Log write without scroll region should reset
3935            assert!(!writer.scroll_region_active());
3936            writer.write_log("log message\n").unwrap();
3937
3938            // After reset, posterior should be back to priors
3939            let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
3940            assert!(
3941                (alpha_after - 1.0).abs() < 0.01 && (beta_after - 19.0).abs() < 0.01,
3942                "posterior should reset to priors after log write: alpha={}, beta={}",
3943                alpha_after,
3944                beta_after
3945            );
3946        }
3947    }
3948
3949    #[test]
3950    fn log_write_with_scroll_region_preserves_diff_strategy() {
3951        // When scroll region is active, log writes should NOT reset diff strategy
3952        let mut output = Vec::new();
3953        {
3954            let config = RuntimeDiffConfig::default();
3955            let mut writer = TerminalWriter::with_diff_config(
3956                &mut output,
3957                ScreenMode::Inline { ui_height: 5 },
3958                UiAnchor::Bottom,
3959                scroll_region_caps(), // has scroll region support
3960                config,
3961            );
3962            writer.set_size(80, 24);
3963
3964            // Present frames to activate scroll region and update posterior
3965            let mut buffer = Buffer::new(80, 5);
3966            buffer.set_raw(0, 0, Cell::from_char('X'));
3967            writer.present_ui(&buffer, None, false).unwrap();
3968
3969            buffer.set_raw(1, 1, Cell::from_char('Y'));
3970            writer.present_ui(&buffer, None, false).unwrap();
3971
3972            assert!(writer.scroll_region_active());
3973
3974            // Get posterior before log write
3975            let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
3976
3977            // Log write with scroll region active should NOT reset
3978            writer.write_log("log message\n").unwrap();
3979
3980            let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
3981            assert!(
3982                (alpha_after - alpha_before).abs() < 0.01
3983                    && (beta_after - beta_before).abs() < 0.01,
3984                "posterior should be preserved with scroll region: before=({}, {}), after=({}, {})",
3985                alpha_before,
3986                beta_before,
3987                alpha_after,
3988                beta_after
3989            );
3990        }
3991    }
3992
3993    #[test]
3994    fn strategy_selection_config_flags_applied() {
3995        // Verify that RuntimeDiffConfig flags are correctly stored and accessible
3996        let config = RuntimeDiffConfig::default()
3997            .with_dirty_rows_enabled(false)
3998            .with_bayesian_enabled(false);
3999
4000        let writer = TerminalWriter::with_diff_config(
4001            Vec::<u8>::new(),
4002            ScreenMode::AltScreen,
4003            UiAnchor::Bottom,
4004            basic_caps(),
4005            config,
4006        );
4007
4008        // Config should be accessible
4009        assert!(!writer.diff_config().dirty_rows_enabled);
4010        assert!(!writer.diff_config().bayesian_enabled);
4011
4012        // Diff strategy should use the underlying strategy config
4013        let (alpha, beta) = writer.diff_strategy().posterior_params();
4014        // Default priors
4015        assert!((alpha - 1.0).abs() < 0.01);
4016        assert!((beta - 19.0).abs() < 0.01);
4017    }
4018
4019    #[test]
4020    fn resize_respects_reset_toggle() {
4021        // With reset_on_resize disabled, posterior should be preserved after resize
4022        let config = RuntimeDiffConfig::default().with_reset_on_resize(false);
4023
4024        let mut writer = TerminalWriter::with_diff_config(
4025            Vec::<u8>::new(),
4026            ScreenMode::AltScreen,
4027            UiAnchor::Bottom,
4028            basic_caps(),
4029            config,
4030        );
4031        writer.set_size(80, 24);
4032
4033        // Present frames to update posterior
4034        let mut buffer = Buffer::new(80, 24);
4035        buffer.set_raw(0, 0, Cell::from_char('X'));
4036        writer.present_ui(&buffer, None, false).unwrap();
4037
4038        let mut buffer2 = Buffer::new(80, 24);
4039        buffer2.set_raw(1, 1, Cell::from_char('Y'));
4040        writer.present_ui(&buffer2, None, false).unwrap();
4041
4042        // Posterior should have moved from initial priors
4043        let (alpha_before, beta_before) = writer.diff_strategy().posterior_params();
4044
4045        // Resize - with reset disabled, posterior should be preserved
4046        writer.set_size(100, 30);
4047
4048        let (alpha_after, beta_after) = writer.diff_strategy().posterior_params();
4049        assert!(
4050            (alpha_after - alpha_before).abs() < 0.01 && (beta_after - beta_before).abs() < 0.01,
4051            "posterior should be preserved when reset_on_resize=false"
4052        );
4053    }
4054}