Skip to main content

ftui_runtime/
program.rs

1#![forbid(unsafe_code)]
2
3//! Bubbletea/Elm-style runtime for terminal applications.
4//!
5//! The program runtime manages the update/view loop, handling events and
6//! rendering frames. It separates state (Model) from rendering (View) and
7//! provides a command pattern for side effects.
8//!
9//! # Example
10//!
11//! ```ignore
12//! use ftui_runtime::program::{Model, Cmd};
13//! use ftui_core::event::Event;
14//! use ftui_render::frame::Frame;
15//!
16//! struct Counter {
17//!     count: i32,
18//! }
19//!
20//! enum Msg {
21//!     Increment,
22//!     Decrement,
23//!     Quit,
24//! }
25//!
26//! impl From<Event> for Msg {
27//!     fn from(event: Event) -> Self {
28//!         match event {
29//!             Event::Key(k) if k.is_char('q') => Msg::Quit,
30//!             Event::Key(k) if k.is_char('+') => Msg::Increment,
31//!             Event::Key(k) if k.is_char('-') => Msg::Decrement,
32//!             _ => Msg::Increment, // Default
33//!         }
34//!     }
35//! }
36//!
37//! impl Model for Counter {
38//!     type Message = Msg;
39//!
40//!     fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
41//!         match msg {
42//!             Msg::Increment => { self.count += 1; Cmd::none() }
43//!             Msg::Decrement => { self.count -= 1; Cmd::none() }
44//!             Msg::Quit => Cmd::quit(),
45//!         }
46//!     }
47//!
48//!     fn view(&self, frame: &mut Frame) {
49//!         // Render counter value to frame
50//!     }
51//! }
52//! ```
53
54use crate::StorageResult;
55use crate::evidence_sink::{EvidenceSink, EvidenceSinkConfig};
56use crate::evidence_telemetry::{
57    BudgetDecisionSnapshot, ConformalSnapshot, ResizeDecisionSnapshot, set_budget_snapshot,
58    set_resize_snapshot,
59};
60use crate::input_fairness::{FairnessDecision, FairnessEventType, InputFairnessGuard};
61use crate::input_macro::{EventRecorder, InputMacro};
62use crate::locale::LocaleContext;
63use crate::queueing_scheduler::{EstimateSource, QueueingScheduler, SchedulerConfig, WeightSource};
64use crate::render_trace::RenderTraceConfig;
65use crate::resize_coalescer::{CoalesceAction, CoalescerConfig, ResizeCoalescer};
66use crate::state_persistence::StateRegistry;
67use crate::subscription::SubscriptionManager;
68use crate::terminal_writer::{RuntimeDiffConfig, ScreenMode, TerminalWriter, UiAnchor};
69use crate::voi_sampling::{VoiConfig, VoiSampler};
70use crate::{BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor};
71#[cfg(feature = "asupersync-executor")]
72use asupersync::runtime::{BlockingTaskHandle, Runtime as AsupersyncRuntime, RuntimeBuilder};
73use ftui_backend::{BackendEventSource, BackendFeatures};
74use ftui_core::event::{
75    Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
76};
77#[cfg(feature = "crossterm-compat")]
78use ftui_core::terminal_capabilities::TerminalCapabilities;
79#[cfg(feature = "crossterm-compat")]
80use ftui_core::terminal_session::{SessionOptions, TerminalSession};
81use ftui_layout::{
82    PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS, PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PaneCancelReason,
83    PaneDragResizeMachine, PaneDragResizeMachineError, PaneDragResizeState,
84    PaneDragResizeTransition, PaneInertialThrow, PaneLayout, PaneModifierSnapshot,
85    PaneMotionVector, PaneNodeKind, PanePointerButton, PanePointerPosition,
86    PanePressureSnapProfile, PaneResizeDirection, PaneResizeTarget, PaneSemanticInputEvent,
87    PaneSemanticInputEventKind, PaneTree, Rect, SplitAxis,
88};
89use ftui_render::arena::FrameArena;
90use ftui_render::budget::{
91    BudgetControllerConfig, BudgetDecision, DegradationLevel, FrameBudgetConfig, RenderBudget,
92};
93use ftui_render::buffer::Buffer;
94use ftui_render::diff_strategy::DiffStrategy;
95use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
96use ftui_render::frame_guardrails::{FrameGuardrails, GuardrailsConfig};
97use ftui_render::sanitize::sanitize;
98use std::any::Any;
99use std::collections::HashMap;
100use std::io::{self, Stdout, Write};
101use std::panic::{self, AssertUnwindSafe};
102use std::sync::Arc;
103
104/// Check for pending termination signal. Returns `None` when crossterm is not
105/// enabled (headless / wasm builds don't install signal handlers).
106#[inline]
107fn check_termination_signal() -> Option<i32> {
108    ftui_core::shutdown_signal::pending_termination_signal()
109}
110
111/// Clear the pending termination signal.
112#[inline]
113fn clear_termination_signal() {
114    ftui_core::shutdown_signal::clear_pending_termination_signal();
115}
116use std::sync::mpsc;
117use std::thread::{self, JoinHandle};
118use tracing::{debug, debug_span, info, info_span, trace};
119use web_time::{Duration, Instant};
120
121/// The Model trait defines application state and behavior.
122///
123/// Implementations define how the application responds to events
124/// and renders its current state.
125pub trait Model: Sized {
126    /// The message type for this model.
127    ///
128    /// Messages represent actions that update the model state.
129    /// Must be convertible from terminal events.
130    type Message: From<Event> + Send + 'static;
131
132    /// Initialize the model with startup commands.
133    ///
134    /// Called once when the program starts. Return commands to execute
135    /// initial side effects like loading data.
136    fn init(&mut self) -> Cmd<Self::Message> {
137        Cmd::none()
138    }
139
140    /// Update the model in response to a message.
141    ///
142    /// This is the core state transition function. Returns commands
143    /// for any side effects that should be executed.
144    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
145
146    /// Render the current state to a frame.
147    ///
148    /// Called after updates when the UI needs to be redrawn.
149    fn view(&self, frame: &mut Frame);
150
151    /// Declare active subscriptions.
152    ///
153    /// Called after each `update()`. The runtime compares the returned set
154    /// (by `SubId`) against currently running subscriptions and starts/stops
155    /// as needed. Returning an empty vec stops all subscriptions.
156    ///
157    /// # Default
158    ///
159    /// Returns an empty vec (no subscriptions).
160    fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
161        vec![]
162    }
163
164    /// Downcast to [`ScreenTickDispatch`](crate::tick_strategy::ScreenTickDispatch)
165    /// for per-screen tick control.
166    ///
167    /// Override this to return `Some(self)` in multi-screen Models. The runtime
168    /// will then consult the active [`TickStrategy`](crate::tick_strategy::TickStrategy)
169    /// for each inactive screen instead of ticking monolithically.
170    ///
171    /// Default: `None` (all screens tick every frame, backwards-compatible).
172    fn as_screen_tick_dispatch(
173        &mut self,
174    ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
175        None
176    }
177
178    /// Called before the runtime exits, whether via [`Cmd::Quit`] or signal.
179    ///
180    /// Return cleanup commands (e.g., saving state, closing connections).
181    /// The runtime executes these before teardown.
182    ///
183    /// # Migration rationale
184    ///
185    /// Source frameworks use `componentWillUnmount`, `useEffect` cleanup, or
186    /// `beforeDestroy` hooks. This provides an equivalent lifecycle point.
187    fn on_shutdown(&mut self) -> Cmd<Self::Message> {
188        Cmd::none()
189    }
190
191    /// Called when an unrecoverable error occurs during the runtime loop.
192    ///
193    /// Return commands for error recovery or graceful degradation. The
194    /// `error` string contains the error description.
195    ///
196    /// # Migration rationale
197    ///
198    /// Source frameworks use `componentDidCatch`, error boundaries, or
199    /// `onError` hooks. This provides an equivalent error recovery point.
200    fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
201        Cmd::none()
202    }
203}
204
205/// Default weight assigned to background tasks.
206const DEFAULT_TASK_WEIGHT: f64 = 1.0;
207
208/// Default estimated task cost (ms) used for scheduling.
209const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
210
211/// Scheduling metadata for background tasks.
212#[derive(Debug, Clone)]
213pub struct TaskSpec {
214    /// Task weight (importance). Higher = more priority.
215    pub weight: f64,
216    /// Estimated task cost in milliseconds.
217    pub estimate_ms: f64,
218    /// Optional task name for evidence logging.
219    pub name: Option<String>,
220}
221
222impl Default for TaskSpec {
223    fn default() -> Self {
224        Self {
225            weight: DEFAULT_TASK_WEIGHT,
226            estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
227            name: None,
228        }
229    }
230}
231
232impl TaskSpec {
233    /// Create a task spec with an explicit weight and estimate.
234    #[must_use]
235    pub fn new(weight: f64, estimate_ms: f64) -> Self {
236        Self {
237            weight,
238            estimate_ms,
239            name: None,
240        }
241    }
242
243    /// Attach a task name for diagnostics.
244    #[must_use]
245    pub fn with_name(mut self, name: impl Into<String>) -> Self {
246        self.name = Some(name.into());
247        self
248    }
249}
250
251/// Per-frame timing data for profiling.
252#[derive(Debug, Clone, Copy)]
253pub struct FrameTiming {
254    pub frame_idx: u64,
255    pub update_us: u64,
256    pub render_us: u64,
257    pub diff_us: u64,
258    pub present_us: u64,
259    pub total_us: u64,
260}
261
262#[derive(Debug)]
263struct SignalTerminationError {
264    signal: i32,
265}
266
267impl std::fmt::Display for SignalTerminationError {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        write!(f, "terminated by signal {}", self.signal)
270    }
271}
272
273impl std::error::Error for SignalTerminationError {}
274
275fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
276    err.get_ref()
277        .and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
278        .map(|inner| inner.signal)
279}
280
281/// Sink for frame timing events.
282pub trait FrameTimingSink: Send + Sync {
283    fn record_frame(&self, timing: &FrameTiming);
284}
285
286/// Configuration for frame timing capture.
287#[derive(Clone)]
288pub struct FrameTimingConfig {
289    pub sink: Arc<dyn FrameTimingSink>,
290}
291
292impl FrameTimingConfig {
293    #[must_use]
294    pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
295        Self { sink }
296    }
297}
298
299impl std::fmt::Debug for FrameTimingConfig {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        f.debug_struct("FrameTimingConfig")
302            .field("sink", &"<dyn FrameTimingSink>")
303            .finish()
304    }
305}
306
307/// Commands represent side effects to be executed by the runtime.
308///
309/// Commands are returned from `init()` and `update()` to trigger
310/// actions like quitting, sending messages, or scheduling ticks.
311#[derive(Default)]
312pub enum Cmd<M> {
313    /// No operation.
314    #[default]
315    None,
316    /// Quit the application.
317    Quit,
318    /// Execute multiple commands as a batch (currently sequential).
319    Batch(Vec<Cmd<M>>),
320    /// Execute commands sequentially.
321    Sequence(Vec<Cmd<M>>),
322    /// Send a message to the model.
323    Msg(M),
324    /// Schedule a tick after a duration.
325    Tick(Duration),
326    /// Write a log message to the terminal output.
327    ///
328    /// This writes to the scrollback region in inline mode, or is ignored/handled
329    /// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
330    Log(String),
331    /// Execute a blocking operation on a background thread.
332    ///
333    /// When effect queue scheduling is enabled, tasks are enqueued and executed
334    /// in Smith-rule order on a dedicated worker thread. Otherwise the closure
335    /// runs on a spawned thread immediately. The return value is sent back
336    /// as a message to the model.
337    Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
338    /// Save widget state to the persistence registry.
339    ///
340    /// Triggers a flush of the state registry to the storage backend.
341    /// No-op if persistence is not configured.
342    SaveState,
343    /// Restore widget state from the persistence registry.
344    ///
345    /// Triggers a load from the storage backend and updates the cache.
346    /// No-op if persistence is not configured. Returns a message via
347    /// callback if state was successfully restored.
348    RestoreState,
349    /// Toggle mouse capture at runtime.
350    ///
351    /// Instructs the terminal session to enable or disable mouse event capture.
352    /// No-op in test simulators.
353    SetMouseCapture(bool),
354    /// Replace the tick strategy at runtime.
355    ///
356    /// Takes ownership of a boxed strategy. Use when switching from one
357    /// strategy to another (e.g., `Uniform` → `Predictive` after loading
358    /// persisted transition data).
359    SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
360}
361
362impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        match self {
365            Self::None => write!(f, "None"),
366            Self::Quit => write!(f, "Quit"),
367            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
368            Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
369            Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
370            Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
371            Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
372            Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
373            Self::SaveState => write!(f, "SaveState"),
374            Self::RestoreState => write!(f, "RestoreState"),
375            Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
376            Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
377        }
378    }
379}
380
381impl<M> Cmd<M> {
382    /// Create a no-op command.
383    #[inline]
384    pub fn none() -> Self {
385        Self::None
386    }
387
388    /// Create a quit command.
389    #[inline]
390    pub fn quit() -> Self {
391        Self::Quit
392    }
393
394    /// Create a message command.
395    #[inline]
396    pub fn msg(m: M) -> Self {
397        Self::Msg(m)
398    }
399
400    /// Create a log command.
401    ///
402    /// The message will be sanitized and written to the terminal log (scrollback).
403    /// A newline is appended if not present.
404    #[inline]
405    pub fn log(msg: impl Into<String>) -> Self {
406        Self::Log(msg.into())
407    }
408
409    /// Create a batch of commands.
410    pub fn batch(cmds: Vec<Self>) -> Self {
411        if cmds.is_empty() {
412            Self::None
413        } else if cmds.len() == 1 {
414            cmds.into_iter().next().unwrap_or(Self::None)
415        } else {
416            Self::Batch(cmds)
417        }
418    }
419
420    /// Create a sequence of commands.
421    pub fn sequence(cmds: Vec<Self>) -> Self {
422        if cmds.is_empty() {
423            Self::None
424        } else if cmds.len() == 1 {
425            cmds.into_iter().next().unwrap_or(Self::None)
426        } else {
427            Self::Sequence(cmds)
428        }
429    }
430
431    /// Return a stable name for telemetry and tracing.
432    #[inline]
433    pub fn type_name(&self) -> &'static str {
434        match self {
435            Self::None => "None",
436            Self::Quit => "Quit",
437            Self::Batch(_) => "Batch",
438            Self::Sequence(_) => "Sequence",
439            Self::Msg(_) => "Msg",
440            Self::Tick(_) => "Tick",
441            Self::Log(_) => "Log",
442            Self::Task(..) => "Task",
443            Self::SaveState => "SaveState",
444            Self::RestoreState => "RestoreState",
445            Self::SetMouseCapture(_) => "SetMouseCapture",
446            Self::SetTickStrategy(_) => "SetTickStrategy",
447        }
448    }
449
450    /// Create a tick command.
451    #[inline]
452    pub fn tick(duration: Duration) -> Self {
453        Self::Tick(duration)
454    }
455
456    /// Create a background task command.
457    ///
458    /// The closure runs on a spawned thread (or the effect queue worker when
459    /// scheduling is enabled). When it completes, the returned message is
460    /// sent back to the model's `update()`.
461    pub fn task<F>(f: F) -> Self
462    where
463        F: FnOnce() -> M + Send + 'static,
464    {
465        Self::Task(TaskSpec::default(), Box::new(f))
466    }
467
468    /// Create a background task command with explicit scheduling metadata.
469    pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
470    where
471        F: FnOnce() -> M + Send + 'static,
472    {
473        Self::Task(spec, Box::new(f))
474    }
475
476    /// Create a background task command with explicit weight and estimate.
477    pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
478    where
479        F: FnOnce() -> M + Send + 'static,
480    {
481        Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
482    }
483
484    /// Create a named background task command.
485    pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
486    where
487        F: FnOnce() -> M + Send + 'static,
488    {
489        Self::Task(TaskSpec::default().with_name(name), Box::new(f))
490    }
491
492    /// Replace the active tick strategy at runtime.
493    ///
494    /// Use when switching strategies (e.g., `Uniform` → `Predictive` after
495    /// loading persisted transition data).
496    pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
497        Self::SetTickStrategy(Box::new(strategy))
498    }
499
500    /// Create a save state command.
501    ///
502    /// Triggers a flush of the state registry to the storage backend.
503    /// No-op if persistence is not configured.
504    #[inline]
505    pub fn save_state() -> Self {
506        Self::SaveState
507    }
508
509    /// Create a restore state command.
510    ///
511    /// Triggers a load from the storage backend.
512    /// No-op if persistence is not configured.
513    #[inline]
514    pub fn restore_state() -> Self {
515        Self::RestoreState
516    }
517
518    /// Create a mouse capture toggle command.
519    ///
520    /// Instructs the runtime to enable or disable mouse event capture on the
521    /// underlying terminal session.
522    #[inline]
523    pub fn set_mouse_capture(enabled: bool) -> Self {
524        Self::SetMouseCapture(enabled)
525    }
526
527    /// Count the number of atomic commands in this command.
528    ///
529    /// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
530    pub fn count(&self) -> usize {
531        match self {
532            Self::None => 0,
533            Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
534            _ => 1,
535        }
536    }
537}
538
539/// Resize handling behavior for the runtime.
540#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541pub enum ResizeBehavior {
542    /// Apply resize immediately (no debounce, no placeholder).
543    Immediate,
544    /// Coalesce resize events for continuous reflow.
545    Throttled,
546}
547
548impl ResizeBehavior {
549    const fn uses_coalescer(self) -> bool {
550        matches!(self, ResizeBehavior::Throttled)
551    }
552}
553
554/// Policy controlling when terminal mouse capture is enabled.
555///
556/// Mouse capture can steal normal scrollback interaction in inline mode.
557/// `Auto` keeps inline mode scrollback-safe while still enabling mouse in
558/// alt-screen mode.
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
560pub enum MouseCapturePolicy {
561    /// Enable in alt-screen mode, disable in inline modes.
562    #[default]
563    Auto,
564    /// Always enable mouse capture.
565    On,
566    /// Always disable mouse capture.
567    Off,
568}
569
570impl MouseCapturePolicy {
571    /// Resolve the policy to a concrete mouse-capture toggle.
572    #[must_use]
573    pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
574        match self {
575            Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
576            Self::On => true,
577            Self::Off => false,
578        }
579    }
580}
581
582const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
583const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
584
585/// One splitter handle region in terminal cell-space.
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub struct PaneTerminalSplitterHandle {
588    /// Semantic resize target represented by this handle.
589    pub target: PaneResizeTarget,
590    /// Cell-space hit rectangle for this handle.
591    pub rect: Rect,
592    /// Split boundary coordinate used for deterministic nearest-target ranking.
593    pub boundary: i32,
594}
595
596/// Build deterministic splitter handle regions for terminal hit-testing.
597///
598/// Handles are emitted in split-id order and are clamped to the split rect.
599#[must_use]
600pub fn pane_terminal_splitter_handles(
601    tree: &PaneTree,
602    layout: &PaneLayout,
603    hit_thickness: u16,
604) -> Vec<PaneTerminalSplitterHandle> {
605    let thickness = if hit_thickness == 0 {
606        PANE_TERMINAL_DEFAULT_HIT_THICKNESS
607    } else {
608        hit_thickness
609    };
610    let mut handles = Vec::new();
611    for node in tree.nodes() {
612        let PaneNodeKind::Split(split) = &node.kind else {
613            continue;
614        };
615        let Some(split_rect) = layout.rect(node.id) else {
616            continue;
617        };
618        if split_rect.is_empty() {
619            continue;
620        }
621        let Some(first_rect) = layout.rect(split.first) else {
622            continue;
623        };
624        let Some(second_rect) = layout.rect(split.second) else {
625            continue;
626        };
627
628        let boundary_u16 = match split.axis {
629            SplitAxis::Horizontal => {
630                // Horizontal split => left/right panes => vertical splitter line.
631                if second_rect.x == split_rect.x {
632                    first_rect.right()
633                } else {
634                    second_rect.x
635                }
636            }
637            SplitAxis::Vertical => {
638                // Vertical split => top/bottom panes => horizontal splitter line.
639                if second_rect.y == split_rect.y {
640                    first_rect.bottom()
641                } else {
642                    second_rect.y
643                }
644            }
645        };
646        let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
647            continue;
648        };
649        handles.push(PaneTerminalSplitterHandle {
650            target: PaneResizeTarget {
651                split_id: node.id,
652                axis: split.axis,
653            },
654            rect,
655            boundary: i32::from(boundary_u16),
656        });
657    }
658    handles
659}
660
661/// Resolve a semantic splitter target from a terminal cell position.
662///
663/// If multiple handles overlap, chooses deterministically by:
664/// 1) smallest distance to the splitter boundary, then
665/// 2) smaller split_id, then
666/// 3) horizontal axis before vertical axis.
667#[must_use]
668pub fn pane_terminal_resolve_splitter_target(
669    handles: &[PaneTerminalSplitterHandle],
670    x: u16,
671    y: u16,
672) -> Option<PaneResizeTarget> {
673    let px = i32::from(x);
674    let py = i32::from(y);
675    let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
676
677    for handle in handles {
678        if !rect_contains_cell(handle.rect, x, y) {
679            continue;
680        }
681        let distance = match handle.target.axis {
682            SplitAxis::Horizontal => px.abs_diff(handle.boundary),
683            SplitAxis::Vertical => py.abs_diff(handle.boundary),
684        };
685        let axis_rank = match handle.target.axis {
686            SplitAxis::Horizontal => 0,
687            SplitAxis::Vertical => 1,
688        };
689        let key = (distance, handle.target.split_id.get(), axis_rank);
690        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
691            best = Some((key, handle.target));
692        }
693    }
694
695    best.map(|(_, target)| target)
696}
697
698/// Register pane splitter handles into the frame hit-grid.
699///
700/// Each handle is registered as `HitRegion::Handle` with encoded target data.
701/// Returns number of successfully-registered regions.
702pub fn register_pane_terminal_splitter_hits(
703    frame: &mut Frame,
704    handles: &[PaneTerminalSplitterHandle],
705    hit_id_base: u32,
706) -> usize {
707    let mut registered = 0usize;
708    for (idx, handle) in handles.iter().enumerate() {
709        let Ok(offset) = u32::try_from(idx) else {
710            break;
711        };
712        let hit_id = HitId::new(hit_id_base.saturating_add(offset));
713        if frame.register_hit(
714            handle.rect,
715            hit_id,
716            HitRegion::Handle,
717            encode_pane_resize_target(handle.target),
718        ) {
719            registered = registered.saturating_add(1);
720        }
721    }
722    registered
723}
724
725/// Decode pane resize target from a hit-grid tuple produced by pane handle registration.
726#[must_use]
727pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
728    let (_, region, data) = hit;
729    if region != HitRegion::Handle {
730        return None;
731    }
732    decode_pane_resize_target(data)
733}
734
735fn splitter_hit_rect(
736    axis: SplitAxis,
737    split_rect: Rect,
738    boundary: u16,
739    thickness: u16,
740) -> Option<Rect> {
741    let half = thickness.saturating_sub(1) / 2;
742    match axis {
743        SplitAxis::Horizontal => {
744            let start = boundary.saturating_sub(half).max(split_rect.x);
745            let end = boundary
746                .saturating_add(thickness.saturating_sub(half))
747                .min(split_rect.right());
748            let width = end.saturating_sub(start);
749            (width > 0 && split_rect.height > 0).then_some(Rect::new(
750                start,
751                split_rect.y,
752                width,
753                split_rect.height,
754            ))
755        }
756        SplitAxis::Vertical => {
757            let start = boundary.saturating_sub(half).max(split_rect.y);
758            let end = boundary
759                .saturating_add(thickness.saturating_sub(half))
760                .min(split_rect.bottom());
761            let height = end.saturating_sub(start);
762            (height > 0 && split_rect.width > 0).then_some(Rect::new(
763                split_rect.x,
764                start,
765                split_rect.width,
766                height,
767            ))
768        }
769    }
770}
771
772fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
773    x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
774}
775
776fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
777    let axis = match target.axis {
778        SplitAxis::Horizontal => 0_u64,
779        SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
780    };
781    (target.split_id.get() << 1) | axis
782}
783
784fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
785    let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
786        SplitAxis::Horizontal
787    } else {
788        SplitAxis::Vertical
789    };
790    let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
791    Some(PaneResizeTarget { split_id, axis })
792}
793
794// ============================================================================
795// Pane capability matrix for multiplexer / terminal compat (bd-6u66i)
796// ============================================================================
797
798/// Which multiplexer environment the terminal is running inside.
799#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
800pub enum PaneMuxEnvironment {
801    /// No multiplexer detected — direct terminal access.
802    None,
803    /// tmux (TMUX env var set, or DA2 terminal type 84).
804    Tmux,
805    /// GNU Screen (STY env var set, or DA2 terminal type 83).
806    Screen,
807    /// Zellij (ZELLIJ env var set).
808    Zellij,
809    /// WezTerm mux-served pane/session.
810    WeztermMux,
811}
812
813/// Resolved capability matrix describing which pane interaction features
814/// are available in the current terminal + multiplexer environment.
815///
816/// Derived from [`TerminalCapabilities`] via [`PaneCapabilityMatrix::from_capabilities`].
817/// The adapter uses this to decide which code-paths are safe and which
818/// need deterministic fallbacks.
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820pub struct PaneCapabilityMatrix {
821    /// Detected multiplexer environment.
822    pub mux: PaneMuxEnvironment,
823
824    // --- Mouse input capabilities ---
825    /// SGR (1006) extended mouse protocol available.
826    /// Without this, mouse coordinates are limited to 223 columns/rows.
827    pub mouse_sgr: bool,
828    /// Mouse drag events are reliably delivered.
829    /// False in some screen versions where drag tracking is incomplete.
830    pub mouse_drag_reliable: bool,
831    /// Mouse button events include correct button identity on release.
832    /// X10/normal mode sends button 3 for all releases; SGR preserves it.
833    pub mouse_button_discrimination: bool,
834
835    // --- Focus / lifecycle ---
836    /// Terminal delivers CSI I / CSI O focus events.
837    pub focus_events: bool,
838    /// Bracketed paste mode available (affects interaction cancel heuristics).
839    pub bracketed_paste: bool,
840
841    // --- Rendering affordances ---
842    /// Unicode box-drawing glyphs available for splitter rendering.
843    pub unicode_box_drawing: bool,
844    /// True-color support for splitter highlight/drag feedback.
845    pub true_color: bool,
846
847    // --- Fallback summary ---
848    /// One or more pane features are degraded due to environment constraints.
849    pub degraded: bool,
850}
851
852/// Human-readable description of a known limitation and its fallback.
853#[derive(Debug, Clone, PartialEq, Eq)]
854pub struct PaneCapabilityLimitation {
855    /// Short identifier (e.g. `"mouse_drag_unreliable"`).
856    pub id: &'static str,
857    /// What the limitation is.
858    pub description: &'static str,
859    /// What the adapter does instead.
860    pub fallback: &'static str,
861}
862
863impl PaneCapabilityMatrix {
864    /// Derive the pane capability matrix from terminal capabilities.
865    ///
866    /// This is the single source of truth for which pane features are
867    /// available. All fallback decisions flow from this matrix.
868    #[must_use]
869    pub fn from_capabilities(
870        caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
871    ) -> Self {
872        let mux = if caps.in_tmux {
873            PaneMuxEnvironment::Tmux
874        } else if caps.in_screen {
875            PaneMuxEnvironment::Screen
876        } else if caps.in_zellij {
877            PaneMuxEnvironment::Zellij
878        } else if caps.in_wezterm_mux {
879            PaneMuxEnvironment::WeztermMux
880        } else {
881            PaneMuxEnvironment::None
882        };
883
884        let mouse_sgr = caps.mouse_sgr;
885
886        // GNU Screen has historically unreliable drag event delivery.
887        // tmux and zellij forward drags correctly in modern versions.
888        let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
889
890        // Button discrimination requires SGR mouse protocol.
891        // Without it, X10/normal mode reports button 3 for all releases.
892        let mouse_button_discrimination = mouse_sgr;
893
894        // Focus events are conservatively disabled in any mux context.
895        let focus_events = caps.focus_events && !caps.in_any_mux();
896
897        let bracketed_paste = caps.bracketed_paste;
898        let unicode_box_drawing = caps.unicode_box_drawing;
899        let true_color = caps.true_color;
900
901        let degraded =
902            !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
903
904        Self {
905            mux,
906            mouse_sgr,
907            mouse_drag_reliable,
908            mouse_button_discrimination,
909            focus_events,
910            bracketed_paste,
911            unicode_box_drawing,
912            true_color,
913            degraded,
914        }
915    }
916
917    /// Whether pane drag interactions should be enabled at all.
918    ///
919    /// Drag requires at minimum mouse event support. If drag events
920    /// are unreliable (e.g. GNU Screen), drag is disabled and the
921    /// adapter falls back to keyboard-only resize.
922    #[must_use]
923    pub const fn drag_enabled(&self) -> bool {
924        self.mouse_drag_reliable
925    }
926
927    /// Whether focus-loss auto-cancel is effective.
928    ///
929    /// When focus events are unavailable, the adapter cannot detect
930    /// window blur — interactions must rely on timeout or explicit
931    /// keyboard cancel instead.
932    #[must_use]
933    pub const fn focus_cancel_effective(&self) -> bool {
934        self.focus_events
935    }
936
937    /// Collect all active limitations with their fallback descriptions.
938    #[must_use]
939    pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
940        let mut out = Vec::new();
941
942        if !self.mouse_sgr {
943            out.push(PaneCapabilityLimitation {
944                id: "no_sgr_mouse",
945                description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
946                fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
947            });
948        }
949
950        if !self.mouse_drag_reliable {
951            out.push(PaneCapabilityLimitation {
952                id: "mouse_drag_unreliable",
953                description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
954                fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
955            });
956        }
957
958        if !self.mouse_button_discrimination {
959            out.push(PaneCapabilityLimitation {
960                id: "no_button_discrimination",
961                description: "Mouse release events do not identify which button was released",
962                fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
963            });
964        }
965
966        if !self.focus_events {
967            out.push(PaneCapabilityLimitation {
968                id: "no_focus_events",
969                description: "Terminal does not deliver focus-in/focus-out events",
970                fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
971            });
972        }
973
974        out
975    }
976}
977
978/// Configuration for terminal-to-pane semantic input translation.
979///
980/// This adapter normalizes terminal `Event` streams into
981/// `PaneSemanticInputEvent` values accepted by `PaneDragResizeMachine`.
982#[derive(Debug, Clone, Copy, PartialEq, Eq)]
983pub struct PaneTerminalAdapterConfig {
984    /// Drag start threshold in pane-local units.
985    pub drag_threshold: u16,
986    /// Drag update hysteresis threshold in pane-local units.
987    pub update_hysteresis: u16,
988    /// Mouse button required to begin a drag sequence.
989    pub activation_button: PanePointerButton,
990    /// Minimum drag delta (Manhattan distance, cells) before forwarding
991    /// updates while already in the dragging state.
992    pub drag_update_coalesce_distance: u16,
993    /// Cancel active interactions on focus loss.
994    pub cancel_on_focus_lost: bool,
995    /// Cancel active interactions on terminal resize.
996    pub cancel_on_resize: bool,
997}
998
999impl Default for PaneTerminalAdapterConfig {
1000    fn default() -> Self {
1001        Self {
1002            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1003            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1004            activation_button: PanePointerButton::Primary,
1005            drag_update_coalesce_distance: 2,
1006            cancel_on_focus_lost: true,
1007            cancel_on_resize: true,
1008        }
1009    }
1010}
1011
1012#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1013struct PaneTerminalActivePointer {
1014    pointer_id: u32,
1015    target: PaneResizeTarget,
1016    button: PanePointerButton,
1017    last_position: PanePointerPosition,
1018    cumulative_delta_x: i32,
1019    cumulative_delta_y: i32,
1020    direction_changes: u16,
1021    sample_count: u32,
1022    previous_step_delta_x: i32,
1023    previous_step_delta_y: i32,
1024    start_time: Instant,
1025}
1026
1027/// Lifecycle phase observed while translating a terminal event.
1028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub enum PaneTerminalLifecyclePhase {
1030    MouseDown,
1031    MouseDrag,
1032    MouseMove,
1033    MouseUp,
1034    MouseScroll,
1035    KeyResize,
1036    KeyCancel,
1037    FocusLoss,
1038    ResizeInterrupt,
1039    Other,
1040}
1041
1042/// Deterministic reason a terminal event did not map to pane semantics.
1043#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1044pub enum PaneTerminalIgnoredReason {
1045    MissingTarget,
1046    NoActivePointer,
1047    PointerButtonMismatch,
1048    ActivationButtonRequired,
1049    WindowNotFocused,
1050    UnsupportedKey,
1051    FocusGainNoop,
1052    ResizeNoop,
1053    DragCoalesced,
1054    NonSemanticEvent,
1055    MachineRejectedEvent,
1056}
1057
1058/// Translation outcome for one raw terminal event.
1059#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1060pub enum PaneTerminalLogOutcome {
1061    SemanticForwarded,
1062    SemanticForwardedAfterRecovery,
1063    Ignored(PaneTerminalIgnoredReason),
1064}
1065
1066/// Structured translation log entry for one raw terminal event.
1067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1068pub struct PaneTerminalLogEntry {
1069    pub phase: PaneTerminalLifecyclePhase,
1070    pub sequence: Option<u64>,
1071    pub pointer_id: Option<u32>,
1072    pub target: Option<PaneResizeTarget>,
1073    pub recovery_cancel_sequence: Option<u64>,
1074    pub outcome: PaneTerminalLogOutcome,
1075}
1076
1077/// Output of one terminal event translation step.
1078///
1079/// `recovery_*` fields are populated when the adapter first emits an internal
1080/// cancel (for stale/missing mouse-up recovery) and then forwards the incoming
1081/// event as a fresh semantic event.
1082#[derive(Debug, Clone, PartialEq)]
1083pub struct PaneTerminalDispatch {
1084    pub primary_event: Option<PaneSemanticInputEvent>,
1085    pub primary_transition: Option<PaneDragResizeTransition>,
1086    pub motion: Option<PaneMotionVector>,
1087    pub inertial_throw: Option<PaneInertialThrow>,
1088    pub projected_position: Option<PanePointerPosition>,
1089    pub recovery_event: Option<PaneSemanticInputEvent>,
1090    pub recovery_transition: Option<PaneDragResizeTransition>,
1091    pub log: PaneTerminalLogEntry,
1092}
1093
1094impl PaneTerminalDispatch {
1095    fn ignored(
1096        phase: PaneTerminalLifecyclePhase,
1097        reason: PaneTerminalIgnoredReason,
1098        pointer_id: Option<u32>,
1099        target: Option<PaneResizeTarget>,
1100    ) -> Self {
1101        Self {
1102            primary_event: None,
1103            primary_transition: None,
1104            motion: None,
1105            inertial_throw: None,
1106            projected_position: None,
1107            recovery_event: None,
1108            recovery_transition: None,
1109            log: PaneTerminalLogEntry {
1110                phase,
1111                sequence: None,
1112                pointer_id,
1113                target,
1114                recovery_cancel_sequence: None,
1115                outcome: PaneTerminalLogOutcome::Ignored(reason),
1116            },
1117        }
1118    }
1119
1120    fn forwarded(
1121        phase: PaneTerminalLifecyclePhase,
1122        pointer_id: Option<u32>,
1123        target: Option<PaneResizeTarget>,
1124        event: PaneSemanticInputEvent,
1125        transition: PaneDragResizeTransition,
1126    ) -> Self {
1127        let sequence = Some(event.sequence);
1128        Self {
1129            primary_event: Some(event),
1130            primary_transition: Some(transition),
1131            motion: None,
1132            inertial_throw: None,
1133            projected_position: None,
1134            recovery_event: None,
1135            recovery_transition: None,
1136            log: PaneTerminalLogEntry {
1137                phase,
1138                sequence,
1139                pointer_id,
1140                target,
1141                recovery_cancel_sequence: None,
1142                outcome: PaneTerminalLogOutcome::SemanticForwarded,
1143            },
1144        }
1145    }
1146
1147    /// Derive dynamic snap profile from translated pointer motion.
1148    #[must_use]
1149    pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1150        self.motion.map(PanePressureSnapProfile::from_motion)
1151    }
1152}
1153
1154/// Deterministic terminal adapter mapping raw `Event` values into
1155/// schema-validated pane semantic interaction events.
1156#[derive(Debug, Clone)]
1157pub struct PaneTerminalAdapter {
1158    machine: PaneDragResizeMachine,
1159    config: PaneTerminalAdapterConfig,
1160    active: Option<PaneTerminalActivePointer>,
1161    window_focused: bool,
1162    next_sequence: u64,
1163}
1164
1165impl PaneTerminalAdapter {
1166    /// Construct a new adapter with validated drag thresholds.
1167    pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1168        let config = PaneTerminalAdapterConfig {
1169            drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1170            ..config
1171        };
1172        let machine = PaneDragResizeMachine::new_with_hysteresis(
1173            config.drag_threshold,
1174            config.update_hysteresis,
1175        )?;
1176        Ok(Self {
1177            machine,
1178            config,
1179            active: None,
1180            window_focused: true,
1181            next_sequence: 1,
1182        })
1183    }
1184
1185    /// Adapter configuration.
1186    #[must_use]
1187    pub const fn config(&self) -> PaneTerminalAdapterConfig {
1188        self.config
1189    }
1190
1191    /// Active pointer id currently tracked by the adapter, if any.
1192    #[must_use]
1193    pub fn active_pointer_id(&self) -> Option<u32> {
1194        self.active.map(|active| active.pointer_id)
1195    }
1196
1197    /// Whether the host window is currently focused.
1198    #[must_use]
1199    pub const fn window_focused(&self) -> bool {
1200        self.window_focused
1201    }
1202
1203    /// Current pane drag/resize machine state.
1204    #[must_use]
1205    pub const fn machine_state(&self) -> PaneDragResizeState {
1206        self.machine.state()
1207    }
1208
1209    /// Translate one raw terminal event into pane semantic event(s).
1210    ///
1211    /// `target_hint` is provided by host hit-testing (upcoming pane-terminal
1212    /// tasks). Pointer drag/move/up reuse active target continuity once armed.
1213    pub fn translate(
1214        &mut self,
1215        event: &Event,
1216        target_hint: Option<PaneResizeTarget>,
1217    ) -> PaneTerminalDispatch {
1218        match event {
1219            Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1220            Event::Key(key) => self.translate_key(*key, target_hint),
1221            Event::Focus(focused) => self.translate_focus(*focused),
1222            Event::Resize { .. } => self.translate_resize(),
1223            _ => PaneTerminalDispatch::ignored(
1224                PaneTerminalLifecyclePhase::Other,
1225                PaneTerminalIgnoredReason::NonSemanticEvent,
1226                None,
1227                target_hint,
1228            ),
1229        }
1230    }
1231
1232    /// Translate one raw terminal event while resolving splitter targets from
1233    /// terminal hit regions.
1234    ///
1235    /// This is a convenience wrapper for host code that already has splitter
1236    /// handle regions from [`pane_terminal_splitter_handles`].
1237    pub fn translate_with_handles(
1238        &mut self,
1239        event: &Event,
1240        handles: &[PaneTerminalSplitterHandle],
1241    ) -> PaneTerminalDispatch {
1242        let active_target = self.active.map(|active| active.target);
1243        let target_hint = match event {
1244            Event::Mouse(mouse) => {
1245                let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1246                match mouse.kind {
1247                    MouseEventKind::Down(_)
1248                    | MouseEventKind::ScrollUp
1249                    | MouseEventKind::ScrollDown
1250                    | MouseEventKind::ScrollLeft
1251                    | MouseEventKind::ScrollRight => resolved,
1252                    MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1253                        resolved.or(active_target)
1254                    }
1255                }
1256            }
1257            Event::Key(_) => active_target,
1258            _ => None,
1259        };
1260        self.translate(event, target_hint)
1261    }
1262
1263    fn translate_mouse(
1264        &mut self,
1265        mouse: MouseEvent,
1266        target_hint: Option<PaneResizeTarget>,
1267    ) -> PaneTerminalDispatch {
1268        let position = mouse_position(mouse);
1269        let modifiers = pane_modifiers(mouse.modifiers);
1270        match mouse.kind {
1271            MouseEventKind::Down(button) => {
1272                let pane_button = pane_button(button);
1273                if pane_button != self.config.activation_button {
1274                    return PaneTerminalDispatch::ignored(
1275                        PaneTerminalLifecyclePhase::MouseDown,
1276                        PaneTerminalIgnoredReason::ActivationButtonRequired,
1277                        Some(pointer_id_for_button(pane_button)),
1278                        target_hint,
1279                    );
1280                }
1281                let Some(target) = target_hint else {
1282                    return PaneTerminalDispatch::ignored(
1283                        PaneTerminalLifecyclePhase::MouseDown,
1284                        PaneTerminalIgnoredReason::MissingTarget,
1285                        Some(pointer_id_for_button(pane_button)),
1286                        None,
1287                    );
1288                };
1289
1290                let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1291                let pointer_id = pointer_id_for_button(pane_button);
1292                let kind = PaneSemanticInputEventKind::PointerDown {
1293                    target,
1294                    pointer_id,
1295                    button: pane_button,
1296                    position,
1297                };
1298                let mut dispatch = self.forward_semantic(
1299                    PaneTerminalLifecyclePhase::MouseDown,
1300                    Some(pointer_id),
1301                    Some(target),
1302                    kind,
1303                    modifiers,
1304                );
1305                if dispatch.primary_transition.is_some() {
1306                    self.active = Some(PaneTerminalActivePointer {
1307                        pointer_id,
1308                        target,
1309                        button: pane_button,
1310                        last_position: position,
1311                        cumulative_delta_x: 0,
1312                        cumulative_delta_y: 0,
1313                        direction_changes: 0,
1314                        sample_count: 0,
1315                        previous_step_delta_x: 0,
1316                        previous_step_delta_y: 0,
1317                        start_time: Instant::now(),
1318                    });
1319                }
1320                if let Some((cancel_event, cancel_transition)) = recovery {
1321                    dispatch.recovery_event = Some(cancel_event);
1322                    dispatch.recovery_transition = Some(cancel_transition);
1323                    dispatch.log.recovery_cancel_sequence =
1324                        dispatch.recovery_event.as_ref().map(|event| event.sequence);
1325                    if matches!(
1326                        dispatch.log.outcome,
1327                        PaneTerminalLogOutcome::SemanticForwarded
1328                    ) {
1329                        dispatch.log.outcome =
1330                            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1331                    }
1332                }
1333                dispatch
1334            }
1335            MouseEventKind::Drag(button) => {
1336                let pane_button = pane_button(button);
1337                let Some(mut active) = self.active else {
1338                    return PaneTerminalDispatch::ignored(
1339                        PaneTerminalLifecyclePhase::MouseDrag,
1340                        PaneTerminalIgnoredReason::NoActivePointer,
1341                        Some(pointer_id_for_button(pane_button)),
1342                        target_hint,
1343                    );
1344                };
1345                if active.button != pane_button {
1346                    return PaneTerminalDispatch::ignored(
1347                        PaneTerminalLifecyclePhase::MouseDrag,
1348                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1349                        Some(pointer_id_for_button(pane_button)),
1350                        Some(active.target),
1351                    );
1352                }
1353                let delta_x = position.x.saturating_sub(active.last_position.x);
1354                let delta_y = position.y.saturating_sub(active.last_position.y);
1355                if self.should_coalesce_drag(delta_x, delta_y) {
1356                    return PaneTerminalDispatch::ignored(
1357                        PaneTerminalLifecyclePhase::MouseDrag,
1358                        PaneTerminalIgnoredReason::DragCoalesced,
1359                        Some(active.pointer_id),
1360                        Some(active.target),
1361                    );
1362                }
1363                if active.sample_count > 0 {
1364                    let flipped_x = delta_x.signum() != 0
1365                        && active.previous_step_delta_x.signum() != 0
1366                        && delta_x.signum() != active.previous_step_delta_x.signum();
1367                    let flipped_y = delta_y.signum() != 0
1368                        && active.previous_step_delta_y.signum() != 0
1369                        && delta_y.signum() != active.previous_step_delta_y.signum();
1370                    if flipped_x || flipped_y {
1371                        active.direction_changes = active.direction_changes.saturating_add(1);
1372                    }
1373                }
1374                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1375                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1376                active.sample_count = active.sample_count.saturating_add(1);
1377                active.previous_step_delta_x = delta_x;
1378                active.previous_step_delta_y = delta_y;
1379                let kind = PaneSemanticInputEventKind::PointerMove {
1380                    target: active.target,
1381                    pointer_id: active.pointer_id,
1382                    position,
1383                    delta_x,
1384                    delta_y,
1385                };
1386                let mut dispatch = self.forward_semantic(
1387                    PaneTerminalLifecyclePhase::MouseDrag,
1388                    Some(active.pointer_id),
1389                    Some(active.target),
1390                    kind,
1391                    modifiers,
1392                );
1393                if dispatch.primary_transition.is_some() {
1394                    active.last_position = position;
1395                    self.active = Some(active);
1396                    let duration = active.start_time.elapsed().as_millis() as u32;
1397                    dispatch.motion = Some(PaneMotionVector::from_delta(
1398                        active.cumulative_delta_x,
1399                        active.cumulative_delta_y,
1400                        duration,
1401                        active.direction_changes,
1402                    ));
1403                }
1404                dispatch
1405            }
1406            MouseEventKind::Moved => {
1407                let Some(mut active) = self.active else {
1408                    return PaneTerminalDispatch::ignored(
1409                        PaneTerminalLifecyclePhase::MouseMove,
1410                        PaneTerminalIgnoredReason::NoActivePointer,
1411                        None,
1412                        target_hint,
1413                    );
1414                };
1415                let delta_x = position.x.saturating_sub(active.last_position.x);
1416                let delta_y = position.y.saturating_sub(active.last_position.y);
1417                if self.should_coalesce_drag(delta_x, delta_y) {
1418                    return PaneTerminalDispatch::ignored(
1419                        PaneTerminalLifecyclePhase::MouseMove,
1420                        PaneTerminalIgnoredReason::DragCoalesced,
1421                        Some(active.pointer_id),
1422                        Some(active.target),
1423                    );
1424                }
1425                if active.sample_count > 0 {
1426                    let flipped_x = delta_x.signum() != 0
1427                        && active.previous_step_delta_x.signum() != 0
1428                        && delta_x.signum() != active.previous_step_delta_x.signum();
1429                    let flipped_y = delta_y.signum() != 0
1430                        && active.previous_step_delta_y.signum() != 0
1431                        && delta_y.signum() != active.previous_step_delta_y.signum();
1432                    if flipped_x || flipped_y {
1433                        active.direction_changes = active.direction_changes.saturating_add(1);
1434                    }
1435                }
1436                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1437                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1438                active.sample_count = active.sample_count.saturating_add(1);
1439                active.previous_step_delta_x = delta_x;
1440                active.previous_step_delta_y = delta_y;
1441                let kind = PaneSemanticInputEventKind::PointerMove {
1442                    target: active.target,
1443                    pointer_id: active.pointer_id,
1444                    position,
1445                    delta_x,
1446                    delta_y,
1447                };
1448                let mut dispatch = self.forward_semantic(
1449                    PaneTerminalLifecyclePhase::MouseMove,
1450                    Some(active.pointer_id),
1451                    Some(active.target),
1452                    kind,
1453                    modifiers,
1454                );
1455                if dispatch.primary_transition.is_some() {
1456                    active.last_position = position;
1457                    self.active = Some(active);
1458                    let duration = active.start_time.elapsed().as_millis() as u32;
1459                    dispatch.motion = Some(PaneMotionVector::from_delta(
1460                        active.cumulative_delta_x,
1461                        active.cumulative_delta_y,
1462                        duration,
1463                        active.direction_changes,
1464                    ));
1465                }
1466                dispatch
1467            }
1468            MouseEventKind::Up(button) => {
1469                let pane_button = pane_button(button);
1470                let Some(active) = self.active else {
1471                    return PaneTerminalDispatch::ignored(
1472                        PaneTerminalLifecyclePhase::MouseUp,
1473                        PaneTerminalIgnoredReason::NoActivePointer,
1474                        Some(pointer_id_for_button(pane_button)),
1475                        target_hint,
1476                    );
1477                };
1478                if active.button != pane_button {
1479                    return PaneTerminalDispatch::ignored(
1480                        PaneTerminalLifecyclePhase::MouseUp,
1481                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1482                        Some(pointer_id_for_button(pane_button)),
1483                        Some(active.target),
1484                    );
1485                }
1486                let kind = PaneSemanticInputEventKind::PointerUp {
1487                    target: active.target,
1488                    pointer_id: active.pointer_id,
1489                    button: active.button,
1490                    position,
1491                };
1492                let mut dispatch = self.forward_semantic(
1493                    PaneTerminalLifecyclePhase::MouseUp,
1494                    Some(active.pointer_id),
1495                    Some(active.target),
1496                    kind,
1497                    modifiers,
1498                );
1499                if dispatch.primary_transition.is_some() {
1500                    let duration = active.start_time.elapsed().as_millis() as u32;
1501                    let motion = PaneMotionVector::from_delta(
1502                        active.cumulative_delta_x,
1503                        active.cumulative_delta_y,
1504                        duration,
1505                        active.direction_changes,
1506                    );
1507                    let inertial_throw = PaneInertialThrow::from_motion(motion);
1508                    dispatch.motion = Some(motion);
1509                    dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1510                    dispatch.inertial_throw = Some(inertial_throw);
1511                    self.active = None;
1512                }
1513                dispatch
1514            }
1515            MouseEventKind::ScrollUp
1516            | MouseEventKind::ScrollDown
1517            | MouseEventKind::ScrollLeft
1518            | MouseEventKind::ScrollRight => {
1519                let target = target_hint.or(self.active.map(|active| active.target));
1520                let Some(target) = target else {
1521                    return PaneTerminalDispatch::ignored(
1522                        PaneTerminalLifecyclePhase::MouseScroll,
1523                        PaneTerminalIgnoredReason::MissingTarget,
1524                        None,
1525                        None,
1526                    );
1527                };
1528                let lines = match mouse.kind {
1529                    MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1530                    MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1531                    _ => unreachable!("handled by outer match"),
1532                };
1533                let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1534                self.forward_semantic(
1535                    PaneTerminalLifecyclePhase::MouseScroll,
1536                    None,
1537                    Some(target),
1538                    kind,
1539                    modifiers,
1540                )
1541            }
1542        }
1543    }
1544
1545    fn translate_key(
1546        &mut self,
1547        key: KeyEvent,
1548        target_hint: Option<PaneResizeTarget>,
1549    ) -> PaneTerminalDispatch {
1550        if !self.window_focused {
1551            return PaneTerminalDispatch::ignored(
1552                PaneTerminalLifecyclePhase::KeyResize,
1553                PaneTerminalIgnoredReason::WindowNotFocused,
1554                self.active_pointer_id(),
1555                target_hint.or(self.active.map(|active| active.target)),
1556            );
1557        }
1558        if key.kind == KeyEventKind::Release {
1559            return PaneTerminalDispatch::ignored(
1560                PaneTerminalLifecyclePhase::Other,
1561                PaneTerminalIgnoredReason::UnsupportedKey,
1562                None,
1563                target_hint,
1564            );
1565        }
1566        if matches!(key.code, KeyCode::Escape) {
1567            return self.cancel_active_dispatch(
1568                PaneTerminalLifecyclePhase::KeyCancel,
1569                PaneCancelReason::EscapeKey,
1570                PaneTerminalIgnoredReason::NoActivePointer,
1571            );
1572        }
1573        let target = target_hint.or(self.active.map(|active| active.target));
1574        let Some(target) = target else {
1575            return PaneTerminalDispatch::ignored(
1576                PaneTerminalLifecyclePhase::KeyResize,
1577                PaneTerminalIgnoredReason::MissingTarget,
1578                None,
1579                None,
1580            );
1581        };
1582        let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1583            return PaneTerminalDispatch::ignored(
1584                PaneTerminalLifecyclePhase::KeyResize,
1585                PaneTerminalIgnoredReason::UnsupportedKey,
1586                None,
1587                Some(target),
1588            );
1589        };
1590        let units = keyboard_resize_units(key.modifiers);
1591        let kind = PaneSemanticInputEventKind::KeyboardResize {
1592            target,
1593            direction,
1594            units,
1595        };
1596        self.forward_semantic(
1597            PaneTerminalLifecyclePhase::KeyResize,
1598            self.active_pointer_id(),
1599            Some(target),
1600            kind,
1601            pane_modifiers(key.modifiers),
1602        )
1603    }
1604
1605    fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1606        if focused {
1607            self.window_focused = true;
1608            return PaneTerminalDispatch::ignored(
1609                PaneTerminalLifecyclePhase::Other,
1610                PaneTerminalIgnoredReason::FocusGainNoop,
1611                self.active_pointer_id(),
1612                self.active.map(|active| active.target),
1613            );
1614        }
1615        self.window_focused = false;
1616        if !self.config.cancel_on_focus_lost {
1617            return PaneTerminalDispatch::ignored(
1618                PaneTerminalLifecyclePhase::FocusLoss,
1619                PaneTerminalIgnoredReason::ResizeNoop,
1620                self.active_pointer_id(),
1621                self.active.map(|active| active.target),
1622            );
1623        }
1624        self.cancel_active_dispatch(
1625            PaneTerminalLifecyclePhase::FocusLoss,
1626            PaneCancelReason::FocusLost,
1627            PaneTerminalIgnoredReason::NoActivePointer,
1628        )
1629    }
1630
1631    fn translate_resize(&mut self) -> PaneTerminalDispatch {
1632        if !self.config.cancel_on_resize {
1633            return PaneTerminalDispatch::ignored(
1634                PaneTerminalLifecyclePhase::ResizeInterrupt,
1635                PaneTerminalIgnoredReason::ResizeNoop,
1636                self.active_pointer_id(),
1637                self.active.map(|active| active.target),
1638            );
1639        }
1640        self.cancel_active_dispatch(
1641            PaneTerminalLifecyclePhase::ResizeInterrupt,
1642            PaneCancelReason::Programmatic,
1643            PaneTerminalIgnoredReason::ResizeNoop,
1644        )
1645    }
1646
1647    fn cancel_active_dispatch(
1648        &mut self,
1649        phase: PaneTerminalLifecyclePhase,
1650        reason: PaneCancelReason,
1651        no_active_reason: PaneTerminalIgnoredReason,
1652    ) -> PaneTerminalDispatch {
1653        let Some(active) = self.active else {
1654            return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1655        };
1656        let kind = PaneSemanticInputEventKind::Cancel {
1657            target: Some(active.target),
1658            reason,
1659        };
1660        let dispatch = self.forward_semantic(
1661            phase,
1662            Some(active.pointer_id),
1663            Some(active.target),
1664            kind,
1665            PaneModifierSnapshot::default(),
1666        );
1667        if dispatch.primary_transition.is_some() {
1668            self.active = None;
1669        }
1670        dispatch
1671    }
1672
1673    fn cancel_active_internal(
1674        &mut self,
1675        reason: PaneCancelReason,
1676    ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1677        let active = self.active?;
1678        let kind = PaneSemanticInputEventKind::Cancel {
1679            target: Some(active.target),
1680            reason,
1681        };
1682        let result = self
1683            .apply_semantic(kind, PaneModifierSnapshot::default())
1684            .ok();
1685        if result.is_some() {
1686            self.active = None;
1687        }
1688        result
1689    }
1690
1691    fn forward_semantic(
1692        &mut self,
1693        phase: PaneTerminalLifecyclePhase,
1694        pointer_id: Option<u32>,
1695        target: Option<PaneResizeTarget>,
1696        kind: PaneSemanticInputEventKind,
1697        modifiers: PaneModifierSnapshot,
1698    ) -> PaneTerminalDispatch {
1699        match self.apply_semantic(kind, modifiers) {
1700            Ok((event, transition)) => {
1701                PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1702            }
1703            Err(_) => PaneTerminalDispatch::ignored(
1704                phase,
1705                PaneTerminalIgnoredReason::MachineRejectedEvent,
1706                pointer_id,
1707                target,
1708            ),
1709        }
1710    }
1711
1712    fn apply_semantic(
1713        &mut self,
1714        kind: PaneSemanticInputEventKind,
1715        modifiers: PaneModifierSnapshot,
1716    ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1717    {
1718        let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1719        event.modifiers = modifiers;
1720        let transition = self.machine.apply_event(&event)?;
1721        Ok((event, transition))
1722    }
1723
1724    fn next_sequence(&mut self) -> u64 {
1725        let sequence = self.next_sequence;
1726        self.next_sequence = self.next_sequence.saturating_add(1);
1727        sequence
1728    }
1729
1730    fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1731        if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1732            return false;
1733        }
1734        let movement = delta_x
1735            .unsigned_abs()
1736            .saturating_add(delta_y.unsigned_abs());
1737        movement < u32::from(self.config.drag_update_coalesce_distance)
1738    }
1739
1740    /// Force-cancel any active pane interaction and return diagnostic info.
1741    ///
1742    /// This is the safety-valve for cleanup paths (RAII guard drops, signal
1743    /// handlers, panic hooks) where constructing a proper semantic event is
1744    /// not feasible. It resets both the underlying drag/resize state machine
1745    /// and the adapter's active-pointer tracking.
1746    ///
1747    /// Returns `None` if no interaction was active.
1748    pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1749        let was_active = self.active.is_some();
1750        let machine_state_before = self.machine.state();
1751        let machine_transition = self.machine.force_cancel();
1752        let active_pointer = self.active.take();
1753        if !was_active && machine_transition.is_none() {
1754            return None;
1755        }
1756        Some(PaneCleanupDiagnostics {
1757            had_active_pointer: was_active,
1758            active_pointer_id: active_pointer.map(|a| a.pointer_id),
1759            machine_state_before,
1760            machine_transition,
1761        })
1762    }
1763}
1764
1765/// Structured diagnostics emitted when pane interaction state is force-cleaned.
1766///
1767/// Fields mirror the pane layout types which are already `Serialize`/`Deserialize`,
1768/// so callers can convert this struct to JSON for evidence logging.
1769#[derive(Debug, Clone, PartialEq, Eq)]
1770pub struct PaneCleanupDiagnostics {
1771    /// Whether the adapter had an active pointer tracker when cleanup ran.
1772    pub had_active_pointer: bool,
1773    /// The pointer ID that was active (if any).
1774    pub active_pointer_id: Option<u32>,
1775    /// The machine state before force-cancel was applied.
1776    pub machine_state_before: PaneDragResizeState,
1777    /// The transition produced by force-cancel, or `None` if the machine
1778    /// was already idle.
1779    pub machine_transition: Option<PaneDragResizeTransition>,
1780}
1781
1782/// RAII guard that ensures pane interaction state is cleanly canceled on drop.
1783///
1784/// When a pane interaction session is active and the guard drops (due to
1785/// panic, scope exit, or any other unwind), it force-cancels any in-progress
1786/// drag/resize and collects cleanup diagnostics.
1787///
1788/// # Usage
1789///
1790/// ```ignore
1791/// let guard = PaneInteractionGuard::new(&mut adapter);
1792/// // ... pane interaction event loop ...
1793/// // If this scope panics, guard's Drop will force-cancel the drag machine
1794/// let diagnostics = guard.finish(); // explicit clean finish
1795/// ```
1796pub struct PaneInteractionGuard<'a> {
1797    adapter: &'a mut PaneTerminalAdapter,
1798    finished: bool,
1799    diagnostics: Option<PaneCleanupDiagnostics>,
1800}
1801
1802impl<'a> PaneInteractionGuard<'a> {
1803    /// Create a new guard wrapping the given adapter.
1804    pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1805        Self {
1806            adapter,
1807            finished: false,
1808            diagnostics: None,
1809        }
1810    }
1811
1812    /// Access the wrapped adapter for normal event translation.
1813    pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1814        self.adapter
1815    }
1816
1817    /// Explicitly finish the guard, returning any cleanup diagnostics.
1818    ///
1819    /// Calling `finish()` is optional — the guard will also clean up on drop.
1820    /// However, `finish()` gives the caller access to the diagnostics.
1821    pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1822        self.finished = true;
1823        let diagnostics = self.adapter.force_cancel_all();
1824        self.diagnostics = diagnostics.clone();
1825        diagnostics
1826    }
1827}
1828
1829impl Drop for PaneInteractionGuard<'_> {
1830    fn drop(&mut self) {
1831        if !self.finished {
1832            self.diagnostics = self.adapter.force_cancel_all();
1833        }
1834    }
1835}
1836
1837fn pane_button(button: MouseButton) -> PanePointerButton {
1838    match button {
1839        MouseButton::Left => PanePointerButton::Primary,
1840        MouseButton::Right => PanePointerButton::Secondary,
1841        MouseButton::Middle => PanePointerButton::Middle,
1842    }
1843}
1844
1845fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1846    match button {
1847        PanePointerButton::Primary => 1,
1848        PanePointerButton::Secondary => 2,
1849        PanePointerButton::Middle => 3,
1850    }
1851}
1852
1853fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1854    PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1855}
1856
1857fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1858    PaneModifierSnapshot {
1859        shift: modifiers.contains(Modifiers::SHIFT),
1860        alt: modifiers.contains(Modifiers::ALT),
1861        ctrl: modifiers.contains(Modifiers::CTRL),
1862        meta: modifiers.contains(Modifiers::SUPER),
1863    }
1864}
1865
1866fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1867    match (axis, code) {
1868        (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1869        (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1870        (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1871        (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1872        (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1873        (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1874        _ => None,
1875    }
1876}
1877
1878fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1879    if modifiers.contains(Modifiers::SHIFT) {
1880        5
1881    } else {
1882        1
1883    }
1884}
1885
1886/// Configuration for state persistence in the program runtime.
1887///
1888/// Controls when and how widget state is saved/restored.
1889#[derive(Clone)]
1890pub struct PersistenceConfig {
1891    /// State registry for persistence. If None, persistence is disabled.
1892    pub registry: Option<std::sync::Arc<StateRegistry>>,
1893    /// Interval for periodic checkpoint saves. None disables checkpoints.
1894    pub checkpoint_interval: Option<Duration>,
1895    /// Automatically load state on program start.
1896    pub auto_load: bool,
1897    /// Automatically save state on program exit.
1898    pub auto_save: bool,
1899}
1900
1901impl Default for PersistenceConfig {
1902    fn default() -> Self {
1903        Self {
1904            registry: None,
1905            checkpoint_interval: None,
1906            auto_load: true,
1907            auto_save: true,
1908        }
1909    }
1910}
1911
1912impl std::fmt::Debug for PersistenceConfig {
1913    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1914        f.debug_struct("PersistenceConfig")
1915            .field(
1916                "registry",
1917                &self.registry.as_ref().map(|r| r.backend_name()),
1918            )
1919            .field("checkpoint_interval", &self.checkpoint_interval)
1920            .field("auto_load", &self.auto_load)
1921            .field("auto_save", &self.auto_save)
1922            .finish()
1923    }
1924}
1925
1926impl PersistenceConfig {
1927    /// Create a disabled persistence config.
1928    #[must_use]
1929    pub fn disabled() -> Self {
1930        Self::default()
1931    }
1932
1933    /// Create a persistence config with the given registry.
1934    #[must_use]
1935    pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1936        Self {
1937            registry: Some(registry),
1938            ..Default::default()
1939        }
1940    }
1941
1942    /// Set the checkpoint interval.
1943    #[must_use]
1944    pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1945        self.checkpoint_interval = Some(interval);
1946        self
1947    }
1948
1949    /// Enable or disable auto-load on start.
1950    #[must_use]
1951    pub fn auto_load(mut self, enabled: bool) -> Self {
1952        self.auto_load = enabled;
1953        self
1954    }
1955
1956    /// Enable or disable auto-save on exit.
1957    #[must_use]
1958    pub fn auto_save(mut self, enabled: bool) -> Self {
1959        self.auto_save = enabled;
1960        self
1961    }
1962}
1963
1964/// Configuration for widget refresh selection under render budget.
1965///
1966/// Defaults are conservative and deterministic:
1967/// - enabled: true
1968/// - staleness_window_ms: 1_000
1969/// - starve_ms: 3_000
1970/// - max_starved_per_frame: 2
1971/// - max_drop_fraction: 1.0 (disabled)
1972/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
1973/// - starve_boost: 1.5
1974/// - min_cost_us: 1.0
1975#[derive(Debug, Clone)]
1976pub struct WidgetRefreshConfig {
1977    /// Enable budgeted widget refresh selection.
1978    pub enabled: bool,
1979    /// Staleness decay window (ms) used to normalize staleness scores.
1980    pub staleness_window_ms: u64,
1981    /// Staleness threshold that triggers starvation guard (ms).
1982    pub starve_ms: u64,
1983    /// Maximum number of starved widgets to force in per frame.
1984    pub max_starved_per_frame: usize,
1985    /// Maximum fraction of non-essential widgets that may be dropped.
1986    /// Set to 1.0 to disable the guardrail.
1987    pub max_drop_fraction: f32,
1988    /// Weight for base priority signal.
1989    pub weight_priority: f32,
1990    /// Weight for staleness signal.
1991    pub weight_staleness: f32,
1992    /// Weight for focus boost.
1993    pub weight_focus: f32,
1994    /// Weight for interaction boost.
1995    pub weight_interaction: f32,
1996    /// Additive boost to value for starved widgets.
1997    pub starve_boost: f32,
1998    /// Minimum cost (us) to avoid divide-by-zero.
1999    pub min_cost_us: f32,
2000}
2001
2002impl Default for WidgetRefreshConfig {
2003    fn default() -> Self {
2004        Self {
2005            enabled: true,
2006            staleness_window_ms: 1_000,
2007            starve_ms: 3_000,
2008            max_starved_per_frame: 2,
2009            max_drop_fraction: 1.0,
2010            weight_priority: 1.0,
2011            weight_staleness: 0.5,
2012            weight_focus: 0.75,
2013            weight_interaction: 0.5,
2014            starve_boost: 1.5,
2015            min_cost_us: 1.0,
2016        }
2017    }
2018}
2019
2020/// Configuration for effect queue scheduling.
2021#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2022pub enum TaskExecutorBackend {
2023    /// Spawn one native thread per task and reap finished handles on the main loop.
2024    #[default]
2025    Spawned,
2026    /// Route tasks through the runtime's queueing scheduler.
2027    EffectQueue,
2028    /// Route blocking task closures through an Asupersync blocking pool.
2029    #[cfg(feature = "asupersync-executor")]
2030    Asupersync,
2031}
2032
2033#[derive(Debug, Clone)]
2034pub struct EffectQueueConfig {
2035    /// Whether effect queue scheduling is enabled.
2036    ///
2037    /// This legacy convenience flag is kept in sync with `backend`. New code
2038    /// should prefer `backend` for executor selection.
2039    pub enabled: bool,
2040    /// Which task executor backend to use for `Cmd::Task`.
2041    pub backend: TaskExecutorBackend,
2042    /// Scheduler configuration (Smith's rule by default).
2043    pub scheduler: SchedulerConfig,
2044    /// Maximum queue depth before backpressure kicks in (bd-2zd0a).
2045    ///
2046    /// When the queue depth exceeds this limit, new tasks are dropped with
2047    /// a `tracing::warn!` and the `effects_queue_dropped` counter increments.
2048    /// A value of `0` means unbounded (no backpressure).
2049    pub max_queue_depth: usize,
2050    /// Whether the backend selection was set explicitly by the caller.
2051    explicit_backend: bool,
2052}
2053
2054impl Default for EffectQueueConfig {
2055    fn default() -> Self {
2056        let scheduler = SchedulerConfig {
2057            smith_enabled: true,
2058            force_fifo: false,
2059            preemptive: false,
2060            aging_factor: 0.0,
2061            wait_starve_ms: 0.0,
2062            enable_logging: false,
2063            ..Default::default()
2064        };
2065        Self {
2066            enabled: false,
2067            backend: TaskExecutorBackend::Spawned,
2068            scheduler,
2069            max_queue_depth: 0,
2070            explicit_backend: false,
2071        }
2072    }
2073}
2074
2075impl EffectQueueConfig {
2076    /// Enable effect queue scheduling with the provided scheduler config.
2077    #[must_use]
2078    pub fn with_enabled(mut self, enabled: bool) -> Self {
2079        self.enabled = enabled;
2080        self.backend = if enabled {
2081            TaskExecutorBackend::EffectQueue
2082        } else {
2083            TaskExecutorBackend::Spawned
2084        };
2085        self.explicit_backend = true;
2086        self
2087    }
2088
2089    /// Select the task executor backend for `Cmd::Task`.
2090    #[must_use]
2091    pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2092        self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2093        self.backend = backend;
2094        self.explicit_backend = true;
2095        self
2096    }
2097
2098    /// Override the scheduler configuration.
2099    #[must_use]
2100    pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2101        self.scheduler = scheduler;
2102        self
2103    }
2104
2105    /// Set the maximum queue depth for backpressure (bd-2zd0a).
2106    ///
2107    /// When the queue depth exceeds this limit, new tasks are dropped.
2108    /// A value of `0` means unbounded (no backpressure, the default).
2109    #[must_use]
2110    pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2111        self.max_queue_depth = depth;
2112        self
2113    }
2114
2115    #[must_use]
2116    fn uses_legacy_default_backend(&self) -> bool {
2117        !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2118    }
2119}
2120
2121/// Immediate event-drain policy for the runtime main loop.
2122///
2123/// When a poll reports readiness, the runtime drains events by repeatedly
2124/// checking `poll_event(Duration::ZERO)` to avoid latency between buffered
2125/// inputs. This policy bounds that immediate-drain path so bursty workloads do
2126/// not devolve into zero-timeout spin storms.
2127#[derive(Debug, Clone)]
2128pub struct ImmediateDrainConfig {
2129    /// Maximum consecutive zero-timeout polls allowed in a single burst window.
2130    pub max_zero_timeout_polls_per_burst: usize,
2131    /// Maximum wall-clock time spent in a single immediate-drain burst window.
2132    pub max_burst_duration: Duration,
2133    /// Non-zero poll timeout used when the burst window budget is exhausted.
2134    pub backoff_timeout: Duration,
2135}
2136
2137impl Default for ImmediateDrainConfig {
2138    fn default() -> Self {
2139        Self {
2140            max_zero_timeout_polls_per_burst: 64,
2141            max_burst_duration: Duration::from_millis(2),
2142            backoff_timeout: Duration::from_millis(1),
2143        }
2144    }
2145}
2146
2147/// Runtime counters for immediate-drain behavior.
2148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2149pub struct ImmediateDrainStats {
2150    /// Number of event-drain bursts observed.
2151    pub bursts: u64,
2152    /// Total zero-timeout polls executed (`poll_event(Duration::ZERO)`).
2153    pub zero_timeout_polls: u64,
2154    /// Total non-zero backoff polls executed after exhausting burst budget.
2155    pub backoff_polls: u64,
2156    /// Number of bursts that hit the configured immediate-drain cap.
2157    pub capped_bursts: u64,
2158    /// Max number of zero-timeout polls seen in a single burst window.
2159    pub max_zero_timeout_polls_in_burst: u64,
2160}
2161
2162/// Conservative runtime load-governor configuration.
2163///
2164/// The first runtime governor uses a single primary control lever: adaptive
2165/// render degradation driven by measured frame times. The controller combines
2166/// hysteresis, cooldown, and e-process evidence gates inside `RenderBudget`,
2167/// and the legacy threshold path remains available as the safe fallback by
2168/// disabling this config.
2169#[derive(Debug, Clone, PartialEq)]
2170pub struct LoadGovernorConfig {
2171    /// Whether the adaptive governor is active.
2172    pub enabled: bool,
2173    /// Controller used to decide degrade/upgrade transitions from frame timing.
2174    pub budget_controller: BudgetControllerConfig,
2175}
2176
2177impl Default for LoadGovernorConfig {
2178    fn default() -> Self {
2179        Self {
2180            enabled: true,
2181            budget_controller: BudgetControllerConfig::default(),
2182        }
2183    }
2184}
2185
2186impl LoadGovernorConfig {
2187    /// Create an enabled governor with conservative defaults.
2188    #[must_use]
2189    pub fn enabled() -> Self {
2190        Self::default()
2191    }
2192
2193    /// Disable the governor and use the legacy render-budget threshold path.
2194    #[must_use]
2195    pub fn disabled() -> Self {
2196        Self {
2197            enabled: false,
2198            budget_controller: BudgetControllerConfig::default(),
2199        }
2200    }
2201
2202    /// Toggle governor activation.
2203    #[must_use]
2204    pub fn with_enabled(mut self, enabled: bool) -> Self {
2205        self.enabled = enabled;
2206        self
2207    }
2208
2209    /// Replace the adaptive budget controller configuration.
2210    #[must_use]
2211    pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
2212        self.budget_controller = config;
2213        self
2214    }
2215}
2216
2217/// Runtime lane for the Asupersync migration rollout.
2218///
2219/// Controls which subscription/effect execution backend is active.
2220/// The default is `Structured`, reflecting the completed CancellationToken migration (bd-3tmu4).
2221///
2222/// # Migration rollout
2223///
2224/// 1. `Legacy` — pre-migration thread-based subscriptions with manual stop coordination
2225/// 2. `Structured` — CancellationToken-backed subscriptions (current default after bd-3tmu4)
2226/// 3. `Asupersync` — full Asupersync-native execution (future)
2227///
2228/// Selection is logged at startup so operators can tell which lane is active.
2229/// Fallback from `Asupersync` → `Structured` → `Legacy` is automatic on error.
2230#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2231pub enum RuntimeLane {
2232    /// Pre-migration behavior: thread-based subscriptions with manual stop coordination.
2233    /// This is the safe default that preserves all existing semantics.
2234    Legacy,
2235    /// Structured cancellation: subscriptions use CancellationToken internally.
2236    /// Externally observable behavior is identical to Legacy.
2237    #[default]
2238    Structured,
2239    /// Full Asupersync-native execution (reserved for future use).
2240    /// Falls back to Structured if Asupersync primitives are unavailable.
2241    Asupersync,
2242}
2243
2244impl RuntimeLane {
2245    /// Resolve the effective lane, applying fallback rules.
2246    ///
2247    /// If the requested lane is not yet implemented, falls back to the
2248    /// highest available lane. Currently: Asupersync → Structured.
2249    #[must_use]
2250    pub fn resolve(self) -> Self {
2251        match self {
2252            Self::Asupersync => {
2253                tracing::info!(
2254                    target: "ftui.runtime",
2255                    requested = "asupersync",
2256                    resolved = "structured",
2257                    "Asupersync lane not yet available; falling back to structured cancellation"
2258                );
2259                Self::Structured
2260            }
2261            other => other,
2262        }
2263    }
2264
2265    /// Returns a human-readable label for logging.
2266    #[must_use]
2267    pub fn label(self) -> &'static str {
2268        match self {
2269            Self::Legacy => "legacy",
2270            Self::Structured => "structured",
2271            Self::Asupersync => "asupersync",
2272        }
2273    }
2274
2275    /// Check if this lane uses structured cancellation (CancellationToken).
2276    #[must_use]
2277    pub fn uses_structured_cancellation(self) -> bool {
2278        matches!(self, Self::Structured | Self::Asupersync)
2279    }
2280
2281    /// Resolve the default task executor backend for this lane.
2282    #[must_use]
2283    fn task_executor_backend(self) -> TaskExecutorBackend {
2284        match self {
2285            Self::Legacy => TaskExecutorBackend::Spawned,
2286            Self::Structured => TaskExecutorBackend::EffectQueue,
2287            Self::Asupersync => {
2288                #[cfg(feature = "asupersync-executor")]
2289                {
2290                    TaskExecutorBackend::Asupersync
2291                }
2292                #[cfg(not(feature = "asupersync-executor"))]
2293                {
2294                    TaskExecutorBackend::EffectQueue
2295                }
2296            }
2297        }
2298    }
2299
2300    /// Read the lane from the `FTUI_RUNTIME_LANE` environment variable.
2301    ///
2302    /// Accepted values (case-insensitive): `legacy`, `structured`, `asupersync`.
2303    /// Returns `None` if the variable is unset or contains an unrecognized value.
2304    #[must_use]
2305    pub fn from_env() -> Option<Self> {
2306        let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2307        Self::parse(&val)
2308    }
2309
2310    /// Parse a lane name (case-insensitive).
2311    ///
2312    /// Returns `None` for unrecognized values.
2313    #[must_use]
2314    pub fn parse(s: &str) -> Option<Self> {
2315        match s.to_ascii_lowercase().as_str() {
2316            "legacy" => Some(Self::Legacy),
2317            "structured" => Some(Self::Structured),
2318            "asupersync" => Some(Self::Asupersync),
2319            _ => {
2320                tracing::warn!(
2321                    target: "ftui.runtime",
2322                    value = s,
2323                    "RuntimeLane::parse: unrecognized value"
2324                );
2325                None
2326            }
2327        }
2328    }
2329}
2330
2331/// Rollout policy for the Asupersync migration (bd-2crbt).
2332///
2333/// Controls how the runtime lane transition is managed:
2334///
2335/// - `Off` — use only the configured lane, no shadow comparison.
2336/// - `Shadow` — run both baseline and candidate lanes, compare outputs,
2337///   but use only the baseline lane for actual rendering. Evidence is emitted
2338///   to the configured JSONL sink for operator review.
2339/// - `Enabled` — use the candidate lane for rendering (requires prior shadow
2340///   evidence showing deterministic match).
2341///
2342/// The policy is logged at startup and can be overridden via the
2343/// `FTUI_ROLLOUT_POLICY` environment variable (`off`, `shadow`, `enabled`).
2344#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2345pub enum RolloutPolicy {
2346    /// No rollout activity — use the configured lane directly.
2347    #[default]
2348    Off,
2349    /// Shadow-run comparison mode: run both lanes, emit evidence, use baseline.
2350    Shadow,
2351    /// Candidate lane is live — requires prior shadow evidence.
2352    Enabled,
2353}
2354
2355impl RolloutPolicy {
2356    /// Read the policy from the `FTUI_ROLLOUT_POLICY` environment variable.
2357    ///
2358    /// Accepted values (case-insensitive): `off`, `shadow`, `enabled`.
2359    /// Returns `None` if unset or unrecognized.
2360    #[must_use]
2361    pub fn from_env() -> Option<Self> {
2362        let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2363        Self::parse(&val)
2364    }
2365
2366    /// Parse a rollout policy name (case-insensitive).
2367    ///
2368    /// Returns `None` for unrecognized values.
2369    #[must_use]
2370    pub fn parse(s: &str) -> Option<Self> {
2371        match s.to_ascii_lowercase().as_str() {
2372            "off" => Some(Self::Off),
2373            "shadow" => Some(Self::Shadow),
2374            "enabled" => Some(Self::Enabled),
2375            _ => {
2376                tracing::warn!(
2377                    target: "ftui.runtime",
2378                    value = s,
2379                    "RolloutPolicy::parse: unrecognized value"
2380                );
2381                None
2382            }
2383        }
2384    }
2385
2386    /// Returns a human-readable label for logging.
2387    #[must_use]
2388    pub fn label(self) -> &'static str {
2389        match self {
2390            Self::Off => "off",
2391            Self::Shadow => "shadow",
2392            Self::Enabled => "enabled",
2393        }
2394    }
2395
2396    /// Whether this policy involves shadow comparison.
2397    #[must_use]
2398    pub fn is_shadow(self) -> bool {
2399        matches!(self, Self::Shadow)
2400    }
2401}
2402
2403impl std::fmt::Display for RolloutPolicy {
2404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2405        f.write_str(self.label())
2406    }
2407}
2408
2409impl std::fmt::Display for RuntimeLane {
2410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2411        f.write_str(self.label())
2412    }
2413}
2414
2415/// Configuration for the program runtime.
2416#[derive(Debug, Clone)]
2417pub struct ProgramConfig {
2418    /// Screen mode (inline or alternate screen).
2419    pub screen_mode: ScreenMode,
2420    /// UI anchor for inline mode.
2421    pub ui_anchor: UiAnchor,
2422    /// Frame budget configuration.
2423    pub budget: FrameBudgetConfig,
2424    /// Runtime load-governor configuration.
2425    pub load_governor: LoadGovernorConfig,
2426    /// Diff strategy configuration for the terminal writer.
2427    pub diff_config: RuntimeDiffConfig,
2428    /// Evidence JSONL sink configuration.
2429    pub evidence_sink: EvidenceSinkConfig,
2430    /// Render-trace recorder configuration.
2431    pub render_trace: RenderTraceConfig,
2432    /// Optional frame timing sink.
2433    pub frame_timing: Option<FrameTimingConfig>,
2434    /// Conformal predictor configuration for frame-time risk gating.
2435    pub conformal_config: Option<ConformalConfig>,
2436    /// Locale context used for rendering.
2437    pub locale_context: LocaleContext,
2438    /// Input poll timeout.
2439    pub poll_timeout: Duration,
2440    /// Immediate event-drain policy for burst handling.
2441    pub immediate_drain: ImmediateDrainConfig,
2442    /// Resize coalescer configuration.
2443    pub resize_coalescer: CoalescerConfig,
2444    /// Resize handling behavior (immediate/throttled).
2445    pub resize_behavior: ResizeBehavior,
2446    /// Forced terminal size override (when set, resize events are ignored).
2447    pub forced_size: Option<(u16, u16)>,
2448    /// Mouse capture policy (`Auto`, `On`, `Off`).
2449    ///
2450    /// `Auto` is inline-safe: off in inline modes, on in alt-screen mode.
2451    pub mouse_capture_policy: MouseCapturePolicy,
2452    /// Enable bracketed paste.
2453    pub bracketed_paste: bool,
2454    /// Enable focus reporting.
2455    pub focus_reporting: bool,
2456    /// Enable Kitty keyboard protocol (repeat/release events).
2457    pub kitty_keyboard: bool,
2458    /// State persistence configuration.
2459    pub persistence: PersistenceConfig,
2460    /// Inline auto UI height remeasurement policy.
2461    pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2462    /// Widget refresh selection configuration.
2463    pub widget_refresh: WidgetRefreshConfig,
2464    /// Effect queue scheduling configuration.
2465    pub effect_queue: EffectQueueConfig,
2466    /// Frame guardrails configuration (memory + queue safety limits).
2467    pub guardrails: GuardrailsConfig,
2468    /// Install signal handlers for cleanup on SIGINT/SIGTERM/SIGHUP.
2469    ///
2470    /// Defaults to `true` for application safety. Set to `false` in tests or
2471    /// when the embedding application manages signals.
2472    pub intercept_signals: bool,
2473    /// Optional tick strategy for selective background screen ticking.
2474    ///
2475    /// When `None` (default), all screens tick every frame (current behavior).
2476    /// When set, the runtime consults the strategy for each inactive screen.
2477    pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2478    /// Runtime execution lane for the Asupersync migration rollout.
2479    ///
2480    /// Controls which subscription/effect backend is active.
2481    /// Defaults to `Structured` (CancellationToken-backed, current migration state).
2482    /// Logged at startup so operators can identify the active lane.
2483    pub runtime_lane: RuntimeLane,
2484    /// Rollout policy for the Asupersync migration (bd-2crbt).
2485    ///
2486    /// Controls whether shadow-run comparison is active during this session.
2487    /// When `Shadow`, both the baseline and candidate lanes run in parallel
2488    /// and evidence is emitted; rendering uses the baseline lane only.
2489    pub rollout_policy: RolloutPolicy,
2490}
2491
2492impl Default for ProgramConfig {
2493    fn default() -> Self {
2494        Self {
2495            screen_mode: ScreenMode::Inline { ui_height: 4 },
2496            ui_anchor: UiAnchor::Bottom,
2497            budget: FrameBudgetConfig::default(),
2498            load_governor: LoadGovernorConfig::default(),
2499            diff_config: RuntimeDiffConfig::default(),
2500            evidence_sink: EvidenceSinkConfig::default(),
2501            render_trace: RenderTraceConfig::default(),
2502            frame_timing: None,
2503            conformal_config: None,
2504            locale_context: LocaleContext::global(),
2505            poll_timeout: Duration::from_millis(100),
2506            immediate_drain: ImmediateDrainConfig::default(),
2507            resize_coalescer: CoalescerConfig::default(),
2508            resize_behavior: ResizeBehavior::Throttled,
2509            forced_size: None,
2510            mouse_capture_policy: MouseCapturePolicy::Auto,
2511            bracketed_paste: true,
2512            focus_reporting: false,
2513            kitty_keyboard: false,
2514            persistence: PersistenceConfig::default(),
2515            inline_auto_remeasure: None,
2516            widget_refresh: WidgetRefreshConfig::default(),
2517            effect_queue: EffectQueueConfig::default(),
2518            guardrails: GuardrailsConfig::default(),
2519            intercept_signals: true,
2520            tick_strategy: None,
2521            runtime_lane: RuntimeLane::default(),
2522            rollout_policy: RolloutPolicy::default(),
2523        }
2524    }
2525}
2526
2527impl ProgramConfig {
2528    /// Create config for fullscreen applications.
2529    pub fn fullscreen() -> Self {
2530        Self {
2531            screen_mode: ScreenMode::AltScreen,
2532            ..Default::default()
2533        }
2534    }
2535
2536    /// Create config for inline mode with specified height.
2537    pub fn inline(height: u16) -> Self {
2538        Self {
2539            screen_mode: ScreenMode::Inline { ui_height: height },
2540            ..Default::default()
2541        }
2542    }
2543
2544    /// Create config for inline mode with automatic UI height.
2545    pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
2546        Self {
2547            screen_mode: ScreenMode::InlineAuto {
2548                min_height,
2549                max_height,
2550            },
2551            inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
2552            ..Default::default()
2553        }
2554    }
2555
2556    /// Enable mouse support.
2557    #[must_use]
2558    pub fn with_mouse(mut self) -> Self {
2559        self.mouse_capture_policy = MouseCapturePolicy::On;
2560        self
2561    }
2562
2563    /// Set mouse capture policy.
2564    #[must_use]
2565    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
2566        self.mouse_capture_policy = policy;
2567        self
2568    }
2569
2570    /// Force mouse capture enabled/disabled regardless of screen mode.
2571    #[must_use]
2572    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
2573        self.mouse_capture_policy = if enabled {
2574            MouseCapturePolicy::On
2575        } else {
2576            MouseCapturePolicy::Off
2577        };
2578        self
2579    }
2580
2581    /// Resolve mouse capture using the configured policy and screen mode.
2582    #[must_use]
2583    pub const fn resolved_mouse_capture(&self) -> bool {
2584        self.mouse_capture_policy.resolve(self.screen_mode)
2585    }
2586
2587    /// Set the budget configuration.
2588    #[must_use]
2589    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
2590        self.budget = budget;
2591        self
2592    }
2593
2594    /// Set the runtime load-governor configuration.
2595    #[must_use]
2596    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
2597        self.load_governor = config;
2598        self
2599    }
2600
2601    /// Disable the adaptive load governor and use legacy render-budget behavior.
2602    #[must_use]
2603    pub fn without_load_governor(mut self) -> Self {
2604        self.load_governor = LoadGovernorConfig::disabled();
2605        self
2606    }
2607
2608    /// Set the diff strategy configuration for the terminal writer.
2609    #[must_use]
2610    pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
2611        self.diff_config = diff_config;
2612        self
2613    }
2614
2615    /// Set the evidence JSONL sink configuration.
2616    #[must_use]
2617    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
2618        self.evidence_sink = config;
2619        self
2620    }
2621
2622    /// Set the render-trace recorder configuration.
2623    #[must_use]
2624    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
2625        self.render_trace = config;
2626        self
2627    }
2628
2629    /// Set a frame timing sink for per-frame profiling.
2630    #[must_use]
2631    pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
2632        self.frame_timing = Some(config);
2633        self
2634    }
2635
2636    /// Enable conformal frame-time risk gating with the given config.
2637    #[must_use]
2638    pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
2639        self.conformal_config = Some(config);
2640        self
2641    }
2642
2643    /// Disable conformal frame-time risk gating.
2644    #[must_use]
2645    pub fn without_conformal(mut self) -> Self {
2646        self.conformal_config = None;
2647        self
2648    }
2649
2650    /// Set the locale context used for rendering.
2651    #[must_use]
2652    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
2653        self.locale_context = locale_context;
2654        self
2655    }
2656
2657    /// Set the base locale used for rendering.
2658    #[must_use]
2659    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
2660        self.locale_context = LocaleContext::new(locale);
2661        self
2662    }
2663
2664    /// Set the widget refresh selection configuration.
2665    #[must_use]
2666    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
2667        self.widget_refresh = config;
2668        self
2669    }
2670
2671    /// Set the effect queue scheduling configuration.
2672    #[must_use]
2673    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
2674        self.effect_queue = config;
2675        self
2676    }
2677
2678    /// Set the resize coalescer configuration.
2679    #[must_use]
2680    pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
2681        self.resize_coalescer = config;
2682        self
2683    }
2684
2685    /// Set the resize handling behavior.
2686    #[must_use]
2687    pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
2688        self.resize_behavior = behavior;
2689        self
2690    }
2691
2692    /// Force a fixed terminal size (cols, rows). Resize events are ignored.
2693    #[must_use]
2694    pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
2695        let width = width.max(1);
2696        let height = height.max(1);
2697        self.forced_size = Some((width, height));
2698        self
2699    }
2700
2701    /// Clear any forced terminal size override.
2702    #[must_use]
2703    pub fn without_forced_size(mut self) -> Self {
2704        self.forced_size = None;
2705        self
2706    }
2707
2708    /// Toggle legacy immediate-resize behavior for migration.
2709    #[must_use]
2710    pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
2711        if enabled {
2712            self.resize_behavior = ResizeBehavior::Immediate;
2713        }
2714        self
2715    }
2716
2717    /// Set the persistence configuration.
2718    #[must_use]
2719    pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
2720        self.persistence = persistence;
2721        self
2722    }
2723
2724    /// Enable persistence with the given registry.
2725    #[must_use]
2726    pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
2727        self.persistence = PersistenceConfig::with_registry(registry);
2728        self
2729    }
2730
2731    /// Enable inline auto UI height remeasurement with the given policy.
2732    #[must_use]
2733    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
2734        self.inline_auto_remeasure = Some(config);
2735        self
2736    }
2737
2738    /// Disable inline auto UI height remeasurement.
2739    #[must_use]
2740    pub fn without_inline_auto_remeasure(mut self) -> Self {
2741        self.inline_auto_remeasure = None;
2742        self
2743    }
2744
2745    /// Enable or disable signal interception (SIGHUP/SIGTERM/SIGINT) for cleanup.
2746    #[must_use]
2747    pub fn with_signal_interception(mut self, enabled: bool) -> Self {
2748        self.intercept_signals = enabled;
2749        self
2750    }
2751
2752    /// Set frame guardrails configuration.
2753    #[must_use]
2754    pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
2755        self.guardrails = config;
2756        self
2757    }
2758
2759    /// Set the immediate event-drain policy for burst handling.
2760    #[must_use]
2761    pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
2762        self.immediate_drain = config;
2763        self
2764    }
2765
2766    /// Set the tick strategy for selective background screen ticking.
2767    ///
2768    /// When set, the runtime consults the strategy to decide which inactive
2769    /// screens should tick on each frame. Without a strategy, all screens
2770    /// tick every frame (backwards-compatible default).
2771    ///
2772    /// ```ignore
2773    /// ProgramConfig::default()
2774    ///     .with_tick_strategy(TickStrategyKind::Uniform { divisor: 5 })
2775    /// ```
2776    #[must_use]
2777    pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
2778        self.tick_strategy = Some(strategy);
2779        self
2780    }
2781
2782    /// Set the runtime execution lane.
2783    #[must_use]
2784    pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
2785        self.runtime_lane = lane;
2786        self
2787    }
2788
2789    /// Set the rollout policy for the Asupersync migration.
2790    #[must_use]
2791    pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
2792        self.rollout_policy = policy;
2793        self
2794    }
2795
2796    /// Apply environment-variable overrides for lane and rollout policy.
2797    ///
2798    /// Reads `FTUI_RUNTIME_LANE` and `FTUI_ROLLOUT_POLICY`. Unset variables
2799    /// are ignored. Unrecognized values emit a `tracing::warn` and are
2800    /// ignored (the programmatic default or prior builder value is retained).
2801    #[must_use]
2802    pub fn with_env_overrides(mut self) -> Self {
2803        if let Some(lane) = RuntimeLane::from_env() {
2804            self.runtime_lane = lane;
2805        }
2806        if let Some(policy) = RolloutPolicy::from_env() {
2807            self.rollout_policy = policy;
2808        }
2809        self
2810    }
2811
2812    #[must_use]
2813    fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
2814        if !self.effect_queue.uses_legacy_default_backend() {
2815            return self.effect_queue.clone();
2816        }
2817
2818        self.effect_queue
2819            .clone()
2820            .with_backend(self.runtime_lane.resolve().task_executor_backend())
2821    }
2822}
2823
2824fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
2825    let budget = RenderBudget::from_config(&config.budget);
2826    if config.load_governor.enabled {
2827        let mut controller = config.load_governor.budget_controller.clone();
2828        controller.target = config.budget.total;
2829        budget.with_controller(controller)
2830    } else {
2831        budget
2832    }
2833}
2834
2835enum EffectCommand<M> {
2836    Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
2837    Shutdown,
2838}
2839
2840#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2841enum EffectLoopControl {
2842    Continue,
2843    ShutdownRequested,
2844}
2845
2846struct EffectQueue<M: Send + 'static> {
2847    sender: mpsc::Sender<EffectCommand<M>>,
2848    handle: Option<JoinHandle<()>>,
2849    closed: bool,
2850}
2851
2852impl<M: Send + 'static> EffectQueue<M> {
2853    fn start(
2854        config: EffectQueueConfig,
2855        result_sender: mpsc::Sender<M>,
2856        evidence_sink: Option<EvidenceSink>,
2857    ) -> io::Result<Self> {
2858        let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
2859        let handle = thread::Builder::new()
2860            .name("ftui-effects".into())
2861            .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
2862
2863        Ok(Self {
2864            sender: tx,
2865            handle: Some(handle),
2866            closed: false,
2867        })
2868    }
2869
2870    fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
2871        if self.closed {
2872            crate::effect_system::record_queue_drop("post_shutdown");
2873            tracing::debug!("rejecting task enqueue after effect queue shutdown");
2874            return;
2875        }
2876        if self
2877            .sender
2878            .send(EffectCommand::Enqueue(spec, task))
2879            .is_err()
2880        {
2881            crate::effect_system::record_queue_drop("channel_closed");
2882        }
2883    }
2884
2885    /// Timeout for the effect-queue thread to finish after sending Shutdown.
2886    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
2887    /// Poll interval when waiting for the effect-queue thread (bd-170o5).
2888    ///
2889    /// This sleep-poll pattern is the idiomatic Rust approach for bounded
2890    /// thread joins — `JoinHandle` has no `join_timeout` in stable Rust.
2891    /// 1ms is chosen to minimize shutdown latency while avoiding spin.
2892    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
2893
2894    fn shutdown(&mut self) {
2895        self.closed = true;
2896        let _ = self.sender.send(EffectCommand::Shutdown);
2897        if let Some(handle) = self.handle.take() {
2898            let start = Instant::now();
2899            // Fast path: most shutdowns complete nearly instantly after the
2900            // Shutdown command is drained. Check once before entering poll loop.
2901            if handle.is_finished() {
2902                let _ = handle.join();
2903                let elapsed_us = start.elapsed().as_micros() as u64;
2904                tracing::debug!(
2905                    target: "ftui.runtime",
2906                    elapsed_us,
2907                    "effect-queue shutdown (fast path)"
2908                );
2909                return;
2910            }
2911            // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
2912            while !handle.is_finished() {
2913                if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
2914                    tracing::warn!(
2915                        target: "ftui.runtime",
2916                        timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
2917                        "effect-queue thread did not stop within timeout; detaching"
2918                    );
2919                    return;
2920                }
2921                thread::sleep(Self::SHUTDOWN_POLL);
2922            }
2923            let _ = handle.join();
2924            let elapsed_us = start.elapsed().as_micros() as u64;
2925            tracing::debug!(
2926                target: "ftui.runtime",
2927                elapsed_us,
2928                "effect-queue shutdown (slow path)"
2929            );
2930        }
2931    }
2932}
2933
2934impl<M: Send + 'static> Drop for EffectQueue<M> {
2935    fn drop(&mut self) {
2936        self.shutdown();
2937    }
2938}
2939
2940struct SpawnTaskExecutor<M: Send + 'static> {
2941    result_sender: mpsc::Sender<M>,
2942    evidence_sink: Option<EvidenceSink>,
2943    handles: Vec<JoinHandle<()>>,
2944    closed: bool,
2945}
2946
2947impl<M: Send + 'static> SpawnTaskExecutor<M> {
2948    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
2949    /// Poll interval for bounded thread joins (bd-170o5).
2950    ///
2951    /// Same rationale as `EffectQueue::SHUTDOWN_POLL` — `JoinHandle` has no
2952    /// `join_timeout` in stable Rust, so we poll `is_finished()` with a
2953    /// 1ms sleep to minimize shutdown latency while avoiding spin.
2954    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
2955
2956    fn new(result_sender: mpsc::Sender<M>, evidence_sink: Option<EvidenceSink>) -> Self {
2957        Self {
2958            result_sender,
2959            evidence_sink,
2960            handles: Vec::new(),
2961            closed: false,
2962        }
2963    }
2964
2965    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
2966        if self.closed {
2967            tracing::debug!("rejecting spawned task submit after shutdown");
2968            return;
2969        }
2970        let sender = self.result_sender.clone();
2971        let evidence_sink = self.evidence_sink.clone();
2972        let handle = thread::spawn(move || {
2973            let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
2974        });
2975        self.handles.push(handle);
2976    }
2977
2978    fn reap_finished(&mut self) {
2979        if self.handles.is_empty() {
2980            return;
2981        }
2982
2983        let mut i = 0;
2984        while i < self.handles.len() {
2985            if self.handles[i].is_finished() {
2986                let handle = self.handles.swap_remove(i);
2987                let _ = handle.join();
2988            } else {
2989                i += 1;
2990            }
2991        }
2992    }
2993
2994    fn shutdown(&mut self) {
2995        self.closed = true;
2996        let start = Instant::now();
2997        // Fast path: reap any already-finished handles first.
2998        self.reap_finished();
2999        if self.handles.is_empty() {
3000            let elapsed_us = start.elapsed().as_micros() as u64;
3001            tracing::debug!(
3002                target: "ftui.runtime",
3003                elapsed_us,
3004                "spawn-executor shutdown (fast path, all tasks already finished)"
3005            );
3006            return;
3007        }
3008        // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
3009        let pending_at_start = self.handles.len();
3010        while self.handles.iter().any(|handle| !handle.is_finished()) {
3011            if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3012                let still_pending = self
3013                    .handles
3014                    .iter()
3015                    .filter(|handle| !handle.is_finished())
3016                    .count();
3017                tracing::warn!(
3018                    target: "ftui.runtime",
3019                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3020                    pending_handles = still_pending,
3021                    "background task threads did not stop within timeout; detaching"
3022                );
3023                self.handles.clear();
3024                return;
3025            }
3026            thread::sleep(Self::SHUTDOWN_POLL);
3027        }
3028        self.reap_finished();
3029        let elapsed_us = start.elapsed().as_micros() as u64;
3030        tracing::debug!(
3031            target: "ftui.runtime",
3032            elapsed_us,
3033            pending_at_start,
3034            "spawn-executor shutdown (slow path)"
3035        );
3036    }
3037}
3038
3039#[cfg(feature = "asupersync-executor")]
3040struct AsupersyncTaskExecutor<M: Send + 'static> {
3041    result_sender: mpsc::Sender<M>,
3042    evidence_sink: Option<EvidenceSink>,
3043    runtime: AsupersyncRuntime,
3044    handles: Vec<BlockingTaskHandle>,
3045    closed: bool,
3046}
3047
3048#[cfg(feature = "asupersync-executor")]
3049impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
3050    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3051
3052    fn new(
3053        result_sender: mpsc::Sender<M>,
3054        evidence_sink: Option<EvidenceSink>,
3055    ) -> io::Result<Self> {
3056        let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
3057        let runtime = RuntimeBuilder::new()
3058            .blocking_threads(1, max_threads)
3059            .thread_name_prefix("ftui-asupersync-task")
3060            .build()
3061            .map_err(|error| {
3062                io::Error::other(format!("asupersync runtime init failed: {error}"))
3063            })?;
3064
3065        Ok(Self {
3066            result_sender,
3067            evidence_sink,
3068            runtime,
3069            handles: Vec::new(),
3070            closed: false,
3071        })
3072    }
3073
3074    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3075        if self.closed {
3076            tracing::debug!("rejecting asupersync task submit after shutdown");
3077            return;
3078        }
3079        let sender = self.result_sender.clone();
3080        let evidence_sink = self.evidence_sink.clone();
3081        let handle = self
3082            .runtime
3083            .spawn_blocking(move || {
3084                let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3085            })
3086            .expect("asupersync blocking pool must be configured");
3087        self.handles.push(handle);
3088    }
3089
3090    fn reap_finished(&mut self) {
3091        self.handles.retain(|handle| !handle.is_done());
3092    }
3093
3094    fn shutdown(&mut self) {
3095        self.closed = true;
3096        let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3097        for handle in &self.handles {
3098            let remaining = deadline.saturating_duration_since(Instant::now());
3099            if remaining.is_zero() || !handle.wait_timeout(remaining) {
3100                tracing::warn!(
3101                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3102                    pending_handles = self
3103                        .handles
3104                        .iter()
3105                        .filter(|pending| !pending.is_done())
3106                        .count(),
3107                    "Asupersync blocking tasks did not stop within timeout; detaching"
3108                );
3109                self.handles.clear();
3110                return;
3111            }
3112        }
3113        self.handles.clear();
3114    }
3115}
3116
3117enum TaskExecutor<M: Send + 'static> {
3118    Spawned(SpawnTaskExecutor<M>),
3119    Queued(EffectQueue<M>),
3120    #[cfg(feature = "asupersync-executor")]
3121    Asupersync(AsupersyncTaskExecutor<M>),
3122}
3123
3124impl<M: Send + 'static> TaskExecutor<M> {
3125    fn new(
3126        config: &EffectQueueConfig,
3127        result_sender: mpsc::Sender<M>,
3128        evidence_sink: Option<EvidenceSink>,
3129    ) -> io::Result<Self> {
3130        let executor = match config.backend {
3131            TaskExecutorBackend::Spawned => {
3132                Self::Spawned(SpawnTaskExecutor::new(result_sender, evidence_sink.clone()))
3133            }
3134            TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3135                config.clone(),
3136                result_sender,
3137                evidence_sink.clone(),
3138            )?),
3139            #[cfg(feature = "asupersync-executor")]
3140            TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3141                result_sender,
3142                evidence_sink.clone(),
3143            )?),
3144        };
3145
3146        emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3147        Ok(executor)
3148    }
3149
3150    fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3151        match self {
3152            Self::Spawned(executor) => executor.submit(task),
3153            Self::Queued(queue) => queue.enqueue(spec, task),
3154            #[cfg(feature = "asupersync-executor")]
3155            Self::Asupersync(executor) => executor.submit(task),
3156        }
3157    }
3158
3159    fn reap_finished(&mut self) {
3160        match self {
3161            Self::Spawned(executor) => executor.reap_finished(),
3162            #[cfg(feature = "asupersync-executor")]
3163            Self::Asupersync(executor) => executor.reap_finished(),
3164            Self::Queued(_) => {}
3165        }
3166    }
3167
3168    fn shutdown(&mut self) {
3169        match self {
3170            Self::Spawned(executor) => executor.shutdown(),
3171            Self::Queued(queue) => queue.shutdown(),
3172            #[cfg(feature = "asupersync-executor")]
3173            Self::Asupersync(executor) => executor.shutdown(),
3174        }
3175    }
3176
3177    #[cfg(test)]
3178    fn kind_name(&self) -> &'static str {
3179        self.kind_name_for_logs()
3180    }
3181
3182    fn kind_name_for_logs(&self) -> &'static str {
3183        match self {
3184            Self::Spawned(_) => "spawned",
3185            Self::Queued(_) => "queued",
3186            #[cfg(feature = "asupersync-executor")]
3187            Self::Asupersync(_) => "asupersync",
3188        }
3189    }
3190}
3191
3192fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3193    let Some(sink) = sink else {
3194        return;
3195    };
3196    let _ = sink.write_jsonl(&format!(
3197        r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3198    ));
3199}
3200
3201fn emit_task_executor_completion_evidence(
3202    sink: Option<&EvidenceSink>,
3203    backend: &str,
3204    duration_us: u64,
3205) {
3206    let Some(sink) = sink else {
3207        return;
3208    };
3209    let _ = sink.write_jsonl(&format!(
3210        r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3211    ));
3212}
3213
3214fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3215    let Some(sink) = sink else {
3216        return;
3217    };
3218    let escaped = panic_msg
3219        .replace('\\', "\\\\")
3220        .replace('"', "\\\"")
3221        .replace('\n', "\\n")
3222        .replace('\r', "\\r")
3223        .replace('\t', "\\t");
3224    let _ = sink.write_jsonl(&format!(
3225        r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3226    ));
3227}
3228
3229fn emit_task_executor_backpressure_evidence(
3230    sink: Option<&EvidenceSink>,
3231    backend: &str,
3232    action: &str,
3233    queue_length: usize,
3234    max_queue_size: usize,
3235    total_rejected: u64,
3236) {
3237    let Some(sink) = sink else {
3238        return;
3239    };
3240    let _ = sink.write_jsonl(&format!(
3241        r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3242    ));
3243}
3244
3245fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3246    if let Some(s) = payload.downcast_ref::<&str>() {
3247        (*s).to_owned()
3248    } else if let Some(s) = payload.downcast_ref::<String>() {
3249        s.clone()
3250    } else {
3251        "unknown panic payload".to_owned()
3252    }
3253}
3254
3255fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3256    #[cfg(feature = "tracing")]
3257    tracing::error!(
3258        executor_backend = backend,
3259        panic_msg,
3260        "task executor task panicked"
3261    );
3262    #[cfg(not(feature = "tracing"))]
3263    eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3264}
3265
3266fn run_task_closure<M: Send + 'static>(
3267    task: Box<dyn FnOnce() -> M + Send>,
3268    backend: &str,
3269    evidence_sink: Option<&EvidenceSink>,
3270    result_sender: &mpsc::Sender<M>,
3271) -> bool {
3272    let start = Instant::now();
3273    match panic::catch_unwind(AssertUnwindSafe(task)) {
3274        Ok(msg) => {
3275            let duration_us = start.elapsed().as_micros() as u64;
3276            tracing::debug!(
3277                target: "ftui.effect",
3278                command_type = "task",
3279                executor_backend = backend,
3280                duration_us = duration_us,
3281                effect_duration_us = duration_us,
3282                "task effect completed"
3283            );
3284            emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3285            let _ = result_sender.send(msg);
3286            true
3287        }
3288        Err(payload) => {
3289            let panic_msg = panic_payload_message(payload);
3290            log_task_executor_panic(backend, &panic_msg);
3291            emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3292            false
3293        }
3294    }
3295}
3296
3297fn effect_queue_loop<M: Send + 'static>(
3298    config: EffectQueueConfig,
3299    rx: mpsc::Receiver<EffectCommand<M>>,
3300    result_sender: mpsc::Sender<M>,
3301    evidence_sink: Option<EvidenceSink>,
3302) {
3303    let mut scheduler = QueueingScheduler::new(config.scheduler);
3304    let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3305    let mut shutdown_requested = false;
3306    let max_depth = config.max_queue_depth;
3307
3308    loop {
3309        if tasks.is_empty() {
3310            if shutdown_requested {
3311                return;
3312            }
3313            match rx.recv() {
3314                Ok(cmd) => {
3315                    if matches!(
3316                        handle_effect_command(
3317                            cmd,
3318                            &mut scheduler,
3319                            &mut tasks,
3320                            &result_sender,
3321                            evidence_sink.as_ref(),
3322                            max_depth,
3323                        ),
3324                        EffectLoopControl::ShutdownRequested
3325                    ) {
3326                        shutdown_requested = true;
3327                    }
3328                }
3329                Err(_) => return,
3330            }
3331        }
3332
3333        while let Ok(cmd) = rx.try_recv() {
3334            if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3335                crate::effect_system::record_queue_drop("post_shutdown");
3336                continue;
3337            }
3338            if matches!(
3339                handle_effect_command(
3340                    cmd,
3341                    &mut scheduler,
3342                    &mut tasks,
3343                    &result_sender,
3344                    evidence_sink.as_ref(),
3345                    max_depth,
3346                ),
3347                EffectLoopControl::ShutdownRequested
3348            ) {
3349                shutdown_requested = true;
3350            }
3351        }
3352
3353        if tasks.is_empty() {
3354            if shutdown_requested {
3355                return;
3356            }
3357            continue;
3358        }
3359
3360        let Some(job) = scheduler.peek_next().cloned() else {
3361            continue;
3362        };
3363
3364        if let Some(ref sink) = evidence_sink {
3365            let evidence = scheduler.evidence();
3366            let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3367        }
3368
3369        let completed = scheduler.tick(job.remaining_time);
3370        for job_id in completed {
3371            if let Some(task) = tasks.remove(&job_id) {
3372                let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3373                crate::effect_system::record_queue_processed();
3374            }
3375        }
3376    }
3377}
3378
3379fn handle_effect_command<M: Send + 'static>(
3380    cmd: EffectCommand<M>,
3381    scheduler: &mut QueueingScheduler,
3382    tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3383    result_sender: &mpsc::Sender<M>,
3384    evidence_sink: Option<&EvidenceSink>,
3385    max_depth: usize,
3386) -> EffectLoopControl {
3387    match cmd {
3388        EffectCommand::Enqueue(spec, task) => {
3389            // Backpressure: drop task if queue depth exceeds limit (bd-2zd0a)
3390            if max_depth > 0 && tasks.len() >= max_depth {
3391                crate::effect_system::record_queue_drop("backpressure");
3392                return EffectLoopControl::Continue;
3393            }
3394            let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3395                WeightSource::Default
3396            } else {
3397                WeightSource::Explicit
3398            };
3399            let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3400                EstimateSource::Default
3401            } else {
3402                EstimateSource::Explicit
3403            };
3404            let id = scheduler.submit_with_sources(
3405                spec.weight,
3406                spec.estimate_ms,
3407                weight_source,
3408                estimate_source,
3409                spec.name,
3410            );
3411            if let Some(id) = id {
3412                tasks.insert(id, task);
3413                crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3414            } else {
3415                let stats = scheduler.stats();
3416                emit_task_executor_backpressure_evidence(
3417                    evidence_sink,
3418                    "queued",
3419                    "inline_fallback",
3420                    stats.queue_length,
3421                    scheduler.max_queue_size(),
3422                    stats.total_rejected,
3423                );
3424                let _ =
3425                    run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
3426            }
3427            EffectLoopControl::Continue
3428        }
3429        EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
3430    }
3431}
3432
3433// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
3434
3435/// Policy for remeasuring inline auto UI height.
3436///
3437/// Uses VOI (value-of-information) sampling to decide when to perform
3438/// a costly full-height measurement, with any-time valid guarantees via
3439/// the embedded e-process in `VoiSampler`.
3440#[derive(Debug, Clone)]
3441pub struct InlineAutoRemeasureConfig {
3442    /// VOI sampling configuration.
3443    pub voi: VoiConfig,
3444    /// Minimum row delta to count as a "violation".
3445    pub change_threshold_rows: u16,
3446}
3447
3448impl Default for InlineAutoRemeasureConfig {
3449    fn default() -> Self {
3450        Self {
3451            voi: VoiConfig {
3452                // Height changes are expected to be rare; bias toward fewer samples.
3453                prior_alpha: 1.0,
3454                prior_beta: 9.0,
3455                // Allow ~1s max latency to adapt to growth/shrink.
3456                max_interval_ms: 1000,
3457                // Avoid over-sampling in high-FPS loops.
3458                min_interval_ms: 100,
3459                // Disable event forcing; use time-based gating.
3460                max_interval_events: 0,
3461                min_interval_events: 0,
3462                // Treat sampling as moderately expensive.
3463                sample_cost: 0.08,
3464                ..VoiConfig::default()
3465            },
3466            change_threshold_rows: 1,
3467        }
3468    }
3469}
3470
3471#[derive(Debug)]
3472struct InlineAutoRemeasureState {
3473    config: InlineAutoRemeasureConfig,
3474    sampler: VoiSampler,
3475}
3476
3477impl InlineAutoRemeasureState {
3478    fn new(config: InlineAutoRemeasureConfig) -> Self {
3479        let sampler = VoiSampler::new(config.voi.clone());
3480        Self { config, sampler }
3481    }
3482
3483    fn reset(&mut self) {
3484        self.sampler = VoiSampler::new(self.config.voi.clone());
3485    }
3486}
3487
3488#[derive(Debug, Clone)]
3489struct ConformalEvidence {
3490    bucket_key: String,
3491    n_b: usize,
3492    alpha: f64,
3493    q_b: f64,
3494    y_hat: f64,
3495    upper_us: f64,
3496    risk: bool,
3497    fallback_level: u8,
3498    window_size: usize,
3499    reset_count: u64,
3500}
3501
3502impl ConformalEvidence {
3503    fn from_prediction(prediction: &ConformalPrediction) -> Self {
3504        let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
3505        Self {
3506            bucket_key: prediction.bucket.to_string(),
3507            n_b: prediction.sample_count,
3508            alpha,
3509            q_b: prediction.quantile,
3510            y_hat: prediction.y_hat,
3511            upper_us: prediction.upper_us,
3512            risk: prediction.risk,
3513            fallback_level: prediction.fallback_level,
3514            window_size: prediction.window_size,
3515            reset_count: prediction.reset_count,
3516        }
3517    }
3518}
3519
3520#[derive(Debug, Clone)]
3521struct BudgetDecisionEvidence {
3522    frame_idx: u64,
3523    decision: BudgetDecision,
3524    controller_decision: BudgetDecision,
3525    degradation_before: DegradationLevel,
3526    degradation_after: DegradationLevel,
3527    frame_time_us: f64,
3528    budget_us: f64,
3529    pid_output: f64,
3530    pid_p: f64,
3531    pid_i: f64,
3532    pid_d: f64,
3533    e_value: f64,
3534    frames_observed: u32,
3535    frames_since_change: u32,
3536    in_warmup: bool,
3537    conformal: Option<ConformalEvidence>,
3538}
3539
3540impl BudgetDecisionEvidence {
3541    fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
3542        if after > before {
3543            BudgetDecision::Degrade
3544        } else if after < before {
3545            BudgetDecision::Upgrade
3546        } else {
3547            BudgetDecision::Hold
3548        }
3549    }
3550
3551    #[must_use]
3552    fn to_jsonl(&self) -> String {
3553        let conformal = self.conformal.as_ref();
3554        let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
3555        let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
3556        let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
3557        let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
3558        let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
3559        let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
3560        let risk = Self::opt_bool(conformal.map(|c| c.risk));
3561        let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
3562        let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
3563        let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
3564
3565        format!(
3566            r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","degradation_before":"{}","degradation_after":"{}","frame_time_us":{:.6},"budget_us":{:.6},"pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frames_observed":{},"frames_since_change":{},"in_warmup":{},"bucket_key":{},"n_b":{},"alpha":{},"q_b":{},"y_hat":{},"upper_us":{},"risk":{},"fallback_level":{},"window_size":{},"reset_count":{}}}"#,
3567            self.frame_idx,
3568            self.decision.as_str(),
3569            self.controller_decision.as_str(),
3570            self.degradation_before.as_str(),
3571            self.degradation_after.as_str(),
3572            self.frame_time_us,
3573            self.budget_us,
3574            self.pid_output,
3575            self.pid_p,
3576            self.pid_i,
3577            self.pid_d,
3578            self.e_value,
3579            self.frames_observed,
3580            self.frames_since_change,
3581            self.in_warmup,
3582            bucket_key,
3583            n_b,
3584            alpha,
3585            q_b,
3586            y_hat,
3587            upper_us,
3588            risk,
3589            fallback_level,
3590            window_size,
3591            reset_count
3592        )
3593    }
3594
3595    fn opt_f64(value: Option<f64>) -> String {
3596        value
3597            .map(|v| format!("{v:.6}"))
3598            .unwrap_or_else(|| "null".to_string())
3599    }
3600
3601    fn opt_u64(value: Option<u64>) -> String {
3602        value
3603            .map(|v| v.to_string())
3604            .unwrap_or_else(|| "null".to_string())
3605    }
3606
3607    fn opt_u8(value: Option<u8>) -> String {
3608        value
3609            .map(|v| v.to_string())
3610            .unwrap_or_else(|| "null".to_string())
3611    }
3612
3613    fn opt_usize(value: Option<usize>) -> String {
3614        value
3615            .map(|v| v.to_string())
3616            .unwrap_or_else(|| "null".to_string())
3617    }
3618
3619    fn opt_bool(value: Option<bool>) -> String {
3620        value
3621            .map(|v| v.to_string())
3622            .unwrap_or_else(|| "null".to_string())
3623    }
3624
3625    fn opt_str(value: Option<&str>) -> String {
3626        value
3627            .map(|v| {
3628                format!(
3629                    "\"{}\"",
3630                    v.replace('\\', "\\\\")
3631                        .replace('"', "\\\"")
3632                        .replace('\n', "\\n")
3633                        .replace('\r', "\\r")
3634                        .replace('\t', "\\t")
3635                )
3636            })
3637            .unwrap_or_else(|| "null".to_string())
3638    }
3639}
3640
3641#[derive(Debug, Clone)]
3642struct FairnessConfigEvidence {
3643    enabled: bool,
3644    input_priority_threshold_ms: u64,
3645    dominance_threshold: u32,
3646    fairness_threshold: f64,
3647}
3648
3649impl FairnessConfigEvidence {
3650    #[must_use]
3651    fn to_jsonl(&self) -> String {
3652        format!(
3653            r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
3654            self.enabled,
3655            self.input_priority_threshold_ms,
3656            self.dominance_threshold,
3657            self.fairness_threshold
3658        )
3659    }
3660}
3661
3662#[derive(Debug, Clone)]
3663struct FairnessDecisionEvidence {
3664    frame_idx: u64,
3665    decision: &'static str,
3666    reason: &'static str,
3667    pending_input_latency_ms: Option<u64>,
3668    jain_index: f64,
3669    resize_dominance_count: u32,
3670    dominance_threshold: u32,
3671    fairness_threshold: f64,
3672    input_priority_threshold_ms: u64,
3673}
3674
3675impl FairnessDecisionEvidence {
3676    #[must_use]
3677    fn to_jsonl(&self) -> String {
3678        let pending_latency = self
3679            .pending_input_latency_ms
3680            .map(|v| v.to_string())
3681            .unwrap_or_else(|| "null".to_string());
3682        format!(
3683            r#"{{"event":"fairness_decision","frame_idx":{},"decision":"{}","reason":"{}","pending_input_latency_ms":{},"jain_index":{:.6},"resize_dominance_count":{},"dominance_threshold":{},"fairness_threshold":{:.6},"input_priority_threshold_ms":{}}}"#,
3684            self.frame_idx,
3685            self.decision,
3686            self.reason,
3687            pending_latency,
3688            self.jain_index,
3689            self.resize_dominance_count,
3690            self.dominance_threshold,
3691            self.fairness_threshold,
3692            self.input_priority_threshold_ms
3693        )
3694    }
3695}
3696
3697#[derive(Debug, Clone)]
3698struct WidgetRefreshEntry {
3699    widget_id: u64,
3700    essential: bool,
3701    starved: bool,
3702    value: f32,
3703    cost_us: f32,
3704    score: f32,
3705    staleness_ms: u64,
3706}
3707
3708impl WidgetRefreshEntry {
3709    fn to_json(&self) -> String {
3710        format!(
3711            r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
3712            self.widget_id,
3713            self.cost_us,
3714            self.value,
3715            self.score,
3716            self.essential,
3717            self.starved,
3718            self.staleness_ms
3719        )
3720    }
3721}
3722
3723#[derive(Debug, Clone)]
3724struct WidgetRefreshPlan {
3725    frame_idx: u64,
3726    budget_us: f64,
3727    degradation: DegradationLevel,
3728    essentials_cost_us: f64,
3729    selected_cost_us: f64,
3730    selected_value: f64,
3731    signal_count: usize,
3732    selected: Vec<WidgetRefreshEntry>,
3733    skipped_count: usize,
3734    skipped_starved: usize,
3735    starved_selected: usize,
3736    over_budget: bool,
3737}
3738
3739impl WidgetRefreshPlan {
3740    fn new() -> Self {
3741        Self {
3742            frame_idx: 0,
3743            budget_us: 0.0,
3744            degradation: DegradationLevel::Full,
3745            essentials_cost_us: 0.0,
3746            selected_cost_us: 0.0,
3747            selected_value: 0.0,
3748            signal_count: 0,
3749            selected: Vec::new(),
3750            skipped_count: 0,
3751            skipped_starved: 0,
3752            starved_selected: 0,
3753            over_budget: false,
3754        }
3755    }
3756
3757    fn clear(&mut self) {
3758        self.frame_idx = 0;
3759        self.budget_us = 0.0;
3760        self.degradation = DegradationLevel::Full;
3761        self.essentials_cost_us = 0.0;
3762        self.selected_cost_us = 0.0;
3763        self.selected_value = 0.0;
3764        self.signal_count = 0;
3765        self.selected.clear();
3766        self.skipped_count = 0;
3767        self.skipped_starved = 0;
3768        self.starved_selected = 0;
3769        self.over_budget = false;
3770    }
3771
3772    fn as_budget(&self) -> WidgetBudget {
3773        if self.signal_count == 0 {
3774            return WidgetBudget::allow_all();
3775        }
3776        let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
3777        WidgetBudget::allow_only(ids)
3778    }
3779
3780    fn recompute(
3781        &mut self,
3782        frame_idx: u64,
3783        budget_us: f64,
3784        degradation: DegradationLevel,
3785        signals: &[WidgetSignal],
3786        config: &WidgetRefreshConfig,
3787    ) {
3788        self.clear();
3789        self.frame_idx = frame_idx;
3790        self.budget_us = budget_us;
3791        self.degradation = degradation;
3792
3793        if !config.enabled || signals.is_empty() {
3794            return;
3795        }
3796
3797        self.signal_count = signals.len();
3798        let mut essentials_cost = 0.0f64;
3799        let mut selected_cost = 0.0f64;
3800        let mut selected_value = 0.0f64;
3801
3802        let staleness_window = config.staleness_window_ms.max(1) as f32;
3803        let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
3804
3805        for signal in signals {
3806            let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
3807            let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
3808            let mut value = config.weight_priority * signal.priority
3809                + config.weight_staleness * staleness_score
3810                + config.weight_focus * signal.focus_boost
3811                + config.weight_interaction * signal.interaction_boost;
3812            if starved {
3813                value += config.starve_boost;
3814            }
3815            let raw_cost = if signal.recent_cost_us > 0.0 {
3816                signal.recent_cost_us
3817            } else {
3818                signal.cost_estimate_us
3819            };
3820            let cost_us = raw_cost.max(config.min_cost_us);
3821            let score = if cost_us > 0.0 {
3822                value / cost_us
3823            } else {
3824                value
3825            };
3826
3827            let entry = WidgetRefreshEntry {
3828                widget_id: signal.widget_id,
3829                essential: signal.essential,
3830                starved,
3831                value,
3832                cost_us,
3833                score,
3834                staleness_ms: signal.staleness_ms,
3835            };
3836
3837            if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
3838                self.skipped_count += 1;
3839                if starved {
3840                    self.skipped_starved = self.skipped_starved.saturating_add(1);
3841                }
3842                continue;
3843            }
3844
3845            if signal.essential {
3846                essentials_cost += cost_us as f64;
3847                selected_cost += cost_us as f64;
3848                selected_value += value as f64;
3849                if starved {
3850                    self.starved_selected = self.starved_selected.saturating_add(1);
3851                }
3852                self.selected.push(entry);
3853            } else {
3854                candidates.push(entry);
3855            }
3856        }
3857
3858        let mut remaining = budget_us - selected_cost;
3859
3860        if degradation < DegradationLevel::EssentialOnly {
3861            let nonessential_total = candidates.len();
3862            let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
3863            let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
3864            let min_nonessential_selected = if enforce_drop_rate {
3865                let min_fraction = (1.0 - max_drop_fraction).max(0.0);
3866                ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
3867            } else {
3868                0
3869            };
3870
3871            candidates.sort_by(|a, b| {
3872                b.starved
3873                    .cmp(&a.starved)
3874                    .then_with(|| b.score.total_cmp(&a.score))
3875                    .then_with(|| b.value.total_cmp(&a.value))
3876                    .then_with(|| a.cost_us.total_cmp(&b.cost_us))
3877                    .then_with(|| a.widget_id.cmp(&b.widget_id))
3878            });
3879
3880            let mut forced_starved = 0usize;
3881            let mut nonessential_selected = 0usize;
3882            let mut skipped_candidates = if enforce_drop_rate {
3883                Vec::with_capacity(candidates.len())
3884            } else {
3885                Vec::new()
3886            };
3887
3888            for entry in candidates.into_iter() {
3889                if entry.starved && forced_starved >= config.max_starved_per_frame {
3890                    self.skipped_count += 1;
3891                    self.skipped_starved = self.skipped_starved.saturating_add(1);
3892                    if enforce_drop_rate {
3893                        skipped_candidates.push(entry);
3894                    }
3895                    continue;
3896                }
3897
3898                if remaining >= entry.cost_us as f64 {
3899                    remaining -= entry.cost_us as f64;
3900                    selected_cost += entry.cost_us as f64;
3901                    selected_value += entry.value as f64;
3902                    if entry.starved {
3903                        self.starved_selected = self.starved_selected.saturating_add(1);
3904                        forced_starved += 1;
3905                    }
3906                    nonessential_selected += 1;
3907                    self.selected.push(entry);
3908                } else if entry.starved
3909                    && forced_starved < config.max_starved_per_frame
3910                    && nonessential_selected == 0
3911                {
3912                    // Starvation guard: ensure at least one starved widget can refresh.
3913                    selected_cost += entry.cost_us as f64;
3914                    selected_value += entry.value as f64;
3915                    self.starved_selected = self.starved_selected.saturating_add(1);
3916                    forced_starved += 1;
3917                    nonessential_selected += 1;
3918                    self.selected.push(entry);
3919                } else {
3920                    self.skipped_count += 1;
3921                    if entry.starved {
3922                        self.skipped_starved = self.skipped_starved.saturating_add(1);
3923                    }
3924                    if enforce_drop_rate {
3925                        skipped_candidates.push(entry);
3926                    }
3927                }
3928            }
3929
3930            if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
3931                for entry in skipped_candidates.into_iter() {
3932                    if nonessential_selected >= min_nonessential_selected {
3933                        break;
3934                    }
3935                    if entry.starved && forced_starved >= config.max_starved_per_frame {
3936                        continue;
3937                    }
3938                    selected_cost += entry.cost_us as f64;
3939                    selected_value += entry.value as f64;
3940                    if entry.starved {
3941                        self.starved_selected = self.starved_selected.saturating_add(1);
3942                        forced_starved += 1;
3943                        self.skipped_starved = self.skipped_starved.saturating_sub(1);
3944                    }
3945                    self.skipped_count = self.skipped_count.saturating_sub(1);
3946                    nonessential_selected += 1;
3947                    self.selected.push(entry);
3948                }
3949            }
3950        }
3951
3952        self.essentials_cost_us = essentials_cost;
3953        self.selected_cost_us = selected_cost;
3954        self.selected_value = selected_value;
3955        self.over_budget = selected_cost > budget_us;
3956    }
3957
3958    #[must_use]
3959    fn to_jsonl(&self) -> String {
3960        let mut out = String::with_capacity(256 + self.selected.len() * 96);
3961        out.push_str(r#"{"event":"widget_refresh""#);
3962        out.push_str(&format!(
3963            r#","frame_idx":{},"budget_us":{:.3},"degradation":"{}","essentials_cost_us":{:.3},"selected_cost_us":{:.3},"selected_value":{:.3},"selected_count":{},"skipped_count":{},"starved_selected":{},"starved_skipped":{},"over_budget":{}"#,
3964            self.frame_idx,
3965            self.budget_us,
3966            self.degradation.as_str(),
3967            self.essentials_cost_us,
3968            self.selected_cost_us,
3969            self.selected_value,
3970            self.selected.len(),
3971            self.skipped_count,
3972            self.starved_selected,
3973            self.skipped_starved,
3974            self.over_budget
3975        ));
3976        out.push_str(r#","selected":["#);
3977        for (i, entry) in self.selected.iter().enumerate() {
3978            if i > 0 {
3979                out.push(',');
3980            }
3981            out.push_str(&entry.to_json());
3982        }
3983        out.push_str("]}");
3984        out
3985    }
3986}
3987
3988// =============================================================================
3989// CrosstermEventSource: BackendEventSource adapter for TerminalSession
3990// =============================================================================
3991
3992#[cfg(feature = "crossterm-compat")]
3993/// Adapter that wraps [`TerminalSession`] to implement [`BackendEventSource`].
3994///
3995/// This provides the bridge between the legacy crossterm-based terminal session
3996/// and the new backend abstraction. Once the native `ftui-tty` backend fully
3997/// replaces crossterm, this adapter will be removed.
3998pub struct CrosstermEventSource {
3999    session: TerminalSession,
4000    features: BackendFeatures,
4001}
4002
4003#[cfg(feature = "crossterm-compat")]
4004impl CrosstermEventSource {
4005    /// Create a new crossterm event source from a terminal session.
4006    pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
4007        Self {
4008            session,
4009            features: initial_features,
4010        }
4011    }
4012}
4013
4014#[cfg(feature = "crossterm-compat")]
4015impl BackendEventSource for CrosstermEventSource {
4016    type Error = io::Error;
4017
4018    fn size(&self) -> Result<(u16, u16), io::Error> {
4019        self.session.size()
4020    }
4021
4022    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4023        if features.mouse_capture != self.features.mouse_capture {
4024            self.session.set_mouse_capture(features.mouse_capture)?;
4025        }
4026        // bracketed_paste, focus_events, and kitty_keyboard are set at session
4027        // construction and cleaned up in TerminalSession::Drop. Runtime toggling
4028        // is not supported by the crossterm backend.
4029        self.features = features;
4030        Ok(())
4031    }
4032
4033    fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
4034        self.session.poll_event(timeout)
4035    }
4036
4037    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4038        self.session.read_event()
4039    }
4040}
4041
4042// =============================================================================
4043// HeadlessEventSource: no-op event source for headless/test programs
4044// =============================================================================
4045
4046/// A no-op event source for headless and test programs.
4047///
4048/// Returns a fixed terminal size, accepts feature changes silently, and never
4049/// produces events. This allows the test helper to construct a `Program`
4050/// without depending on crossterm or a real terminal.
4051pub struct HeadlessEventSource {
4052    width: u16,
4053    height: u16,
4054    features: BackendFeatures,
4055}
4056
4057impl HeadlessEventSource {
4058    /// Create a headless event source with the given terminal size.
4059    pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
4060        Self {
4061            width,
4062            height,
4063            features,
4064        }
4065    }
4066}
4067
4068impl BackendEventSource for HeadlessEventSource {
4069    type Error = io::Error;
4070
4071    fn size(&self) -> Result<(u16, u16), io::Error> {
4072        Ok((self.width, self.height))
4073    }
4074
4075    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4076        self.features = features;
4077        Ok(())
4078    }
4079
4080    fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
4081        Ok(false)
4082    }
4083
4084    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4085        Ok(None)
4086    }
4087}
4088
4089// =============================================================================
4090// Program
4091// =============================================================================
4092
4093/// The program runtime that manages the update/view loop.
4094pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4095    /// The application model.
4096    model: M,
4097    /// Terminal output coordinator.
4098    writer: TerminalWriter<W>,
4099    /// Event source (terminal input, size queries, feature toggles).
4100    events: E,
4101    /// Currently active backend feature toggles.
4102    backend_features: BackendFeatures,
4103    /// Whether the program is running.
4104    running: bool,
4105    /// Current tick rate (if any).
4106    tick_rate: Option<Duration>,
4107    /// Total commands actually executed by the runtime.
4108    executed_cmd_count: usize,
4109    /// Last tick time.
4110    last_tick: Instant,
4111    /// Whether the UI needs to be redrawn.
4112    dirty: bool,
4113    /// Monotonic frame index for evidence logging.
4114    frame_idx: u64,
4115    /// Monotonic tick index for tick-strategy scheduling.
4116    tick_count: u64,
4117    /// Widget scheduling signals captured during the last render.
4118    widget_signals: Vec<WidgetSignal>,
4119    /// Widget refresh selection configuration.
4120    widget_refresh_config: WidgetRefreshConfig,
4121    /// Last computed widget refresh plan.
4122    widget_refresh_plan: WidgetRefreshPlan,
4123    /// Current terminal width.
4124    width: u16,
4125    /// Current terminal height.
4126    height: u16,
4127    /// Forced terminal size override (when set, resize events are ignored).
4128    forced_size: Option<(u16, u16)>,
4129    /// Poll timeout when no tick is scheduled.
4130    poll_timeout: Duration,
4131    /// Whether the runtime should observe process-level termination signals.
4132    intercept_signals: bool,
4133    /// Immediate drain policy for bursty input handling.
4134    immediate_drain_config: ImmediateDrainConfig,
4135    /// Runtime counters for immediate-drain behavior.
4136    immediate_drain_stats: ImmediateDrainStats,
4137    /// Frame budget configuration.
4138    budget: RenderBudget,
4139    /// Conformal predictor for frame-time risk gating.
4140    conformal_predictor: Option<ConformalPredictor>,
4141    /// Last observed frame time (microseconds), used as a baseline predictor.
4142    last_frame_time_us: Option<f64>,
4143    /// Last observed update duration (microseconds).
4144    last_update_us: Option<u64>,
4145    /// Optional frame timing sink for profiling.
4146    frame_timing: Option<FrameTimingConfig>,
4147    /// Locale context used for rendering.
4148    locale_context: LocaleContext,
4149    /// Last observed locale version.
4150    locale_version: u64,
4151    /// Resize coalescer for rapid resize events.
4152    resize_coalescer: ResizeCoalescer,
4153    /// Shared evidence sink for decision logs (optional).
4154    evidence_sink: Option<EvidenceSink>,
4155    /// Whether fairness config has been logged to evidence sink.
4156    fairness_config_logged: bool,
4157    /// Resize handling behavior.
4158    resize_behavior: ResizeBehavior,
4159    /// Input fairness guard for scheduler integration.
4160    fairness_guard: InputFairnessGuard,
4161    /// Optional event recorder for macro capture.
4162    event_recorder: Option<EventRecorder>,
4163    /// Subscription lifecycle manager.
4164    subscriptions: SubscriptionManager<M::Message>,
4165    /// Channel for receiving messages from background tasks.
4166    #[cfg(test)]
4167    task_sender: std::sync::mpsc::Sender<M::Message>,
4168    /// Channel for receiving messages from background tasks.
4169    task_receiver: std::sync::mpsc::Receiver<M::Message>,
4170    /// Internal task execution substrate behind `Cmd::Task`.
4171    task_executor: TaskExecutor<M::Message>,
4172    /// Optional state registry for widget persistence.
4173    state_registry: Option<std::sync::Arc<StateRegistry>>,
4174    /// Persistence configuration.
4175    persistence_config: PersistenceConfig,
4176    /// Last checkpoint save time.
4177    last_checkpoint: Instant,
4178    /// Inline auto UI height remeasurement state.
4179    inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4180    /// Per-frame bump arena for temporary render-path allocations.
4181    frame_arena: FrameArena,
4182    /// Unified frame guardrails (memory/queue limits).
4183    guardrails: FrameGuardrails,
4184    /// Optional tick strategy for selective background screen ticking.
4185    tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4186    /// Last active screen observed by the tick strategy dispatch path.
4187    last_active_screen_for_strategy: Option<String>,
4188}
4189
4190#[cfg(feature = "crossterm-compat")]
4191impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4192    /// Create a new program with default configuration.
4193    pub fn new(model: M) -> io::Result<Self>
4194    where
4195        M::Message: Send + 'static,
4196    {
4197        Self::with_config(model, ProgramConfig::default())
4198    }
4199
4200    /// Create a new program with the specified configuration.
4201    pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4202    where
4203        M::Message: Send + 'static,
4204    {
4205        let resolved_lane = config.runtime_lane.resolve();
4206        let effect_queue_config = config.resolved_effect_queue_config();
4207        let capabilities = TerminalCapabilities::with_overrides();
4208        let mouse_capture = config.resolved_mouse_capture();
4209        let requested_features = BackendFeatures {
4210            mouse_capture,
4211            bracketed_paste: config.bracketed_paste,
4212            focus_events: config.focus_reporting,
4213            kitty_keyboard: config.kitty_keyboard,
4214        };
4215        let initial_features =
4216            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4217        let session = TerminalSession::new(SessionOptions {
4218            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4219            mouse_capture: initial_features.mouse_capture,
4220            bracketed_paste: initial_features.bracketed_paste,
4221            focus_events: initial_features.focus_events,
4222            kitty_keyboard: initial_features.kitty_keyboard,
4223            intercept_signals: config.intercept_signals,
4224        })?;
4225        let events = CrosstermEventSource::new(session, initial_features);
4226
4227        let mut writer = TerminalWriter::with_diff_config(
4228            io::stdout(),
4229            config.screen_mode,
4230            config.ui_anchor,
4231            capabilities,
4232            config.diff_config.clone(),
4233        );
4234
4235        let frame_timing = config.frame_timing.clone();
4236        writer.set_timing_enabled(frame_timing.is_some());
4237
4238        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4239        if let Some(ref sink) = evidence_sink {
4240            writer = writer.with_evidence_sink(sink.clone());
4241        }
4242
4243        let render_trace = crate::RenderTraceRecorder::from_config(
4244            &config.render_trace,
4245            crate::RenderTraceContext {
4246                capabilities: writer.capabilities(),
4247                diff_config: config.diff_config.clone(),
4248                resize_config: config.resize_coalescer.clone(),
4249                conformal_config: config.conformal_config.clone(),
4250            },
4251        )?;
4252        if let Some(recorder) = render_trace {
4253            writer = writer.with_render_trace(recorder);
4254        }
4255
4256        // Get terminal size for initial frame (or forced size override).
4257        let (w, h) = config
4258            .forced_size
4259            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4260        let width = w.max(1);
4261        let height = h.max(1);
4262        writer.set_size(width, height);
4263
4264        let budget = render_budget_from_program_config(&config);
4265        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4266        let locale_context = config.locale_context.clone();
4267        let locale_version = locale_context.version();
4268        let mut resize_coalescer =
4269            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4270                .with_screen_mode(config.screen_mode);
4271        if let Some(ref sink) = evidence_sink {
4272            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4273        }
4274        let subscriptions = SubscriptionManager::new();
4275        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4276        let inline_auto_remeasure = config
4277            .inline_auto_remeasure
4278            .clone()
4279            .map(InlineAutoRemeasureState::new);
4280        let task_executor = TaskExecutor::new(
4281            &effect_queue_config,
4282            task_sender.clone(),
4283            evidence_sink.clone(),
4284        )?;
4285        let guardrails = FrameGuardrails::new(config.guardrails);
4286
4287        // Log runtime lane and rollout policy at startup (bd-2crbt)
4288        tracing::info!(
4289            target: "ftui.runtime",
4290            requested_lane = config.runtime_lane.label(),
4291            resolved_lane = resolved_lane.label(),
4292            rollout_policy = config.rollout_policy.label(),
4293            "runtime startup: lane={}, rollout={}",
4294            resolved_lane.label(),
4295            config.rollout_policy.label(),
4296        );
4297
4298        Ok(Self {
4299            model,
4300            writer,
4301            events,
4302            backend_features: initial_features,
4303            running: true,
4304            tick_rate: None,
4305            executed_cmd_count: 0,
4306            last_tick: Instant::now(),
4307            dirty: true,
4308            frame_idx: 0,
4309            tick_count: 0,
4310            widget_signals: Vec::new(),
4311            widget_refresh_config: config.widget_refresh,
4312            widget_refresh_plan: WidgetRefreshPlan::new(),
4313            width,
4314            height,
4315            forced_size: config.forced_size,
4316            poll_timeout: config.poll_timeout,
4317            intercept_signals: config.intercept_signals,
4318            immediate_drain_config: config.immediate_drain,
4319            immediate_drain_stats: ImmediateDrainStats::default(),
4320            budget,
4321            conformal_predictor,
4322            last_frame_time_us: None,
4323            last_update_us: None,
4324            frame_timing,
4325            locale_context,
4326            locale_version,
4327            resize_coalescer,
4328            evidence_sink,
4329            fairness_config_logged: false,
4330            resize_behavior: config.resize_behavior,
4331            fairness_guard: InputFairnessGuard::new(),
4332            event_recorder: None,
4333            subscriptions,
4334            #[cfg(test)]
4335            task_sender,
4336            task_receiver,
4337            task_executor,
4338            state_registry: config.persistence.registry.clone(),
4339            persistence_config: config.persistence,
4340            last_checkpoint: Instant::now(),
4341            inline_auto_remeasure,
4342            frame_arena: FrameArena::default(),
4343            guardrails,
4344            tick_strategy: config
4345                .tick_strategy
4346                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4347            last_active_screen_for_strategy: None,
4348        })
4349    }
4350}
4351
4352impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4353    /// Create a program with an externally-constructed event source and writer.
4354    ///
4355    /// This is the generic entry point for alternative backends (native tty,
4356    /// WASM, headless testing). The caller is responsible for terminal
4357    /// lifecycle (raw mode, cleanup) — the event source should handle that
4358    /// via its `Drop` impl or an external RAII guard.
4359    pub fn with_event_source(
4360        model: M,
4361        events: E,
4362        backend_features: BackendFeatures,
4363        writer: TerminalWriter<W>,
4364        config: ProgramConfig,
4365    ) -> io::Result<Self>
4366    where
4367        M::Message: Send + 'static,
4368    {
4369        let effect_queue_config = config.resolved_effect_queue_config();
4370        let (width, height) = config
4371            .forced_size
4372            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4373        let width = width.max(1);
4374        let height = height.max(1);
4375
4376        let mut writer = writer;
4377        writer.set_size(width, height);
4378
4379        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4380        if let Some(ref sink) = evidence_sink {
4381            writer = writer.with_evidence_sink(sink.clone());
4382        }
4383
4384        let render_trace = crate::RenderTraceRecorder::from_config(
4385            &config.render_trace,
4386            crate::RenderTraceContext {
4387                capabilities: writer.capabilities(),
4388                diff_config: config.diff_config.clone(),
4389                resize_config: config.resize_coalescer.clone(),
4390                conformal_config: config.conformal_config.clone(),
4391            },
4392        )?;
4393        if let Some(recorder) = render_trace {
4394            writer = writer.with_render_trace(recorder);
4395        }
4396
4397        let frame_timing = config.frame_timing.clone();
4398        writer.set_timing_enabled(frame_timing.is_some());
4399
4400        let budget = render_budget_from_program_config(&config);
4401        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4402        let locale_context = config.locale_context.clone();
4403        let locale_version = locale_context.version();
4404        let mut resize_coalescer =
4405            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4406                .with_screen_mode(config.screen_mode);
4407        if let Some(ref sink) = evidence_sink {
4408            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4409        }
4410        let subscriptions = SubscriptionManager::new();
4411        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4412        let inline_auto_remeasure = config
4413            .inline_auto_remeasure
4414            .clone()
4415            .map(InlineAutoRemeasureState::new);
4416        let task_executor = TaskExecutor::new(
4417            &effect_queue_config,
4418            task_sender.clone(),
4419            evidence_sink.clone(),
4420        )?;
4421
4422        let guardrails = FrameGuardrails::new(config.guardrails);
4423
4424        Ok(Self {
4425            model,
4426            writer,
4427            events,
4428            backend_features,
4429            running: true,
4430            tick_rate: None,
4431            executed_cmd_count: 0,
4432            last_tick: Instant::now(),
4433            dirty: true,
4434            frame_idx: 0,
4435            tick_count: 0,
4436            widget_signals: Vec::new(),
4437            widget_refresh_config: config.widget_refresh,
4438            widget_refresh_plan: WidgetRefreshPlan::new(),
4439            width,
4440            height,
4441            forced_size: config.forced_size,
4442            poll_timeout: config.poll_timeout,
4443            intercept_signals: config.intercept_signals,
4444            immediate_drain_config: config.immediate_drain,
4445            immediate_drain_stats: ImmediateDrainStats::default(),
4446            budget,
4447            conformal_predictor,
4448            last_frame_time_us: None,
4449            last_update_us: None,
4450            frame_timing,
4451            locale_context,
4452            locale_version,
4453            resize_coalescer,
4454            evidence_sink,
4455            fairness_config_logged: false,
4456            resize_behavior: config.resize_behavior,
4457            fairness_guard: InputFairnessGuard::new(),
4458            event_recorder: None,
4459            subscriptions,
4460            #[cfg(test)]
4461            task_sender,
4462            task_receiver,
4463            task_executor,
4464            state_registry: config.persistence.registry.clone(),
4465            persistence_config: config.persistence,
4466            last_checkpoint: Instant::now(),
4467            inline_auto_remeasure,
4468            frame_arena: FrameArena::default(),
4469            guardrails,
4470            tick_strategy: config
4471                .tick_strategy
4472                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4473            last_active_screen_for_strategy: None,
4474        })
4475    }
4476}
4477
4478// =============================================================================
4479// Native TTY backend constructor (feature-gated)
4480// =============================================================================
4481
4482#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
4483#[inline]
4484const fn sanitize_backend_features_for_capabilities(
4485    requested: BackendFeatures,
4486    capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
4487) -> BackendFeatures {
4488    let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
4489    let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
4490
4491    BackendFeatures {
4492        mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
4493        bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
4494        focus_events: requested.focus_events && focus_events_supported,
4495        kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
4496    }
4497}
4498
4499#[cfg(feature = "native-backend")]
4500impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
4501    /// Create a program backed by the native TTY backend (no Crossterm).
4502    ///
4503    /// This opens a live terminal session via `ftui-tty`, entering raw mode
4504    /// and enabling the requested features. When the program exits (or panics),
4505    /// `TtyBackend::drop()` restores the terminal to its original state.
4506    pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
4507    where
4508        M::Message: Send + 'static,
4509    {
4510        let capabilities = ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
4511        let mouse_capture = config.resolved_mouse_capture();
4512        let requested_features = BackendFeatures {
4513            mouse_capture,
4514            bracketed_paste: config.bracketed_paste,
4515            focus_events: config.focus_reporting,
4516            kitty_keyboard: config.kitty_keyboard,
4517        };
4518        let features =
4519            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4520        let options = ftui_tty::TtySessionOptions {
4521            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4522            features,
4523            intercept_signals: config.intercept_signals,
4524        };
4525        #[cfg(unix)]
4526        let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
4527        #[cfg(not(unix))]
4528        let backend = ftui_tty::TtyBackend::new(0, 0);
4529
4530        let writer = TerminalWriter::with_diff_config(
4531            io::stdout(),
4532            config.screen_mode,
4533            config.ui_anchor,
4534            capabilities,
4535            config.diff_config.clone(),
4536        );
4537
4538        Self::with_event_source(model, backend, features, writer, config)
4539    }
4540}
4541
4542impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4543    /// Run the main event loop.
4544    ///
4545    /// This is the main entry point. It handles:
4546    /// 1. Initialization (terminal setup, raw mode)
4547    /// 2. Event polling and message dispatch
4548    /// 3. Frame rendering
4549    /// 4. Shutdown (terminal cleanup)
4550    pub fn run(&mut self) -> io::Result<()> {
4551        self.run_event_loop()
4552    }
4553
4554    #[inline]
4555    fn observed_termination_signal(&self) -> Option<i32> {
4556        if self.intercept_signals {
4557            check_termination_signal()
4558        } else {
4559            None
4560        }
4561    }
4562
4563    /// Access widget scheduling signals captured on the last render.
4564    #[inline]
4565    pub fn last_widget_signals(&self) -> &[WidgetSignal] {
4566        &self.widget_signals
4567    }
4568
4569    /// Snapshot immediate-drain runtime counters.
4570    #[inline]
4571    pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
4572        self.immediate_drain_stats
4573    }
4574
4575    /// The inner event loop, separated for proper cleanup handling.
4576    fn run_event_loop(&mut self) -> io::Result<()> {
4577        // Auto-load state on start
4578        if self.persistence_config.auto_load {
4579            self.load_state();
4580        }
4581
4582        // Initialize
4583        let cmd = {
4584            let _span = info_span!("ftui.program.init").entered();
4585            self.model.init()
4586        };
4587        self.execute_cmd(cmd)?;
4588
4589        let mut termination_signal = self.observed_termination_signal();
4590        if self.running && termination_signal.is_none() {
4591            // Reconcile initial subscriptions
4592            self.reconcile_subscriptions();
4593
4594            // Initial render
4595            self.render_frame()?;
4596        }
4597
4598        // Main loop
4599        let mut loop_count: u64 = 0;
4600        while self.running {
4601            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4602            if termination_signal.is_some() {
4603                self.running = false;
4604                break;
4605            }
4606
4607            loop_count += 1;
4608            // Log heartbeat every 100 iterations to avoid flooding stderr
4609            if loop_count.is_multiple_of(100) {
4610                crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
4611            }
4612
4613            // Poll for input with tick timeout
4614            let timeout = self.effective_timeout();
4615
4616            // Poll for events with timeout
4617            let poll_result = self.events.poll_event(timeout)?;
4618            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4619            if termination_signal.is_some() {
4620                self.running = false;
4621                break;
4622            }
4623            if poll_result {
4624                self.drain_ready_events()?;
4625            }
4626            if !self.running {
4627                break;
4628            }
4629            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4630            if termination_signal.is_some() {
4631                self.running = false;
4632                break;
4633            }
4634
4635            // Process subscription messages
4636            self.process_subscription_messages()?;
4637            if !self.running {
4638                break;
4639            }
4640
4641            // Process background task results
4642            self.process_task_results()?;
4643            self.reap_finished_tasks();
4644            if !self.running {
4645                break;
4646            }
4647
4648            self.process_resize_coalescer()?;
4649            if !self.running {
4650                break;
4651            }
4652            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4653            if termination_signal.is_some() {
4654                self.running = false;
4655                break;
4656            }
4657
4658            // Detect screen transitions from any update() calls above.
4659            // A.2: notifies the tick strategy so predictive strategies learn.
4660            // D.3: force-ticks the newly active screen for immediate refresh.
4661            self.check_screen_transition();
4662
4663            // Check for tick - deliver to model so periodic logic can run
4664            if self.should_tick() {
4665                self.tick_count = self.tick_count.wrapping_add(1);
4666                let tick_count = self.tick_count;
4667
4668                let mut used_screen_dispatch = false;
4669
4670                // Per-screen tick dispatch: if the model supports multi-screen
4671                // dispatch and a tick strategy is configured, tick individual
4672                // screens selectively instead of calling monolithic
4673                // `update(Tick)`.
4674                if let Some(strategy) = self.tick_strategy.as_mut() {
4675                    // Snapshot screen topology first so the mutable borrow of the
4676                    // dispatch adapter does not overlap strategy decisions.
4677                    let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
4678                        let active = dispatch.active_screen_id();
4679                        let all_screens = dispatch.screen_ids();
4680                        (active, all_screens)
4681                    });
4682
4683                    if let Some((active, all_screens)) = dispatch_snapshot {
4684                        used_screen_dispatch = true;
4685
4686                        // Feed active-screen transitions into the strategy so
4687                        // predictive strategies can learn from real navigation.
4688                        if let Some(previous_active) =
4689                            self.last_active_screen_for_strategy.as_deref()
4690                            && previous_active != active
4691                        {
4692                            strategy.on_screen_transition(previous_active, &active);
4693                        }
4694                        self.last_active_screen_for_strategy = Some(active.clone());
4695
4696                        let all_screens_count = all_screens.len();
4697                        let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
4698                        // Active screen is always ticked.
4699                        tick_targets.push(active.clone());
4700
4701                        // Tick inactive screens according to the strategy.
4702                        for screen_id in all_screens {
4703                            if screen_id != active
4704                                && strategy.should_tick(&screen_id, tick_count, &active)
4705                                    == crate::tick_strategy::TickDecision::Tick
4706                            {
4707                                tick_targets.push(screen_id);
4708                            }
4709                        }
4710
4711                        // Compute skipped screens for tracing.
4712                        let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
4713
4714                        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
4715                            for screen_id in &tick_targets {
4716                                dispatch.tick_screen(screen_id, tick_count);
4717                            }
4718                        }
4719
4720                        trace!(
4721                            tick = tick_count,
4722                            active = %active,
4723                            ticked = tick_targets.len(),
4724                            skipped = skipped_count,
4725                            "tick_strategy.frame"
4726                        );
4727
4728                        // Maintenance tick for the strategy.
4729                        strategy.maintenance_tick(tick_count);
4730                        self.mark_dirty();
4731                    }
4732                }
4733
4734                if used_screen_dispatch && self.running {
4735                    self.reconcile_subscriptions();
4736                }
4737
4738                if !used_screen_dispatch {
4739                    // Monolithic model path does not expose active-screen
4740                    // transitions, so clear dispatch-local transition state.
4741                    self.last_active_screen_for_strategy = None;
4742                    let msg = M::Message::from(Event::Tick);
4743                    let cmd = {
4744                        let _span = debug_span!(
4745                            "ftui.program.update",
4746                            msg_type = "Tick",
4747                            duration_us = tracing::field::Empty,
4748                            cmd_type = tracing::field::Empty
4749                        )
4750                        .entered();
4751                        let start = Instant::now();
4752                        let cmd = self.model.update(msg);
4753                        tracing::Span::current()
4754                            .record("duration_us", start.elapsed().as_micros() as u64);
4755                        tracing::Span::current()
4756                            .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
4757                        cmd
4758                    };
4759                    self.mark_dirty();
4760                    self.execute_cmd(cmd)?;
4761                    if self.running {
4762                        self.reconcile_subscriptions();
4763                    }
4764                }
4765            }
4766
4767            // Check for periodic checkpoint save
4768            self.check_checkpoint_save();
4769
4770            // Detect locale changes outside the event loop.
4771            self.check_locale_change();
4772            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4773            if termination_signal.is_some() {
4774                self.running = false;
4775                break;
4776            }
4777
4778            // Render if dirty
4779            if self.dirty {
4780                self.render_frame()?;
4781            }
4782
4783            // Periodic grapheme pool GC
4784            if loop_count.is_multiple_of(1000) {
4785                self.writer.gc(None);
4786            }
4787        }
4788
4789        let shutdown_cmd = {
4790            let _span = info_span!("ftui.program.shutdown").entered();
4791            self.model.on_shutdown()
4792        };
4793        self.execute_cmd(shutdown_cmd)?;
4794
4795        // Auto-save state on exit
4796        if self.persistence_config.auto_save {
4797            self.save_state();
4798        }
4799
4800        // Shut down tick strategy (gives strategies a chance to persist state)
4801        if let Some(ref mut strategy) = self.tick_strategy {
4802            strategy.shutdown();
4803        }
4804
4805        // Stop all subscriptions on exit
4806        self.subscriptions.stop_all();
4807        self.task_executor.shutdown();
4808        self.reap_finished_tasks();
4809        self.drain_shutdown_task_results()?;
4810
4811        if let Some(signal) = termination_signal {
4812            clear_termination_signal();
4813            let err = io::Error::new(
4814                io::ErrorKind::Interrupted,
4815                SignalTerminationError { signal },
4816            );
4817            debug_assert_eq!(signal_termination_from_error(&err), Some(signal));
4818            return Err(err);
4819        }
4820
4821        Ok(())
4822    }
4823
4824    /// Drain ready events while bounding zero-timeout polling work.
4825    ///
4826    /// The runtime preserves low-latency draining by polling with
4827    /// `Duration::ZERO`, but switches to a bounded backoff path when a burst
4828    /// exceeds configured immediate-drain budgets.
4829    fn drain_ready_events(&mut self) -> io::Result<()> {
4830        self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
4831
4832        let zero_poll_limit = self
4833            .immediate_drain_config
4834            .max_zero_timeout_polls_per_burst
4835            .max(1);
4836        let max_burst_duration = self.immediate_drain_config.max_burst_duration;
4837        let backoff_timeout = self.immediate_drain_config.backoff_timeout;
4838
4839        let mut burst_start = Instant::now();
4840        let mut zero_polls_in_burst_window: u64 = 0;
4841        let mut capped_this_burst = false;
4842
4843        loop {
4844            if let Some(event) = self.events.read_event()? {
4845                self.handle_event(event)?;
4846                if !self.running {
4847                    break;
4848                }
4849            }
4850
4851            let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
4852                || burst_start.elapsed() >= max_burst_duration;
4853
4854            if budget_exhausted {
4855                if !capped_this_burst {
4856                    capped_this_burst = true;
4857                    self.immediate_drain_stats.capped_bursts =
4858                        self.immediate_drain_stats.capped_bursts.saturating_add(1);
4859                }
4860
4861                self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
4862                    .immediate_drain_stats
4863                    .max_zero_timeout_polls_in_burst
4864                    .max(zero_polls_in_burst_window);
4865
4866                std::thread::yield_now();
4867                self.immediate_drain_stats.backoff_polls =
4868                    self.immediate_drain_stats.backoff_polls.saturating_add(1);
4869                if !self.events.poll_event(backoff_timeout)? {
4870                    break;
4871                }
4872                zero_polls_in_burst_window = 0;
4873                burst_start = Instant::now();
4874                continue;
4875            }
4876
4877            self.immediate_drain_stats.zero_timeout_polls = self
4878                .immediate_drain_stats
4879                .zero_timeout_polls
4880                .saturating_add(1);
4881            zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
4882            if !self.events.poll_event(Duration::ZERO)? {
4883                break;
4884            }
4885        }
4886
4887        self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
4888            .immediate_drain_stats
4889            .max_zero_timeout_polls_in_burst
4890            .max(zero_polls_in_burst_window);
4891
4892        Ok(())
4893    }
4894
4895    /// Load state from the persistence registry.
4896    fn load_state(&mut self) {
4897        if let Some(registry) = &self.state_registry {
4898            match registry.load() {
4899                Ok(count) => {
4900                    info!(count, "loaded widget state from persistence");
4901                }
4902                Err(e) => {
4903                    tracing::warn!(error = %e, "failed to load widget state");
4904                }
4905            }
4906        }
4907    }
4908
4909    /// Save state to the persistence registry.
4910    fn save_state(&mut self) {
4911        if let Some(registry) = &self.state_registry {
4912            match registry.flush() {
4913                Ok(true) => {
4914                    debug!("saved widget state to persistence");
4915                }
4916                Ok(false) => {
4917                    // No changes to save
4918                }
4919                Err(e) => {
4920                    tracing::warn!(error = %e, "failed to save widget state");
4921                }
4922            }
4923        }
4924    }
4925
4926    /// Check if it's time for a periodic checkpoint save.
4927    fn check_checkpoint_save(&mut self) {
4928        if let Some(interval) = self.persistence_config.checkpoint_interval
4929            && self.last_checkpoint.elapsed() >= interval
4930        {
4931            self.save_state();
4932            self.last_checkpoint = Instant::now();
4933        }
4934    }
4935
4936    fn handle_event(&mut self, event: Event) -> io::Result<()> {
4937        // Track event start time and type for fairness scheduling.
4938        let event_start = Instant::now();
4939        let fairness_event_type = Self::classify_event_for_fairness(&event);
4940        if fairness_event_type == FairnessEventType::Input {
4941            self.fairness_guard.input_arrived(event_start);
4942        }
4943
4944        // Record event before processing (no-op when recorder is None or idle).
4945        if let Some(recorder) = &mut self.event_recorder {
4946            recorder.record(&event);
4947        }
4948
4949        let event = match event {
4950            Event::Resize { width, height } => {
4951                debug!(
4952                    width,
4953                    height,
4954                    behavior = ?self.resize_behavior,
4955                    "Resize event received"
4956                );
4957                if let Some((forced_width, forced_height)) = self.forced_size {
4958                    debug!(
4959                        forced_width,
4960                        forced_height, "Resize ignored due to forced size override"
4961                    );
4962                    self.fairness_guard.event_processed(
4963                        fairness_event_type,
4964                        event_start.elapsed(),
4965                        Instant::now(),
4966                    );
4967                    return Ok(());
4968                }
4969                // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
4970                let width = width.max(1);
4971                let height = height.max(1);
4972                match self.resize_behavior {
4973                    ResizeBehavior::Immediate => {
4974                        self.resize_coalescer
4975                            .record_external_apply(width, height, Instant::now());
4976                        let result = self.apply_resize(width, height, Duration::ZERO, false);
4977                        self.fairness_guard.event_processed(
4978                            fairness_event_type,
4979                            event_start.elapsed(),
4980                            Instant::now(),
4981                        );
4982                        return result;
4983                    }
4984                    ResizeBehavior::Throttled => {
4985                        let action = self.resize_coalescer.handle_resize(width, height);
4986                        if let CoalesceAction::ApplyResize {
4987                            width,
4988                            height,
4989                            coalesce_time,
4990                            forced_by_deadline,
4991                        } = action
4992                        {
4993                            let result =
4994                                self.apply_resize(width, height, coalesce_time, forced_by_deadline);
4995                            self.fairness_guard.event_processed(
4996                                fairness_event_type,
4997                                event_start.elapsed(),
4998                                Instant::now(),
4999                            );
5000                            return result;
5001                        }
5002
5003                        self.fairness_guard.event_processed(
5004                            fairness_event_type,
5005                            event_start.elapsed(),
5006                            Instant::now(),
5007                        );
5008                        return Ok(());
5009                    }
5010                }
5011            }
5012            other => other,
5013        };
5014
5015        let msg = M::Message::from(event);
5016        let cmd = {
5017            let _span = debug_span!(
5018                "ftui.program.update",
5019                msg_type = "event",
5020                duration_us = tracing::field::Empty,
5021                cmd_type = tracing::field::Empty
5022            )
5023            .entered();
5024            let start = Instant::now();
5025            let cmd = self.model.update(msg);
5026            let elapsed_us = start.elapsed().as_micros() as u64;
5027            self.last_update_us = Some(elapsed_us);
5028            tracing::Span::current().record("duration_us", elapsed_us);
5029            tracing::Span::current()
5030                .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5031            cmd
5032        };
5033        self.mark_dirty();
5034        self.execute_cmd(cmd)?;
5035        if self.running {
5036            self.reconcile_subscriptions();
5037        }
5038
5039        // Track input event processing for fairness.
5040        self.fairness_guard.event_processed(
5041            fairness_event_type,
5042            event_start.elapsed(),
5043            Instant::now(),
5044        );
5045
5046        Ok(())
5047    }
5048
5049    /// Classify an event for fairness tracking.
5050    fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
5051        match event {
5052            Event::Key(_)
5053            | Event::Mouse(_)
5054            | Event::Paste(_)
5055            | Event::Ime(_)
5056            | Event::Focus(_)
5057            | Event::Clipboard(_) => FairnessEventType::Input,
5058            Event::Resize { .. } => FairnessEventType::Resize,
5059            Event::Tick => FairnessEventType::Tick,
5060        }
5061    }
5062
5063    /// Reconcile the model's declared subscriptions with running ones.
5064    fn reconcile_subscriptions(&mut self) {
5065        let _span = debug_span!(
5066            "ftui.program.subscriptions",
5067            active_count = tracing::field::Empty,
5068            started = tracing::field::Empty,
5069            stopped = tracing::field::Empty
5070        )
5071        .entered();
5072        let subs = self.model.subscriptions();
5073        let before_count = self.subscriptions.active_count();
5074        self.subscriptions.reconcile(subs);
5075        let after_count = self.subscriptions.active_count();
5076        let started = after_count.saturating_sub(before_count);
5077        let stopped = before_count.saturating_sub(after_count);
5078        crate::debug_trace!(
5079            "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
5080            before_count,
5081            after_count,
5082            started,
5083            stopped
5084        );
5085        if after_count == 0 {
5086            crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5087        }
5088        let current = tracing::Span::current();
5089        current.record("active_count", after_count);
5090        // started/stopped would require tracking in SubscriptionManager
5091        current.record("started", started);
5092        current.record("stopped", stopped);
5093    }
5094
5095    /// Process pending messages from subscriptions.
5096    fn process_subscription_messages(&mut self) -> io::Result<()> {
5097        let messages = self.subscriptions.drain_messages();
5098        let msg_count = messages.len();
5099        if msg_count > 0 {
5100            crate::debug_trace!("processing {} subscription message(s)", msg_count);
5101        }
5102        for msg in messages {
5103            let cmd = {
5104                let _span = debug_span!(
5105                    "ftui.program.update",
5106                    msg_type = "subscription",
5107                    duration_us = tracing::field::Empty,
5108                    cmd_type = tracing::field::Empty
5109                )
5110                .entered();
5111                let start = Instant::now();
5112                let cmd = self.model.update(msg);
5113                let elapsed_us = start.elapsed().as_micros() as u64;
5114                self.last_update_us = Some(elapsed_us);
5115                tracing::Span::current().record("duration_us", elapsed_us);
5116                tracing::Span::current()
5117                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5118                cmd
5119            };
5120            self.mark_dirty();
5121            self.execute_cmd(cmd)?;
5122            if !self.running {
5123                break;
5124            }
5125        }
5126        if self.running && self.dirty {
5127            self.reconcile_subscriptions();
5128        }
5129        Ok(())
5130    }
5131
5132    /// Process results from background tasks.
5133    fn process_task_results(&mut self) -> io::Result<()> {
5134        while let Ok(msg) = self.task_receiver.try_recv() {
5135            let cmd = {
5136                let _span = debug_span!(
5137                    "ftui.program.update",
5138                    msg_type = "task",
5139                    duration_us = tracing::field::Empty,
5140                    cmd_type = tracing::field::Empty
5141                )
5142                .entered();
5143                let start = Instant::now();
5144                let cmd = self.model.update(msg);
5145                let elapsed_us = start.elapsed().as_micros() as u64;
5146                self.last_update_us = Some(elapsed_us);
5147                tracing::Span::current().record("duration_us", elapsed_us);
5148                tracing::Span::current()
5149                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5150                cmd
5151            };
5152            self.mark_dirty();
5153            self.execute_cmd(cmd)?;
5154            if !self.running {
5155                break;
5156            }
5157        }
5158        if self.running && self.dirty {
5159            self.reconcile_subscriptions();
5160        }
5161        Ok(())
5162    }
5163
5164    /// Execute a command.
5165    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5166        self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5167        match cmd {
5168            Cmd::None => {}
5169            Cmd::Quit => self.running = false,
5170            Cmd::Msg(m) => {
5171                let start = Instant::now();
5172                let cmd = self.model.update(m);
5173                let elapsed_us = start.elapsed().as_micros() as u64;
5174                self.last_update_us = Some(elapsed_us);
5175                self.mark_dirty();
5176                self.execute_cmd(cmd)?;
5177            }
5178            Cmd::Batch(cmds) => {
5179                // Batch currently executes sequentially. This is intentional
5180                // until an async runtime or task scheduler is added.
5181                for c in cmds {
5182                    self.execute_cmd(c)?;
5183                    if !self.running {
5184                        break;
5185                    }
5186                }
5187            }
5188            Cmd::Sequence(cmds) => {
5189                for c in cmds {
5190                    self.execute_cmd(c)?;
5191                    if !self.running {
5192                        break;
5193                    }
5194                }
5195            }
5196            Cmd::Tick(duration) => {
5197                self.tick_rate = Some(duration);
5198                self.last_tick = Instant::now();
5199            }
5200            Cmd::Log(text) => {
5201                let sanitized = sanitize(&text);
5202                let mut text_crlf = if sanitized.contains('\n') {
5203                    sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5204                } else {
5205                    sanitized.into_owned()
5206                };
5207                if !text_crlf.ends_with("\r\n") {
5208                    if text_crlf.ends_with('\n') {
5209                        text_crlf.pop();
5210                    }
5211                    text_crlf.push_str("\r\n");
5212                }
5213                self.writer.write_log(&text_crlf)?;
5214            }
5215            Cmd::Task(spec, f) => {
5216                crate::effect_system::record_command_effect("task", 0);
5217                self.task_executor.submit(spec, f);
5218            }
5219            Cmd::SaveState => {
5220                self.save_state();
5221            }
5222            Cmd::RestoreState => {
5223                self.load_state();
5224            }
5225            Cmd::SetMouseCapture(enabled) => {
5226                self.backend_features.mouse_capture = enabled;
5227                self.events.set_features(self.backend_features)?;
5228            }
5229            Cmd::SetTickStrategy(strategy) => {
5230                let new_name = strategy.name().to_owned();
5231                if let Some(mut previous) = self.tick_strategy.replace(strategy) {
5232                    let old_name = previous.name().to_owned();
5233                    previous.shutdown();
5234                    info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
5235                } else {
5236                    info!(new = %new_name, "tick strategy changed at runtime");
5237                }
5238                self.last_active_screen_for_strategy = None;
5239            }
5240        }
5241        Ok(())
5242    }
5243
5244    /// Detect active-screen transitions after any `update()` call and react:
5245    ///
5246    /// - **A.2** — notify the tick strategy via `on_screen_transition()` so
5247    ///   predictive strategies can learn navigation patterns.
5248    /// - **D.3** — force-tick the newly active screen so it renders fresh
5249    ///   content immediately, without waiting for the next tick interval.
5250    ///
5251    /// This is a no-op when no tick strategy is configured or when the model
5252    /// does not implement [`ScreenTickDispatch`].
5253    fn check_screen_transition(&mut self) {
5254        if self.tick_strategy.is_none() {
5255            return;
5256        }
5257
5258        // Snapshot the current active screen (releases &mut self.model).
5259        let current_active = match self.model.as_screen_tick_dispatch() {
5260            Some(dispatch) => dispatch.active_screen_id(),
5261            None => return,
5262        };
5263
5264        // First observation: just record, no transition event.
5265        let previous = match self.last_active_screen_for_strategy.take() {
5266            Some(prev) => prev,
5267            None => {
5268                self.last_active_screen_for_strategy = Some(current_active);
5269                return;
5270            }
5271        };
5272
5273        if previous == current_active {
5274            self.last_active_screen_for_strategy = Some(current_active);
5275            return;
5276        }
5277
5278        // A.2: Notify strategy of the transition.
5279        if let Some(strategy) = self.tick_strategy.as_mut() {
5280            strategy.on_screen_transition(&previous, &current_active);
5281        }
5282
5283        // D.3: Force-tick the newly active screen immediately.
5284        let mut force_ticked = false;
5285        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5286            dispatch.tick_screen(&current_active, self.tick_count);
5287            force_ticked = true;
5288        }
5289        if force_ticked && self.running {
5290            self.reconcile_subscriptions();
5291        }
5292
5293        self.last_active_screen_for_strategy = Some(current_active);
5294        self.mark_dirty();
5295    }
5296
5297    fn reap_finished_tasks(&mut self) {
5298        self.task_executor.reap_finished();
5299    }
5300
5301    fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
5302        while let Ok(msg) = self.task_receiver.try_recv() {
5303            let cmd = {
5304                let _span = debug_span!(
5305                    "ftui.program.update",
5306                    msg_type = "shutdown_task",
5307                    duration_us = tracing::field::Empty,
5308                    cmd_type = tracing::field::Empty
5309                )
5310                .entered();
5311                let start = Instant::now();
5312                let cmd = self.model.update(msg);
5313                let elapsed_us = start.elapsed().as_micros() as u64;
5314                self.last_update_us = Some(elapsed_us);
5315                tracing::Span::current().record("duration_us", elapsed_us);
5316                tracing::Span::current()
5317                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5318                cmd
5319            };
5320            self.mark_dirty();
5321            self.execute_cmd(cmd)?;
5322        }
5323        Ok(())
5324    }
5325
5326    /// Render a frame with budget tracking.
5327    fn render_frame(&mut self) -> io::Result<()> {
5328        crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
5329
5330        self.frame_idx = self.frame_idx.wrapping_add(1);
5331        let frame_idx = self.frame_idx;
5332        let degradation_start = self.budget.degradation();
5333
5334        // Reset budget for new frame, potentially upgrading quality
5335        self.budget.next_frame();
5336
5337        // Check frame guardrails (memory/queue limits)
5338        let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
5339        // Synchronous program has effectively zero queue depth.
5340        let verdict = self.guardrails.check_frame(memory_bytes, 0);
5341
5342        if verdict.should_drop_frame() {
5343            // Emergency shed: skip this frame entirely to prevent OOM
5344            return Ok(());
5345        }
5346
5347        if verdict.should_degrade() {
5348            // Apply guardrail-recommended degradation if it's stricter than budget's
5349            let current = self.budget.degradation();
5350            if verdict.recommended_level > current {
5351                self.budget.set_degradation(verdict.recommended_level);
5352            }
5353        }
5354
5355        // Apply conformal risk gate before rendering (if enabled)
5356        let mut conformal_prediction = None;
5357        if let Some(predictor) = self.conformal_predictor.as_ref() {
5358            let baseline_us = self
5359                .last_frame_time_us
5360                .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5361            let diff_strategy = self
5362                .writer
5363                .last_diff_strategy()
5364                .unwrap_or(DiffStrategy::Full);
5365            let frame_height_hint = self.writer.render_height_hint().max(1);
5366            let key = BucketKey::from_context(
5367                self.writer.screen_mode(),
5368                diff_strategy,
5369                self.width,
5370                frame_height_hint,
5371            );
5372            let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
5373            let prediction = predictor.predict(key, baseline_us, budget_us);
5374            if prediction.risk {
5375                self.budget.degrade();
5376                info!(
5377                    bucket = %prediction.bucket,
5378                    upper_us = prediction.upper_us,
5379                    budget_us = prediction.budget_us,
5380                    fallback_level = prediction.fallback_level,
5381                    degradation = self.budget.degradation().as_str(),
5382                    "conformal gate triggered strategy downgrade"
5383                );
5384                debug!(
5385                    monotonic.counter.conformal_gate_triggers_total = 1_u64,
5386                    bucket = %prediction.bucket,
5387                    "conformal gate trigger"
5388                );
5389            }
5390            debug!(
5391                bucket = %prediction.bucket,
5392                upper_us = prediction.upper_us,
5393                budget_us = prediction.budget_us,
5394                fallback = prediction.fallback_level,
5395                risk = prediction.risk,
5396                "conformal risk gate"
5397            );
5398            debug!(
5399                monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
5400                bucket = %prediction.bucket,
5401                "conformal prediction interval width"
5402            );
5403            conformal_prediction = Some(prediction);
5404        }
5405
5406        // Early skip if budget says to skip this frame entirely
5407        if self.budget.exhausted() {
5408            self.budget.record_frame_time(Duration::ZERO);
5409            self.emit_budget_evidence(
5410                frame_idx,
5411                degradation_start,
5412                0.0,
5413                conformal_prediction.as_ref(),
5414            );
5415            crate::debug_trace!(
5416                "frame skipped: budget exhausted (degradation={})",
5417                self.budget.degradation().as_str()
5418            );
5419            debug!(
5420                degradation = self.budget.degradation().as_str(),
5421                "frame skipped: budget exhausted before render"
5422            );
5423            // Keep dirty=true: the UI update was never presented, so a
5424            // future frame must still pick it up.
5425            return Ok(());
5426        }
5427
5428        let auto_bounds = self.writer.inline_auto_bounds();
5429        let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
5430        let mut should_measure = needs_measure;
5431        if auto_bounds.is_some()
5432            && let Some(state) = self.inline_auto_remeasure.as_mut()
5433        {
5434            let decision = state.sampler.decide(Instant::now());
5435            if decision.should_sample {
5436                should_measure = true;
5437            }
5438        } else {
5439            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
5440        }
5441
5442        // --- Render phase ---
5443        let render_start = Instant::now();
5444        if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
5445            let measure_height = if needs_measure {
5446                self.writer.render_height_hint().max(1)
5447            } else {
5448                max_height.max(1)
5449            };
5450            let (measure_buffer, _) = self.render_measure_buffer(measure_height);
5451            let measured_height = measure_buffer.content_height();
5452            let clamped = measured_height.clamp(min_height, max_height);
5453            let previous_height = self.writer.auto_ui_height();
5454            self.writer.set_auto_ui_height(clamped);
5455            if let Some(state) = self.inline_auto_remeasure.as_mut() {
5456                let threshold = state.config.change_threshold_rows;
5457                let violated = previous_height
5458                    .map(|prev| prev.abs_diff(clamped) >= threshold)
5459                    .unwrap_or(false);
5460                state.sampler.observe(violated);
5461            }
5462        }
5463        if auto_bounds.is_some()
5464            && let Some(state) = self.inline_auto_remeasure.as_ref()
5465        {
5466            let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
5467            crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
5468        }
5469
5470        let frame_height = self.writer.render_height_hint().max(1);
5471        let _frame_span = info_span!(
5472            "ftui.render.frame",
5473            width = self.width,
5474            height = frame_height,
5475            duration_us = tracing::field::Empty
5476        )
5477        .entered();
5478        let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
5479        self.update_widget_refresh_plan(frame_idx);
5480        let render_elapsed = render_start.elapsed();
5481        let mut present_elapsed = Duration::ZERO;
5482        let mut presented = false;
5483
5484        // Check if render phase overspent its budget
5485        let render_budget = self.budget.phase_budgets().render;
5486        if render_elapsed > render_budget {
5487            debug!(
5488                render_ms = render_elapsed.as_millis() as u32,
5489                budget_ms = render_budget.as_millis() as u32,
5490                "render phase exceeded budget"
5491            );
5492            // With the load governor active, the controller decides degradation
5493            // from measured frame history at the next frame boundary. The
5494            // legacy path keeps its immediate threshold fallback.
5495            if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
5496                self.budget.degrade();
5497            }
5498        }
5499
5500        // --- Present phase ---
5501        if !self.budget.exhausted() {
5502            let present_start = Instant::now();
5503            {
5504                let _present_span = debug_span!("ftui.render.present").entered();
5505                self.writer
5506                    .present_ui_owned(buffer, cursor, cursor_visible)?;
5507            }
5508            presented = true;
5509            present_elapsed = present_start.elapsed();
5510
5511            let present_budget = self.budget.phase_budgets().present;
5512            if present_elapsed > present_budget {
5513                debug!(
5514                    present_ms = present_elapsed.as_millis() as u32,
5515                    budget_ms = present_budget.as_millis() as u32,
5516                    "present phase exceeded budget"
5517                );
5518            }
5519        } else {
5520            debug!(
5521                degradation = self.budget.degradation().as_str(),
5522                elapsed_ms = self.budget.elapsed().as_millis() as u32,
5523                "frame present skipped: budget exhausted after render"
5524            );
5525        }
5526
5527        if let Some(ref frame_timing) = self.frame_timing {
5528            let update_us = self.last_update_us.unwrap_or(0);
5529            let render_us = render_elapsed.as_micros() as u64;
5530            let present_us = present_elapsed.as_micros() as u64;
5531            let diff_us = if presented {
5532                self.writer
5533                    .take_last_present_timings()
5534                    .map(|timings| timings.diff_us)
5535                    .unwrap_or(0)
5536            } else {
5537                let _ = self.writer.take_last_present_timings();
5538                0
5539            };
5540            let total_us = update_us
5541                .saturating_add(render_us)
5542                .saturating_add(present_us);
5543            let timing = FrameTiming {
5544                frame_idx,
5545                update_us,
5546                render_us,
5547                diff_us,
5548                present_us,
5549                total_us,
5550            };
5551            frame_timing.sink.record_frame(&timing);
5552        }
5553
5554        let frame_time = render_elapsed.saturating_add(present_elapsed);
5555        self.budget.record_frame_time(frame_time);
5556        let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
5557
5558        if let (Some(predictor), Some(prediction)) = (
5559            self.conformal_predictor.as_mut(),
5560            conformal_prediction.as_ref(),
5561        ) {
5562            let diff_strategy = self
5563                .writer
5564                .last_diff_strategy()
5565                .unwrap_or(DiffStrategy::Full);
5566            let key = BucketKey::from_context(
5567                self.writer.screen_mode(),
5568                diff_strategy,
5569                self.width,
5570                frame_height,
5571            );
5572            predictor.observe(key, prediction.y_hat, frame_time_us);
5573        }
5574        self.last_frame_time_us = Some(frame_time_us);
5575        self.emit_budget_evidence(
5576            frame_idx,
5577            degradation_start,
5578            frame_time_us,
5579            conformal_prediction.as_ref(),
5580        );
5581
5582        // Only clear dirty when the frame was actually presented.
5583        // If present was skipped (budget exhausted after render), the UI
5584        // update was never shown and must be retried on the next frame.
5585        if presented {
5586            self.dirty = false;
5587        }
5588
5589        Ok(())
5590    }
5591
5592    fn emit_budget_evidence(
5593        &self,
5594        frame_idx: u64,
5595        degradation_start: DegradationLevel,
5596        frame_time_us: f64,
5597        conformal_prediction: Option<&ConformalPrediction>,
5598    ) {
5599        let Some(telemetry) = self.budget.telemetry() else {
5600            set_budget_snapshot(None);
5601            return;
5602        };
5603
5604        let budget_us = conformal_prediction
5605            .map(|prediction| prediction.budget_us)
5606            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5607        let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
5608        let degradation_after = self.budget.degradation();
5609
5610        let evidence = BudgetDecisionEvidence {
5611            frame_idx,
5612            decision: BudgetDecisionEvidence::decision_from_levels(
5613                degradation_start,
5614                degradation_after,
5615            ),
5616            controller_decision: telemetry.last_decision,
5617            degradation_before: degradation_start,
5618            degradation_after,
5619            frame_time_us,
5620            budget_us,
5621            pid_output: telemetry.pid_output,
5622            pid_p: telemetry.pid_p,
5623            pid_i: telemetry.pid_i,
5624            pid_d: telemetry.pid_d,
5625            e_value: telemetry.e_value,
5626            frames_observed: telemetry.frames_observed,
5627            frames_since_change: telemetry.frames_since_change,
5628            in_warmup: telemetry.in_warmup,
5629            conformal,
5630        };
5631
5632        let conformal_snapshot = evidence
5633            .conformal
5634            .as_ref()
5635            .map(|snapshot| ConformalSnapshot {
5636                bucket_key: snapshot.bucket_key.clone(),
5637                sample_count: snapshot.n_b,
5638                upper_us: snapshot.upper_us,
5639                risk: snapshot.risk,
5640            });
5641        set_budget_snapshot(Some(BudgetDecisionSnapshot {
5642            frame_idx: evidence.frame_idx,
5643            decision: evidence.decision,
5644            controller_decision: evidence.controller_decision,
5645            degradation_before: evidence.degradation_before,
5646            degradation_after: evidence.degradation_after,
5647            frame_time_us: evidence.frame_time_us,
5648            budget_us: evidence.budget_us,
5649            pid_output: evidence.pid_output,
5650            e_value: evidence.e_value,
5651            frames_observed: evidence.frames_observed,
5652            frames_since_change: evidence.frames_since_change,
5653            in_warmup: evidence.in_warmup,
5654            conformal: conformal_snapshot,
5655        }));
5656
5657        if let Some(ref sink) = self.evidence_sink {
5658            let _ = sink.write_jsonl(&evidence.to_jsonl());
5659        }
5660    }
5661
5662    fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
5663        if !self.widget_refresh_config.enabled {
5664            self.widget_refresh_plan.clear();
5665            return;
5666        }
5667
5668        let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
5669        let degradation = self.budget.degradation();
5670        self.widget_refresh_plan.recompute(
5671            frame_idx,
5672            budget_us,
5673            degradation,
5674            &self.widget_signals,
5675            &self.widget_refresh_config,
5676        );
5677
5678        if let Some(ref sink) = self.evidence_sink {
5679            let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
5680        }
5681    }
5682
5683    fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
5684        // Reset the per-frame arena so widgets get fresh scratch space.
5685        self.frame_arena.reset();
5686
5687        // Note: Frame borrows the pool and links from writer.
5688        // We scope it so it drops before we call present_ui (which needs exclusive writer access).
5689        let buffer = self.writer.take_render_buffer(self.width, frame_height);
5690        let (pool, links) = self.writer.pool_and_links_mut();
5691        let mut frame = Frame::from_buffer(buffer, pool);
5692        frame.set_degradation(self.budget.degradation());
5693        frame.set_links(links);
5694        frame.set_widget_budget(self.widget_refresh_plan.as_budget());
5695        frame.set_arena(&self.frame_arena);
5696
5697        let view_start = Instant::now();
5698        let _view_span = debug_span!(
5699            "ftui.program.view",
5700            duration_us = tracing::field::Empty,
5701            widget_count = tracing::field::Empty
5702        )
5703        .entered();
5704        self.model.view(&mut frame);
5705        self.widget_signals = frame.take_widget_signals();
5706        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
5707        // widget_count would require tracking in Frame
5708
5709        (frame.buffer, frame.cursor_position, frame.cursor_visible)
5710    }
5711
5712    fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
5713        let Some(ref sink) = self.evidence_sink else {
5714            return;
5715        };
5716
5717        let config = self.fairness_guard.config();
5718        if !self.fairness_config_logged {
5719            let config_entry = FairnessConfigEvidence {
5720                enabled: config.enabled,
5721                input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
5722                dominance_threshold: config.dominance_threshold,
5723                fairness_threshold: config.fairness_threshold,
5724            };
5725            let _ = sink.write_jsonl(&config_entry.to_jsonl());
5726            self.fairness_config_logged = true;
5727        }
5728
5729        let evidence = FairnessDecisionEvidence {
5730            frame_idx: self.frame_idx,
5731            decision: if decision.should_process {
5732                "allow"
5733            } else {
5734                "yield"
5735            },
5736            reason: decision.reason.as_str(),
5737            pending_input_latency_ms: decision
5738                .pending_input_latency
5739                .map(|latency| latency.as_millis() as u64),
5740            jain_index: decision.jain_index,
5741            resize_dominance_count: dominance_count,
5742            dominance_threshold: config.dominance_threshold,
5743            fairness_threshold: config.fairness_threshold,
5744            input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
5745        };
5746
5747        let _ = sink.write_jsonl(&evidence.to_jsonl());
5748    }
5749
5750    fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
5751        // Reset the per-frame arena for measurement pass.
5752        self.frame_arena.reset();
5753
5754        let pool = self.writer.pool_mut();
5755        let mut frame = Frame::new(self.width, frame_height, pool);
5756        frame.set_degradation(self.budget.degradation());
5757        frame.set_arena(&self.frame_arena);
5758
5759        let view_start = Instant::now();
5760        let _view_span = debug_span!(
5761            "ftui.program.view",
5762            duration_us = tracing::field::Empty,
5763            widget_count = tracing::field::Empty
5764        )
5765        .entered();
5766        self.model.view(&mut frame);
5767        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
5768
5769        (frame.buffer, frame.cursor_position)
5770    }
5771
5772    /// Calculate the effective poll timeout.
5773    fn effective_timeout(&self) -> Duration {
5774        if let Some(tick_rate) = self.tick_rate {
5775            let elapsed = self.last_tick.elapsed();
5776            let mut timeout = tick_rate.saturating_sub(elapsed);
5777            if self.resize_behavior.uses_coalescer()
5778                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
5779            {
5780                timeout = timeout.min(resize_timeout);
5781            }
5782            timeout
5783        } else {
5784            let mut timeout = self.poll_timeout;
5785            if self.resize_behavior.uses_coalescer()
5786                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
5787            {
5788                timeout = timeout.min(resize_timeout);
5789            }
5790            timeout
5791        }
5792    }
5793
5794    /// Check if we should send a tick.
5795    fn should_tick(&mut self) -> bool {
5796        if let Some(tick_rate) = self.tick_rate
5797            && self.last_tick.elapsed() >= tick_rate
5798        {
5799            self.last_tick = Instant::now();
5800            return true;
5801        }
5802        false
5803    }
5804
5805    fn process_resize_coalescer(&mut self) -> io::Result<()> {
5806        if !self.resize_behavior.uses_coalescer() {
5807            return Ok(());
5808        }
5809
5810        // Check fairness: if input is starving, skip resize application this cycle.
5811        // This ensures input events are processed before resize is finalized.
5812        let dominance_count = self.fairness_guard.resize_dominance_count();
5813        let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
5814        self.emit_fairness_evidence(&fairness_decision, dominance_count);
5815        if !fairness_decision.should_process {
5816            debug!(
5817                reason = ?fairness_decision.reason,
5818                pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
5819                "Resize yielding to input for fairness"
5820            );
5821            // Skip resize application this cycle to allow input processing.
5822            return Ok(());
5823        }
5824
5825        let action = self.resize_coalescer.tick();
5826        let resize_snapshot =
5827            self.resize_coalescer
5828                .logs()
5829                .last()
5830                .map(|entry| ResizeDecisionSnapshot {
5831                    event_idx: entry.event_idx,
5832                    action: entry.action,
5833                    dt_ms: entry.dt_ms,
5834                    event_rate: entry.event_rate,
5835                    regime: entry.regime,
5836                    pending_size: entry.pending_size,
5837                    applied_size: entry.applied_size,
5838                    time_since_render_ms: entry.time_since_render_ms,
5839                    bocpd: self
5840                        .resize_coalescer
5841                        .bocpd()
5842                        .and_then(|detector| detector.last_evidence().cloned()),
5843                });
5844        set_resize_snapshot(resize_snapshot);
5845
5846        match action {
5847            CoalesceAction::ApplyResize {
5848                width,
5849                height,
5850                coalesce_time,
5851                forced_by_deadline,
5852            } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
5853            _ => Ok(()),
5854        }
5855    }
5856
5857    fn apply_resize(
5858        &mut self,
5859        width: u16,
5860        height: u16,
5861        coalesce_time: Duration,
5862        forced_by_deadline: bool,
5863    ) -> io::Result<()> {
5864        // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
5865        let width = width.max(1);
5866        let height = height.max(1);
5867        self.width = width;
5868        self.height = height;
5869        self.writer.set_size(width, height);
5870        info!(
5871            width = width,
5872            height = height,
5873            coalesce_ms = coalesce_time.as_millis() as u64,
5874            forced = forced_by_deadline,
5875            "Resize applied"
5876        );
5877
5878        let msg = M::Message::from(Event::Resize { width, height });
5879        let start = Instant::now();
5880        let cmd = self.model.update(msg);
5881        let elapsed_us = start.elapsed().as_micros() as u64;
5882        self.last_update_us = Some(elapsed_us);
5883        self.mark_dirty();
5884        self.execute_cmd(cmd)?;
5885        if self.running && self.dirty {
5886            self.reconcile_subscriptions();
5887        }
5888        Ok(())
5889    }
5890
5891    // removed: resize placeholder rendering (continuous reflow preferred)
5892
5893    /// Get a reference to the model.
5894    pub fn model(&self) -> &M {
5895        &self.model
5896    }
5897
5898    /// Get a mutable reference to the model.
5899    pub fn model_mut(&mut self) -> &mut M {
5900        &mut self.model
5901    }
5902
5903    /// Check if the program is running.
5904    pub fn is_running(&self) -> bool {
5905        self.running
5906    }
5907
5908    /// Get the current tick rate, if one has been installed.
5909    #[must_use]
5910    pub const fn tick_rate(&self) -> Option<Duration> {
5911        self.tick_rate
5912    }
5913
5914    /// Get the number of commands actually executed by the runtime.
5915    #[must_use]
5916    pub const fn executed_cmd_count(&self) -> usize {
5917        self.executed_cmd_count
5918    }
5919
5920    /// Request a quit.
5921    pub fn quit(&mut self) {
5922        self.running = false;
5923    }
5924
5925    /// Get a reference to the state registry, if configured.
5926    pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
5927        self.state_registry.as_ref()
5928    }
5929
5930    /// Check if state persistence is enabled.
5931    pub fn has_persistence(&self) -> bool {
5932        self.state_registry.is_some()
5933    }
5934
5935    /// Query the current tick strategy's debug statistics.
5936    ///
5937    /// Returns key-value pairs describing the strategy's internal state
5938    /// (e.g. strategy name, divisors, confidence, transition counts).
5939    /// Returns an empty vec if no tick strategy is configured.
5940    #[must_use]
5941    pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
5942        self.tick_strategy
5943            .as_ref()
5944            .map(|s| s.debug_stats())
5945            .unwrap_or_default()
5946    }
5947
5948    /// Trigger a manual save of widget state.
5949    ///
5950    /// Returns the result of the flush operation, or `Ok(false)` if
5951    /// persistence is not configured.
5952    pub fn trigger_save(&mut self) -> StorageResult<bool> {
5953        if let Some(registry) = &self.state_registry {
5954            registry.flush()
5955        } else {
5956            Ok(false)
5957        }
5958    }
5959
5960    /// Trigger a manual load of widget state.
5961    ///
5962    /// Returns the number of entries loaded, or `Ok(0)` if persistence
5963    /// is not configured.
5964    pub fn trigger_load(&mut self) -> StorageResult<usize> {
5965        if let Some(registry) = &self.state_registry {
5966            registry.load()
5967        } else {
5968            Ok(0)
5969        }
5970    }
5971
5972    fn mark_dirty(&mut self) {
5973        self.dirty = true;
5974    }
5975
5976    fn check_locale_change(&mut self) {
5977        let version = self.locale_context.version();
5978        if version != self.locale_version {
5979            self.locale_version = version;
5980            self.mark_dirty();
5981        }
5982    }
5983
5984    /// Mark the UI as needing redraw.
5985    pub fn request_redraw(&mut self) {
5986        self.mark_dirty();
5987    }
5988
5989    /// Request a re-measure of inline auto UI height on next render.
5990    pub fn request_ui_height_remeasure(&mut self) {
5991        if self.writer.inline_auto_bounds().is_some() {
5992            self.writer.clear_auto_ui_height();
5993            if let Some(state) = self.inline_auto_remeasure.as_mut() {
5994                state.reset();
5995            }
5996            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
5997            self.mark_dirty();
5998        }
5999    }
6000
6001    /// Start recording events into a macro.
6002    ///
6003    /// If already recording, the current recording is discarded and a new one starts.
6004    /// The current terminal size is captured as metadata.
6005    pub fn start_recording(&mut self, name: impl Into<String>) {
6006        let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
6007        recorder.start();
6008        self.event_recorder = Some(recorder);
6009    }
6010
6011    /// Stop recording and return the recorded macro, if any.
6012    ///
6013    /// Returns `None` if not currently recording.
6014    pub fn stop_recording(&mut self) -> Option<InputMacro> {
6015        self.event_recorder.take().map(EventRecorder::finish)
6016    }
6017
6018    /// Check if event recording is active.
6019    pub fn is_recording(&self) -> bool {
6020        self.event_recorder
6021            .as_ref()
6022            .is_some_and(EventRecorder::is_recording)
6023    }
6024}
6025
6026/// Builder for creating and running programs.
6027pub struct App;
6028
6029impl App {
6030    /// Create a new app builder with the given model.
6031    #[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
6032    pub fn new<M: Model>(model: M) -> AppBuilder<M> {
6033        AppBuilder {
6034            model,
6035            config: ProgramConfig::default(),
6036        }
6037    }
6038
6039    /// Create a fullscreen app.
6040    pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
6041        AppBuilder {
6042            model,
6043            config: ProgramConfig::fullscreen(),
6044        }
6045    }
6046
6047    /// Create an inline app with the given height.
6048    pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
6049        AppBuilder {
6050            model,
6051            config: ProgramConfig::inline(height),
6052        }
6053    }
6054
6055    /// Create an inline app with automatic UI height.
6056    pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
6057        AppBuilder {
6058            model,
6059            config: ProgramConfig::inline_auto(min_height, max_height),
6060        }
6061    }
6062
6063    /// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
6064    ///
6065    /// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
6066    /// so that `view_string()` output is rendered through the standard kernel pipeline.
6067    pub fn string_model<S: crate::string_model::StringModel>(
6068        model: S,
6069    ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
6070        AppBuilder {
6071            model: crate::string_model::StringModelAdapter::new(model),
6072            config: ProgramConfig::fullscreen(),
6073        }
6074    }
6075}
6076
6077/// Builder for configuring and running programs.
6078#[must_use]
6079pub struct AppBuilder<M: Model> {
6080    model: M,
6081    config: ProgramConfig,
6082}
6083
6084impl<M: Model> AppBuilder<M> {
6085    /// Set the screen mode.
6086    pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6087        self.config.screen_mode = mode;
6088        self
6089    }
6090
6091    /// Set the UI anchor.
6092    pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6093        self.config.ui_anchor = anchor;
6094        self
6095    }
6096
6097    /// Force mouse capture on.
6098    pub fn with_mouse(mut self) -> Self {
6099        self.config.mouse_capture_policy = MouseCapturePolicy::On;
6100        self
6101    }
6102
6103    /// Set mouse capture policy for this app.
6104    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6105        self.config.mouse_capture_policy = policy;
6106        self
6107    }
6108
6109    /// Force mouse capture enabled/disabled for this app.
6110    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6111        self.config.mouse_capture_policy = if enabled {
6112            MouseCapturePolicy::On
6113        } else {
6114            MouseCapturePolicy::Off
6115        };
6116        self
6117    }
6118
6119    /// Set the frame budget configuration.
6120    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6121        self.config.budget = budget;
6122        self
6123    }
6124
6125    /// Set the runtime load-governor configuration.
6126    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
6127        self.config.load_governor = config;
6128        self
6129    }
6130
6131    /// Disable the adaptive load governor for this app.
6132    pub fn without_load_governor(mut self) -> Self {
6133        self.config.load_governor = LoadGovernorConfig::disabled();
6134        self
6135    }
6136
6137    /// Set the evidence JSONL sink configuration.
6138    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
6139        self.config.evidence_sink = config;
6140        self
6141    }
6142
6143    /// Set the render-trace recorder configuration.
6144    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
6145        self.config.render_trace = config;
6146        self
6147    }
6148
6149    /// Set the widget refresh selection configuration.
6150    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
6151        self.config.widget_refresh = config;
6152        self
6153    }
6154
6155    /// Set the effect queue scheduling configuration.
6156    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
6157        self.config.effect_queue = config;
6158        self
6159    }
6160
6161    /// Enable inline auto UI height remeasurement.
6162    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
6163        self.config.inline_auto_remeasure = Some(config);
6164        self
6165    }
6166
6167    /// Disable inline auto UI height remeasurement.
6168    pub fn without_inline_auto_remeasure(mut self) -> Self {
6169        self.config.inline_auto_remeasure = None;
6170        self
6171    }
6172
6173    /// Set the locale context used for rendering.
6174    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
6175        self.config.locale_context = locale_context;
6176        self
6177    }
6178
6179    /// Set the base locale used for rendering.
6180    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
6181        self.config.locale_context = LocaleContext::new(locale);
6182        self
6183    }
6184
6185    /// Set the resize coalescer configuration.
6186    pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
6187        self.config.resize_coalescer = config;
6188        self
6189    }
6190
6191    /// Set the resize handling behavior.
6192    pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
6193        self.config.resize_behavior = behavior;
6194        self
6195    }
6196
6197    /// Toggle legacy immediate-resize behavior for migration.
6198    pub fn legacy_resize(mut self, enabled: bool) -> Self {
6199        if enabled {
6200            self.config.resize_behavior = ResizeBehavior::Immediate;
6201        }
6202        self
6203    }
6204
6205    /// Set the tick strategy for selective background screen ticking.
6206    pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
6207        self.config.tick_strategy = Some(strategy);
6208        self
6209    }
6210
6211    /// Run the application using the legacy Crossterm backend.
6212    #[cfg(feature = "crossterm-compat")]
6213    pub fn run(self) -> io::Result<()>
6214    where
6215        M::Message: Send + 'static,
6216    {
6217        let mut program = Program::with_config(self.model, self.config)?;
6218        let result = program.run();
6219        if let Err(ref err) = result
6220            && let Some(signal) = signal_termination_from_error(err)
6221        {
6222            drop(program);
6223            std::process::exit(128 + signal);
6224        }
6225        result
6226    }
6227
6228    /// Run the application using the native TTY backend.
6229    #[cfg(feature = "native-backend")]
6230    pub fn run_native(self) -> io::Result<()>
6231    where
6232        M::Message: Send + 'static,
6233    {
6234        let mut program = Program::with_native_backend(self.model, self.config)?;
6235        let result = program.run();
6236        if let Err(ref err) = result
6237            && let Some(signal) = signal_termination_from_error(err)
6238        {
6239            drop(program);
6240            std::process::exit(128 + signal);
6241        }
6242        result
6243    }
6244
6245    /// Run the application using the legacy Crossterm backend.
6246    #[cfg(not(feature = "crossterm-compat"))]
6247    pub fn run(self) -> io::Result<()>
6248    where
6249        M::Message: Send + 'static,
6250    {
6251        let _ = (self.model, self.config);
6252        Err(io::Error::new(
6253            io::ErrorKind::Unsupported,
6254            "enable `crossterm-compat` feature to use AppBuilder::run()",
6255        ))
6256    }
6257
6258    /// Run the application using the native TTY backend.
6259    #[cfg(not(feature = "native-backend"))]
6260    pub fn run_native(self) -> io::Result<()>
6261    where
6262        M::Message: Send + 'static,
6263    {
6264        let _ = (self.model, self.config);
6265        Err(io::Error::new(
6266            io::ErrorKind::Unsupported,
6267            "enable `native-backend` feature to use AppBuilder::run_native()",
6268        ))
6269    }
6270}
6271
6272// =============================================================================
6273// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
6274// =============================================================================
6275//
6276// # M/G/1 Queueing Model for Event Batching
6277//
6278// ## Problem
6279//
6280// The event loop must balance two objectives:
6281// 1. **Low latency**: Process events quickly (small batch window τ).
6282// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
6283//
6284// ## Model
6285//
6286// We model the event loop as an M/G/1 queue:
6287// - Events arrive at rate λ (Poisson process, reasonable for human input).
6288// - Service time S has mean E[S] and variance Var[S] (render + present).
6289// - Utilization ρ = λ·E[S] must be < 1 for stability.
6290//
6291// ## Pollaczek–Khinchine Mean Waiting Time
6292//
6293// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
6294// where E[S²] = Var[S] + E[S]².
6295//
6296// ## Optimal Batch Window τ
6297//
6298// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
6299// the per-frame render cost. The effective per-event latency is:
6300//
6301//   L(τ) = τ/2 + E[S]
6302//         (waiting in batch)  (service)
6303//
6304// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
6305// giving utilization ρ_eff = E[S]/τ.
6306//
6307// Minimizing L(τ) subject to ρ_eff < 1:
6308//   L(τ) = τ/2 + E[S]
6309//   dL/dτ = 1/2  (always positive, so smaller τ is always better for latency)
6310//
6311// But we need ρ_eff < 1, so τ > E[S].
6312//
6313// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
6314// where headroom_factor provides margin (typically 1.5–2.0).
6315//
6316// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
6317// where λ_target is the max frame rate we want to sustain.
6318//
6319// ## Failure Modes
6320//
6321// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
6322//    (degrade to lower frame rate), or drop stale events.
6323// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
6324//    exponential moving average of λ smooths this; high burst periods
6325//    temporarily increase τ.
6326// 3. **Variable service time**: Render complexity varies per frame. Using
6327//    EMA of E[S] tracks this adaptively.
6328//
6329// ## Observable Telemetry
6330//
6331// - λ_est: Exponential moving average of inter-arrival times.
6332// - es_est: Exponential moving average of service (render) times.
6333// - ρ_est: λ_est × es_est (estimated utilization).
6334
6335/// Adaptive batch window controller based on M/G/1 queueing model.
6336///
6337/// Estimates arrival rate λ and service time E[S] from observations,
6338/// then computes the optimal batch window τ to maintain stability
6339/// (ρ < 1) while minimizing latency.
6340#[derive(Debug, Clone)]
6341pub struct BatchController {
6342    /// Exponential moving average of inter-arrival time (seconds).
6343    ema_inter_arrival_s: f64,
6344    /// Exponential moving average of service time (seconds).
6345    ema_service_s: f64,
6346    /// EMA smoothing factor (0..1). Higher = more responsive.
6347    alpha: f64,
6348    /// Minimum batch window (floor).
6349    tau_min_s: f64,
6350    /// Maximum batch window (cap for responsiveness).
6351    tau_max_s: f64,
6352    /// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
6353    headroom: f64,
6354    /// Last event arrival timestamp.
6355    last_arrival: Option<Instant>,
6356    /// Number of observations.
6357    observations: u64,
6358}
6359
6360impl BatchController {
6361    /// Create a new controller with sensible defaults.
6362    ///
6363    /// - `alpha`: EMA smoothing (default 0.2)
6364    /// - `tau_min`: minimum batch window (default 1ms)
6365    /// - `tau_max`: maximum batch window (default 50ms)
6366    /// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
6367    pub fn new() -> Self {
6368        Self {
6369            ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
6370            ema_service_s: 0.002,     // assume 2ms render initially
6371            alpha: 0.2,
6372            tau_min_s: 0.001, // 1ms floor
6373            tau_max_s: 0.050, // 50ms cap
6374            headroom: 2.0,
6375            last_arrival: None,
6376            observations: 0,
6377        }
6378    }
6379
6380    /// Record an event arrival, updating the inter-arrival estimate.
6381    pub fn observe_arrival(&mut self, now: Instant) {
6382        if let Some(last) = self.last_arrival {
6383            let dt = now.saturating_duration_since(last).as_secs_f64();
6384            if dt > 0.0 && dt < 10.0 {
6385                // Guard against stale gaps (e.g., app was suspended)
6386                self.ema_inter_arrival_s =
6387                    self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
6388                self.observations += 1;
6389            }
6390        }
6391        self.last_arrival = Some(now);
6392    }
6393
6394    /// Record a service (render) time observation.
6395    pub fn observe_service(&mut self, duration: Duration) {
6396        let dt = duration.as_secs_f64();
6397        if (0.0..10.0).contains(&dt) {
6398            self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
6399        }
6400    }
6401
6402    /// Estimated arrival rate λ (events/second).
6403    #[inline]
6404    pub fn lambda_est(&self) -> f64 {
6405        if self.ema_inter_arrival_s > 0.0 {
6406            1.0 / self.ema_inter_arrival_s
6407        } else {
6408            0.0
6409        }
6410    }
6411
6412    /// Estimated service time E[S] (seconds).
6413    #[inline]
6414    pub fn service_est_s(&self) -> f64 {
6415        self.ema_service_s
6416    }
6417
6418    /// Estimated utilization ρ = λ × E[S].
6419    #[inline]
6420    pub fn rho_est(&self) -> f64 {
6421        self.lambda_est() * self.ema_service_s
6422    }
6423
6424    /// Compute the optimal batch window τ (seconds).
6425    ///
6426    /// τ = clamp(E[S] × headroom, τ_min, τ_max)
6427    ///
6428    /// When ρ approaches 1, τ increases to maintain stability.
6429    pub fn tau_s(&self) -> f64 {
6430        let base = self.ema_service_s * self.headroom;
6431        base.clamp(self.tau_min_s, self.tau_max_s)
6432    }
6433
6434    /// Compute the optimal batch window as a Duration.
6435    pub fn tau(&self) -> Duration {
6436        Duration::from_secs_f64(self.tau_s())
6437    }
6438
6439    /// Check if the system is stable (ρ < 1).
6440    #[inline]
6441    pub fn is_stable(&self) -> bool {
6442        self.rho_est() < 1.0
6443    }
6444
6445    /// Number of observations recorded.
6446    #[inline]
6447    pub fn observations(&self) -> u64 {
6448        self.observations
6449    }
6450}
6451
6452impl Default for BatchController {
6453    fn default() -> Self {
6454        Self::new()
6455    }
6456}
6457
6458#[cfg(test)]
6459mod tests {
6460    use super::*;
6461    use ftui_core::terminal_capabilities::TerminalCapabilities;
6462    use ftui_layout::PaneDragResizeEffect;
6463    use ftui_render::buffer::Buffer;
6464    use ftui_render::cell::Cell;
6465    use ftui_render::diff_strategy::DiffStrategy;
6466    use ftui_render::frame::CostEstimateSource;
6467    use serde_json::Value;
6468    use std::collections::{HashMap, VecDeque};
6469    use std::path::PathBuf;
6470    use std::sync::mpsc;
6471    use std::sync::{
6472        Arc,
6473        atomic::{AtomicUsize, Ordering},
6474    };
6475
6476    // Simple test model
6477    struct TestModel {
6478        value: i32,
6479    }
6480
6481    #[derive(Debug)]
6482    enum TestMsg {
6483        Increment,
6484        Decrement,
6485        Quit,
6486    }
6487
6488    impl From<Event> for TestMsg {
6489        fn from(_event: Event) -> Self {
6490            TestMsg::Increment
6491        }
6492    }
6493
6494    impl Model for TestModel {
6495        type Message = TestMsg;
6496
6497        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6498            match msg {
6499                TestMsg::Increment => {
6500                    self.value += 1;
6501                    Cmd::none()
6502                }
6503                TestMsg::Decrement => {
6504                    self.value -= 1;
6505                    Cmd::none()
6506                }
6507                TestMsg::Quit => Cmd::quit(),
6508            }
6509        }
6510
6511        fn view(&self, _frame: &mut Frame) {
6512            // No-op for tests
6513        }
6514    }
6515
6516    #[test]
6517    fn cmd_none() {
6518        let cmd: Cmd<TestMsg> = Cmd::none();
6519        assert!(matches!(cmd, Cmd::None));
6520    }
6521
6522    #[test]
6523    fn cmd_quit() {
6524        let cmd: Cmd<TestMsg> = Cmd::quit();
6525        assert!(matches!(cmd, Cmd::Quit));
6526    }
6527
6528    #[test]
6529    fn cmd_msg() {
6530        let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
6531        assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
6532    }
6533
6534    #[test]
6535    fn cmd_batch_empty() {
6536        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
6537        assert!(matches!(cmd, Cmd::None));
6538    }
6539
6540    #[test]
6541    fn cmd_batch_single() {
6542        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
6543        assert!(matches!(cmd, Cmd::Quit));
6544    }
6545
6546    #[test]
6547    fn cmd_batch_multiple() {
6548        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
6549        assert!(matches!(cmd, Cmd::Batch(_)));
6550    }
6551
6552    #[test]
6553    fn cmd_sequence_empty() {
6554        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
6555        assert!(matches!(cmd, Cmd::None));
6556    }
6557
6558    #[test]
6559    fn cmd_tick() {
6560        let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
6561        assert!(matches!(cmd, Cmd::Tick(_)));
6562    }
6563
6564    #[test]
6565    fn cmd_task() {
6566        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
6567        assert!(matches!(cmd, Cmd::Task(..)));
6568    }
6569
6570    #[test]
6571    fn cmd_debug_format() {
6572        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
6573        let debug = format!("{cmd:?}");
6574        assert_eq!(
6575            debug,
6576            "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
6577        );
6578    }
6579
6580    #[test]
6581    fn model_subscriptions_default_empty() {
6582        let model = TestModel { value: 0 };
6583        let subs = model.subscriptions();
6584        assert!(subs.is_empty());
6585    }
6586
6587    #[test]
6588    fn program_config_default() {
6589        let config = ProgramConfig::default();
6590        assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
6591        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
6592        assert!(!config.resolved_mouse_capture());
6593        assert!(config.bracketed_paste);
6594        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
6595        assert!(config.inline_auto_remeasure.is_none());
6596        assert!(config.conformal_config.is_none());
6597        assert!(config.diff_config.bayesian_enabled);
6598        assert!(config.diff_config.dirty_rows_enabled);
6599        assert!(!config.resize_coalescer.enable_bocpd);
6600        assert!(!config.effect_queue.enabled);
6601        assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
6602        assert_eq!(
6603            config.immediate_drain.max_burst_duration,
6604            Duration::from_millis(2)
6605        );
6606        assert_eq!(
6607            config.immediate_drain.backoff_timeout,
6608            Duration::from_millis(1)
6609        );
6610        assert_eq!(
6611            config.resize_coalescer.steady_delay_ms,
6612            CoalescerConfig::default().steady_delay_ms
6613        );
6614    }
6615
6616    #[test]
6617    fn program_config_with_immediate_drain() {
6618        let custom = ImmediateDrainConfig {
6619            max_zero_timeout_polls_per_burst: 7,
6620            max_burst_duration: Duration::from_millis(9),
6621            backoff_timeout: Duration::from_millis(3),
6622        };
6623        let config = ProgramConfig::default().with_immediate_drain(custom.clone());
6624        assert_eq!(
6625            config.immediate_drain.max_zero_timeout_polls_per_burst,
6626            custom.max_zero_timeout_polls_per_burst
6627        );
6628        assert_eq!(
6629            config.immediate_drain.max_burst_duration,
6630            custom.max_burst_duration
6631        );
6632        assert_eq!(
6633            config.immediate_drain.backoff_timeout,
6634            custom.backoff_timeout
6635        );
6636    }
6637
6638    #[test]
6639    fn program_config_fullscreen() {
6640        let config = ProgramConfig::fullscreen();
6641        assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
6642    }
6643
6644    #[test]
6645    fn program_config_inline() {
6646        let config = ProgramConfig::inline(10);
6647        assert!(matches!(
6648            config.screen_mode,
6649            ScreenMode::Inline { ui_height: 10 }
6650        ));
6651    }
6652
6653    #[test]
6654    fn program_config_inline_auto() {
6655        let config = ProgramConfig::inline_auto(3, 9);
6656        assert!(matches!(
6657            config.screen_mode,
6658            ScreenMode::InlineAuto {
6659                min_height: 3,
6660                max_height: 9
6661            }
6662        ));
6663        assert!(config.inline_auto_remeasure.is_some());
6664    }
6665
6666    #[test]
6667    fn program_config_with_mouse() {
6668        let config = ProgramConfig::default().with_mouse();
6669        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
6670        assert!(config.resolved_mouse_capture());
6671    }
6672
6673    #[cfg(feature = "native-backend")]
6674    #[test]
6675    fn sanitize_backend_features_disables_unsupported_features() {
6676        let requested = BackendFeatures {
6677            mouse_capture: true,
6678            bracketed_paste: true,
6679            focus_events: true,
6680            kitty_keyboard: true,
6681        };
6682        let sanitized =
6683            sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
6684        assert_eq!(sanitized, BackendFeatures::default());
6685    }
6686
6687    #[cfg(feature = "native-backend")]
6688    #[test]
6689    fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
6690        let requested = BackendFeatures {
6691            mouse_capture: true,
6692            bracketed_paste: true,
6693            focus_events: true,
6694            kitty_keyboard: true,
6695        };
6696        let caps = TerminalCapabilities::builder()
6697            .mouse_sgr(true)
6698            .bracketed_paste(true)
6699            .focus_events(true)
6700            .kitty_keyboard(true)
6701            .in_wezterm_mux(true)
6702            .build();
6703        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
6704
6705        assert!(sanitized.mouse_capture);
6706        assert!(sanitized.bracketed_paste);
6707        assert!(!sanitized.focus_events);
6708        assert!(!sanitized.kitty_keyboard);
6709    }
6710
6711    #[cfg(feature = "native-backend")]
6712    #[test]
6713    fn sanitize_backend_features_is_conservative_in_tmux() {
6714        let requested = BackendFeatures {
6715            mouse_capture: true,
6716            bracketed_paste: true,
6717            focus_events: true,
6718            kitty_keyboard: true,
6719        };
6720        let caps = TerminalCapabilities::builder()
6721            .mouse_sgr(true)
6722            .bracketed_paste(true)
6723            .focus_events(true)
6724            .kitty_keyboard(true)
6725            .in_tmux(true)
6726            .build();
6727        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
6728
6729        assert!(sanitized.mouse_capture);
6730        assert!(sanitized.bracketed_paste);
6731        assert!(!sanitized.focus_events);
6732        assert!(!sanitized.kitty_keyboard);
6733    }
6734
6735    #[test]
6736    fn program_config_mouse_policy_auto_altscreen() {
6737        let config = ProgramConfig::fullscreen();
6738        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
6739        assert!(config.resolved_mouse_capture());
6740    }
6741
6742    #[test]
6743    fn program_config_mouse_policy_force_off() {
6744        let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
6745        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
6746        assert!(!config.resolved_mouse_capture());
6747    }
6748
6749    #[test]
6750    fn program_config_mouse_policy_force_on_inline() {
6751        let config = ProgramConfig::inline(6).with_mouse_enabled(true);
6752        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
6753        assert!(config.resolved_mouse_capture());
6754    }
6755
6756    fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
6757        PaneResizeTarget {
6758            split_id: ftui_layout::PaneId::MIN,
6759            axis,
6760        }
6761    }
6762
6763    fn pane_id(raw: u64) -> ftui_layout::PaneId {
6764        ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
6765    }
6766
6767    fn nested_pane_tree() -> ftui_layout::PaneTree {
6768        let root = pane_id(1);
6769        let left = pane_id(2);
6770        let right_split = pane_id(3);
6771        let right_top = pane_id(4);
6772        let right_bottom = pane_id(5);
6773        let snapshot = ftui_layout::PaneTreeSnapshot {
6774            schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
6775            root,
6776            next_id: pane_id(6),
6777            nodes: vec![
6778                ftui_layout::PaneNodeRecord::split(
6779                    root,
6780                    None,
6781                    ftui_layout::PaneSplit {
6782                        axis: SplitAxis::Horizontal,
6783                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
6784                        first: left,
6785                        second: right_split,
6786                    },
6787                ),
6788                ftui_layout::PaneNodeRecord::leaf(
6789                    left,
6790                    Some(root),
6791                    ftui_layout::PaneLeaf::new("left"),
6792                ),
6793                ftui_layout::PaneNodeRecord::split(
6794                    right_split,
6795                    Some(root),
6796                    ftui_layout::PaneSplit {
6797                        axis: SplitAxis::Vertical,
6798                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
6799                        first: right_top,
6800                        second: right_bottom,
6801                    },
6802                ),
6803                ftui_layout::PaneNodeRecord::leaf(
6804                    right_top,
6805                    Some(right_split),
6806                    ftui_layout::PaneLeaf::new("right_top"),
6807                ),
6808                ftui_layout::PaneNodeRecord::leaf(
6809                    right_bottom,
6810                    Some(right_split),
6811                    ftui_layout::PaneLeaf::new("right_bottom"),
6812                ),
6813            ],
6814            extensions: std::collections::BTreeMap::new(),
6815        };
6816        ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
6817    }
6818
6819    #[test]
6820    fn pane_terminal_splitter_resolution_is_deterministic() {
6821        let tree = nested_pane_tree();
6822        let layout = tree
6823            .solve_layout(Rect::new(0, 0, 50, 20))
6824            .expect("layout should solve");
6825        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
6826        assert_eq!(handles.len(), 2);
6827
6828        // Intersection between root vertical splitter and right-side horizontal
6829        // splitter deterministically resolves to smaller split ID.
6830        let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
6831            .expect("overlap cell should resolve");
6832        assert_eq!(overlap.split_id, pane_id(1));
6833        assert_eq!(overlap.axis, SplitAxis::Horizontal);
6834
6835        let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
6836            .expect("right split should resolve");
6837        assert_eq!(right_only.split_id, pane_id(3));
6838        assert_eq!(right_only.axis, SplitAxis::Vertical);
6839    }
6840
6841    #[test]
6842    fn pane_terminal_splitter_hits_register_and_decode_target() {
6843        let tree = nested_pane_tree();
6844        let layout = tree
6845            .solve_layout(Rect::new(0, 0, 50, 20))
6846            .expect("layout should solve");
6847        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
6848
6849        let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
6850        let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
6851        let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
6852        assert_eq!(registered, handles.len());
6853
6854        let root_hit = frame
6855            .hit_test(25, 2)
6856            .expect("root splitter should be hittable");
6857        assert_eq!(root_hit.1, HitRegion::Handle);
6858        let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
6859        assert_eq!(root_target.split_id, pane_id(1));
6860        assert_eq!(root_target.axis, SplitAxis::Horizontal);
6861
6862        let right_hit = frame
6863            .hit_test(40, 10)
6864            .expect("right splitter should be hittable");
6865        assert_eq!(right_hit.1, HitRegion::Handle);
6866        let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
6867        assert_eq!(right_target.split_id, pane_id(3));
6868        assert_eq!(right_target.axis, SplitAxis::Vertical);
6869    }
6870
6871    #[test]
6872    fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
6873        let mut adapter =
6874            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
6875        let target = pane_target(SplitAxis::Horizontal);
6876
6877        let down = Event::Mouse(MouseEvent::new(
6878            MouseEventKind::Down(MouseButton::Left),
6879            10,
6880            4,
6881        ));
6882        let down_dispatch = adapter.translate(&down, Some(target));
6883        let down_event = down_dispatch
6884            .primary_event
6885            .as_ref()
6886            .expect("pointer down semantic event");
6887        assert_eq!(down_event.sequence, 1);
6888        assert!(matches!(
6889            down_event.kind,
6890            PaneSemanticInputEventKind::PointerDown {
6891                target: actual_target,
6892                pointer_id: 1,
6893                button: PanePointerButton::Primary,
6894                position
6895            } if actual_target == target && position == PanePointerPosition::new(10, 4)
6896        ));
6897        assert!(down_event.validate().is_ok());
6898
6899        let drag = Event::Mouse(MouseEvent::new(
6900            MouseEventKind::Drag(MouseButton::Left),
6901            14,
6902            4,
6903        ));
6904        let drag_dispatch = adapter.translate(&drag, None);
6905        let drag_event = drag_dispatch
6906            .primary_event
6907            .as_ref()
6908            .expect("pointer move semantic event");
6909        assert_eq!(drag_event.sequence, 2);
6910        assert!(matches!(
6911            drag_event.kind,
6912            PaneSemanticInputEventKind::PointerMove {
6913                target: actual_target,
6914                pointer_id: 1,
6915                position,
6916                delta_x: 4,
6917                delta_y: 0
6918            } if actual_target == target && position == PanePointerPosition::new(14, 4)
6919        ));
6920        let drag_motion = drag_dispatch
6921            .motion
6922            .expect("drag should emit motion metadata");
6923        assert_eq!(drag_motion.delta_x, 4);
6924        assert_eq!(drag_motion.delta_y, 0);
6925        assert_eq!(drag_motion.direction_changes, 0);
6926        assert!(drag_motion.speed > 0.0);
6927        assert!(drag_dispatch.pressure_snap_profile().is_some());
6928
6929        let up = Event::Mouse(MouseEvent::new(
6930            MouseEventKind::Up(MouseButton::Left),
6931            14,
6932            4,
6933        ));
6934        let up_dispatch = adapter.translate(&up, None);
6935        let up_event = up_dispatch
6936            .primary_event
6937            .as_ref()
6938            .expect("pointer up semantic event");
6939        assert_eq!(up_event.sequence, 3);
6940        assert!(matches!(
6941            up_event.kind,
6942            PaneSemanticInputEventKind::PointerUp {
6943                target: actual_target,
6944                pointer_id: 1,
6945                button: PanePointerButton::Primary,
6946                position
6947            } if actual_target == target && position == PanePointerPosition::new(14, 4)
6948        ));
6949        let up_motion = up_dispatch
6950            .motion
6951            .expect("up should emit final motion metadata");
6952        assert_eq!(up_motion.delta_x, 4);
6953        assert_eq!(up_motion.delta_y, 0);
6954        assert_eq!(up_motion.direction_changes, 0);
6955        let inertial_throw = up_dispatch
6956            .inertial_throw
6957            .expect("up should emit inertial throw metadata");
6958        assert_eq!(
6959            up_dispatch.projected_position,
6960            Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
6961        );
6962        assert_eq!(adapter.active_pointer_id(), None);
6963        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
6964    }
6965
6966    #[test]
6967    fn pane_terminal_adapter_focus_loss_emits_cancel() {
6968        let mut adapter =
6969            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
6970        let target = pane_target(SplitAxis::Vertical);
6971
6972        let down = Event::Mouse(MouseEvent::new(
6973            MouseEventKind::Down(MouseButton::Left),
6974            3,
6975            9,
6976        ));
6977        let _ = adapter.translate(&down, Some(target));
6978        assert_eq!(adapter.active_pointer_id(), Some(1));
6979
6980        let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
6981        let cancel_event = cancel_dispatch
6982            .primary_event
6983            .as_ref()
6984            .expect("focus-loss cancel event");
6985        assert!(matches!(
6986            cancel_event.kind,
6987            PaneSemanticInputEventKind::Cancel {
6988                target: Some(actual_target),
6989                reason: PaneCancelReason::FocusLost
6990            } if actual_target == target
6991        ));
6992        assert_eq!(adapter.active_pointer_id(), None);
6993        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
6994    }
6995
6996    #[test]
6997    fn pane_terminal_adapter_recovers_missing_mouse_up() {
6998        let mut adapter =
6999            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7000        let first_target = pane_target(SplitAxis::Horizontal);
7001        let second_target = pane_target(SplitAxis::Vertical);
7002
7003        let first_down = Event::Mouse(MouseEvent::new(
7004            MouseEventKind::Down(MouseButton::Left),
7005            5,
7006            5,
7007        ));
7008        let _ = adapter.translate(&first_down, Some(first_target));
7009
7010        let second_down = Event::Mouse(MouseEvent::new(
7011            MouseEventKind::Down(MouseButton::Left),
7012            8,
7013            11,
7014        ));
7015        let dispatch = adapter.translate(&second_down, Some(second_target));
7016        let recovery = dispatch
7017            .recovery_event
7018            .as_ref()
7019            .expect("recovery cancel expected");
7020        assert!(matches!(
7021            recovery.kind,
7022            PaneSemanticInputEventKind::Cancel {
7023                target: Some(actual_target),
7024                reason: PaneCancelReason::PointerCancel
7025            } if actual_target == first_target
7026        ));
7027        let primary = dispatch
7028            .primary_event
7029            .as_ref()
7030            .expect("second pointer down expected");
7031        assert!(matches!(
7032            primary.kind,
7033            PaneSemanticInputEventKind::PointerDown {
7034                target: actual_target,
7035                pointer_id: 1,
7036                button: PanePointerButton::Primary,
7037                position
7038            } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
7039        ));
7040        assert_eq!(recovery.sequence, 2);
7041        assert_eq!(primary.sequence, 3);
7042        assert!(matches!(
7043            dispatch.log.outcome,
7044            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
7045        ));
7046        assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
7047    }
7048
7049    #[test]
7050    fn pane_terminal_adapter_modifier_parity() {
7051        let mut adapter =
7052            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7053        let target = pane_target(SplitAxis::Horizontal);
7054
7055        let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
7056            .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
7057        let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
7058        let event = dispatch.primary_event.expect("semantic event");
7059        assert!(event.modifiers.shift);
7060        assert!(event.modifiers.alt);
7061        assert!(event.modifiers.ctrl);
7062        assert!(event.modifiers.meta);
7063    }
7064
7065    #[test]
7066    fn pane_terminal_adapter_keyboard_resize_mapping() {
7067        let mut adapter =
7068            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7069        let target = pane_target(SplitAxis::Horizontal);
7070
7071        let key = KeyEvent::new(KeyCode::Right);
7072        let dispatch = adapter.translate(&Event::Key(key), Some(target));
7073        let event = dispatch.primary_event.expect("keyboard resize event");
7074        assert!(matches!(
7075            event.kind,
7076            PaneSemanticInputEventKind::KeyboardResize {
7077                target: actual_target,
7078                direction: PaneResizeDirection::Increase,
7079                units: 1
7080            } if actual_target == target
7081        ));
7082
7083        let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
7084        let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
7085        let shifted_event = shifted_dispatch
7086            .primary_event
7087            .expect("shifted resize event");
7088        assert!(matches!(
7089            shifted_event.kind,
7090            PaneSemanticInputEventKind::KeyboardResize {
7091                direction: PaneResizeDirection::Increase,
7092                units: 5,
7093                ..
7094            }
7095        ));
7096        assert!(shifted_event.modifiers.shift);
7097    }
7098
7099    #[test]
7100    fn pane_terminal_adapter_keyboard_resize_requires_focus() {
7101        let mut adapter =
7102            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7103        let target = pane_target(SplitAxis::Horizontal);
7104
7105        let _ = adapter.translate(&Event::Focus(false), None);
7106        assert!(!adapter.window_focused());
7107
7108        let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7109        assert!(unfocused.primary_event.is_none());
7110        assert!(matches!(
7111            unfocused.log.outcome,
7112            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
7113        ));
7114
7115        let _ = adapter.translate(&Event::Focus(true), None);
7116        assert!(adapter.window_focused());
7117
7118        let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7119        assert!(focused.primary_event.is_some());
7120    }
7121
7122    #[test]
7123    fn pane_terminal_adapter_drag_updates_are_coalesced() {
7124        let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
7125            drag_update_coalesce_distance: 2,
7126            ..PaneTerminalAdapterConfig::default()
7127        })
7128        .expect("valid adapter");
7129        let target = pane_target(SplitAxis::Horizontal);
7130
7131        let down = Event::Mouse(MouseEvent::new(
7132            MouseEventKind::Down(MouseButton::Left),
7133            10,
7134            4,
7135        ));
7136        let _ = adapter.translate(&down, Some(target));
7137
7138        let drag_start = Event::Mouse(MouseEvent::new(
7139            MouseEventKind::Drag(MouseButton::Left),
7140            14,
7141            4,
7142        ));
7143        let started = adapter.translate(&drag_start, None);
7144        assert!(started.primary_event.is_some());
7145        assert!(matches!(
7146            adapter.machine_state(),
7147            PaneDragResizeState::Dragging { .. }
7148        ));
7149
7150        let coalesced = Event::Mouse(MouseEvent::new(
7151            MouseEventKind::Drag(MouseButton::Left),
7152            15,
7153            4,
7154        ));
7155        let coalesced_dispatch = adapter.translate(&coalesced, None);
7156        assert!(coalesced_dispatch.primary_event.is_none());
7157        assert!(matches!(
7158            coalesced_dispatch.log.outcome,
7159            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
7160        ));
7161
7162        let forwarded = Event::Mouse(MouseEvent::new(
7163            MouseEventKind::Drag(MouseButton::Left),
7164            16,
7165            4,
7166        ));
7167        let forwarded_dispatch = adapter.translate(&forwarded, None);
7168        let forwarded_event = forwarded_dispatch
7169            .primary_event
7170            .as_ref()
7171            .expect("coalesced movement should flush once threshold reached");
7172        assert!(matches!(
7173            forwarded_event.kind,
7174            PaneSemanticInputEventKind::PointerMove {
7175                delta_x: 2,
7176                delta_y: 0,
7177                ..
7178            }
7179        ));
7180    }
7181
7182    #[test]
7183    fn pane_terminal_adapter_motion_tracks_direction_changes() {
7184        let mut adapter =
7185            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7186        let target = pane_target(SplitAxis::Horizontal);
7187
7188        let down = Event::Mouse(MouseEvent::new(
7189            MouseEventKind::Down(MouseButton::Left),
7190            10,
7191            4,
7192        ));
7193        let _ = adapter.translate(&down, Some(target));
7194
7195        let drag_forward = Event::Mouse(MouseEvent::new(
7196            MouseEventKind::Drag(MouseButton::Left),
7197            14,
7198            4,
7199        ));
7200        let forward_dispatch = adapter.translate(&drag_forward, None);
7201        let forward_motion = forward_dispatch
7202            .motion
7203            .expect("forward drag should emit motion metadata");
7204        assert_eq!(forward_motion.direction_changes, 0);
7205
7206        let drag_reverse = Event::Mouse(MouseEvent::new(
7207            MouseEventKind::Drag(MouseButton::Left),
7208            12,
7209            4,
7210        ));
7211        let reverse_dispatch = adapter.translate(&drag_reverse, None);
7212        let reverse_motion = reverse_dispatch
7213            .motion
7214            .expect("reverse drag should emit motion metadata");
7215        assert_eq!(reverse_motion.direction_changes, 1);
7216
7217        let up = Event::Mouse(MouseEvent::new(
7218            MouseEventKind::Up(MouseButton::Left),
7219            12,
7220            4,
7221        ));
7222        let up_dispatch = adapter.translate(&up, None);
7223        let up_motion = up_dispatch
7224            .motion
7225            .expect("release should include cumulative motion metadata");
7226        assert_eq!(up_motion.direction_changes, 1);
7227    }
7228
7229    #[test]
7230    fn pane_terminal_adapter_translate_with_handles_resolves_target() {
7231        let tree = nested_pane_tree();
7232        let layout = tree
7233            .solve_layout(Rect::new(0, 0, 50, 20))
7234            .expect("layout should solve");
7235        let handles =
7236            pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
7237        let mut adapter =
7238            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7239
7240        let down = Event::Mouse(MouseEvent::new(
7241            MouseEventKind::Down(MouseButton::Left),
7242            25,
7243            10,
7244        ));
7245        let dispatch = adapter.translate_with_handles(&down, &handles);
7246        let event = dispatch
7247            .primary_event
7248            .as_ref()
7249            .expect("pointer down should be routed from handles");
7250        assert!(matches!(
7251            event.kind,
7252            PaneSemanticInputEventKind::PointerDown {
7253                target:
7254                    PaneResizeTarget {
7255                        split_id,
7256                        axis: SplitAxis::Horizontal
7257                    },
7258                ..
7259            } if split_id == pane_id(1)
7260        ));
7261    }
7262
7263    #[test]
7264    fn model_update() {
7265        let mut model = TestModel { value: 0 };
7266        model.update(TestMsg::Increment);
7267        assert_eq!(model.value, 1);
7268        model.update(TestMsg::Decrement);
7269        assert_eq!(model.value, 0);
7270        assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
7271    }
7272
7273    #[test]
7274    fn model_init_default() {
7275        let mut model = TestModel { value: 0 };
7276        let cmd = model.init();
7277        assert!(matches!(cmd, Cmd::None));
7278    }
7279
7280    // Resize coalescer behavior is covered by resize_coalescer.rs tests.
7281
7282    // =========================================================================
7283    // DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
7284    // =========================================================================
7285
7286    #[test]
7287    fn cmd_sequence_executes_in_order() {
7288        // Verify that Cmd::Sequence executes commands in declared order
7289        use crate::simulator::ProgramSimulator;
7290
7291        struct SeqModel {
7292            trace: Vec<i32>,
7293        }
7294
7295        #[derive(Debug)]
7296        enum SeqMsg {
7297            Append(i32),
7298            TriggerSequence,
7299        }
7300
7301        impl From<Event> for SeqMsg {
7302            fn from(_: Event) -> Self {
7303                SeqMsg::Append(0)
7304            }
7305        }
7306
7307        impl Model for SeqModel {
7308            type Message = SeqMsg;
7309
7310            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7311                match msg {
7312                    SeqMsg::Append(n) => {
7313                        self.trace.push(n);
7314                        Cmd::none()
7315                    }
7316                    SeqMsg::TriggerSequence => Cmd::sequence(vec![
7317                        Cmd::msg(SeqMsg::Append(1)),
7318                        Cmd::msg(SeqMsg::Append(2)),
7319                        Cmd::msg(SeqMsg::Append(3)),
7320                    ]),
7321                }
7322            }
7323
7324            fn view(&self, _frame: &mut Frame) {}
7325        }
7326
7327        let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
7328        sim.init();
7329        sim.send(SeqMsg::TriggerSequence);
7330
7331        assert_eq!(sim.model().trace, vec![1, 2, 3]);
7332    }
7333
7334    #[test]
7335    fn cmd_batch_executes_all_regardless_of_order() {
7336        // Verify that Cmd::Batch executes all commands
7337        use crate::simulator::ProgramSimulator;
7338
7339        struct BatchModel {
7340            values: Vec<i32>,
7341        }
7342
7343        #[derive(Debug)]
7344        enum BatchMsg {
7345            Add(i32),
7346            TriggerBatch,
7347        }
7348
7349        impl From<Event> for BatchMsg {
7350            fn from(_: Event) -> Self {
7351                BatchMsg::Add(0)
7352            }
7353        }
7354
7355        impl Model for BatchModel {
7356            type Message = BatchMsg;
7357
7358            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7359                match msg {
7360                    BatchMsg::Add(n) => {
7361                        self.values.push(n);
7362                        Cmd::none()
7363                    }
7364                    BatchMsg::TriggerBatch => Cmd::batch(vec![
7365                        Cmd::msg(BatchMsg::Add(10)),
7366                        Cmd::msg(BatchMsg::Add(20)),
7367                        Cmd::msg(BatchMsg::Add(30)),
7368                    ]),
7369                }
7370            }
7371
7372            fn view(&self, _frame: &mut Frame) {}
7373        }
7374
7375        let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
7376        sim.init();
7377        sim.send(BatchMsg::TriggerBatch);
7378
7379        // All values should be present
7380        assert_eq!(sim.model().values.len(), 3);
7381        assert!(sim.model().values.contains(&10));
7382        assert!(sim.model().values.contains(&20));
7383        assert!(sim.model().values.contains(&30));
7384    }
7385
7386    #[test]
7387    fn cmd_sequence_stops_on_quit() {
7388        // Verify that Cmd::Sequence stops processing after Quit
7389        use crate::simulator::ProgramSimulator;
7390
7391        struct SeqQuitModel {
7392            trace: Vec<i32>,
7393        }
7394
7395        #[derive(Debug)]
7396        enum SeqQuitMsg {
7397            Append(i32),
7398            TriggerSequenceWithQuit,
7399        }
7400
7401        impl From<Event> for SeqQuitMsg {
7402            fn from(_: Event) -> Self {
7403                SeqQuitMsg::Append(0)
7404            }
7405        }
7406
7407        impl Model for SeqQuitModel {
7408            type Message = SeqQuitMsg;
7409
7410            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7411                match msg {
7412                    SeqQuitMsg::Append(n) => {
7413                        self.trace.push(n);
7414                        Cmd::none()
7415                    }
7416                    SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
7417                        Cmd::msg(SeqQuitMsg::Append(1)),
7418                        Cmd::quit(),
7419                        Cmd::msg(SeqQuitMsg::Append(2)), // Should not execute
7420                    ]),
7421                }
7422            }
7423
7424            fn view(&self, _frame: &mut Frame) {}
7425        }
7426
7427        let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
7428        sim.init();
7429        sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
7430
7431        assert_eq!(sim.model().trace, vec![1]);
7432        assert!(!sim.is_running());
7433    }
7434
7435    #[test]
7436    fn identical_input_produces_identical_state() {
7437        // Verify deterministic state transitions
7438        use crate::simulator::ProgramSimulator;
7439
7440        fn run_scenario() -> Vec<i32> {
7441            struct DetModel {
7442                values: Vec<i32>,
7443            }
7444
7445            #[derive(Debug, Clone)]
7446            enum DetMsg {
7447                Add(i32),
7448                Double,
7449            }
7450
7451            impl From<Event> for DetMsg {
7452                fn from(_: Event) -> Self {
7453                    DetMsg::Add(1)
7454                }
7455            }
7456
7457            impl Model for DetModel {
7458                type Message = DetMsg;
7459
7460                fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7461                    match msg {
7462                        DetMsg::Add(n) => {
7463                            self.values.push(n);
7464                            Cmd::none()
7465                        }
7466                        DetMsg::Double => {
7467                            if let Some(&last) = self.values.last() {
7468                                self.values.push(last * 2);
7469                            }
7470                            Cmd::none()
7471                        }
7472                    }
7473                }
7474
7475                fn view(&self, _frame: &mut Frame) {}
7476            }
7477
7478            let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
7479            sim.init();
7480            sim.send(DetMsg::Add(5));
7481            sim.send(DetMsg::Double);
7482            sim.send(DetMsg::Add(3));
7483            sim.send(DetMsg::Double);
7484
7485            sim.model().values.clone()
7486        }
7487
7488        // Run the same scenario multiple times
7489        let run1 = run_scenario();
7490        let run2 = run_scenario();
7491        let run3 = run_scenario();
7492
7493        assert_eq!(run1, run2);
7494        assert_eq!(run2, run3);
7495        assert_eq!(run1, vec![5, 10, 3, 6]);
7496    }
7497
7498    #[test]
7499    fn identical_state_produces_identical_render() {
7500        // Verify consistent render outputs for identical inputs
7501        use crate::simulator::ProgramSimulator;
7502
7503        struct RenderModel {
7504            counter: i32,
7505        }
7506
7507        #[derive(Debug)]
7508        enum RenderMsg {
7509            Set(i32),
7510        }
7511
7512        impl From<Event> for RenderMsg {
7513            fn from(_: Event) -> Self {
7514                RenderMsg::Set(0)
7515            }
7516        }
7517
7518        impl Model for RenderModel {
7519            type Message = RenderMsg;
7520
7521            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7522                match msg {
7523                    RenderMsg::Set(n) => {
7524                        self.counter = n;
7525                        Cmd::none()
7526                    }
7527                }
7528            }
7529
7530            fn view(&self, frame: &mut Frame) {
7531                let text = format!("Value: {}", self.counter);
7532                for (i, c) in text.chars().enumerate() {
7533                    if (i as u16) < frame.width() {
7534                        use ftui_render::cell::Cell;
7535                        frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
7536                    }
7537                }
7538            }
7539        }
7540
7541        // Create two simulators with the same state
7542        let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
7543        let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
7544
7545        let buf1 = sim1.capture_frame(80, 24);
7546        let buf2 = sim2.capture_frame(80, 24);
7547
7548        // Compare buffer contents
7549        for y in 0..24 {
7550            for x in 0..80 {
7551                let cell1 = buf1.get(x, y).unwrap();
7552                let cell2 = buf2.get(x, y).unwrap();
7553                assert_eq!(
7554                    cell1.content.as_char(),
7555                    cell2.content.as_char(),
7556                    "Mismatch at ({}, {})",
7557                    x,
7558                    y
7559                );
7560            }
7561        }
7562    }
7563
7564    // Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
7565
7566    #[test]
7567    fn cmd_log_creates_log_command() {
7568        let cmd: Cmd<TestMsg> = Cmd::log("test message");
7569        assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
7570    }
7571
7572    #[test]
7573    fn cmd_log_from_string() {
7574        let msg = String::from("dynamic message");
7575        let cmd: Cmd<TestMsg> = Cmd::log(msg);
7576        assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
7577    }
7578
7579    #[test]
7580    fn program_simulator_logs_jsonl_with_seed_and_run_id() {
7581        // Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
7582        use crate::simulator::ProgramSimulator;
7583
7584        struct LogModel {
7585            run_id: &'static str,
7586            seed: u64,
7587        }
7588
7589        #[derive(Debug)]
7590        enum LogMsg {
7591            Emit,
7592        }
7593
7594        impl From<Event> for LogMsg {
7595            fn from(_: Event) -> Self {
7596                LogMsg::Emit
7597            }
7598        }
7599
7600        impl Model for LogModel {
7601            type Message = LogMsg;
7602
7603            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7604                let line = format!(
7605                    r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
7606                    self.run_id, self.seed
7607                );
7608                Cmd::log(line)
7609            }
7610
7611            fn view(&self, _frame: &mut Frame) {}
7612        }
7613
7614        let mut sim = ProgramSimulator::new(LogModel {
7615            run_id: "test-run-001",
7616            seed: 4242,
7617        });
7618        sim.init();
7619        sim.send(LogMsg::Emit);
7620
7621        let logs = sim.logs();
7622        assert_eq!(logs.len(), 1);
7623        assert!(logs[0].contains(r#""run_id":"test-run-001""#));
7624        assert!(logs[0].contains(r#""seed":4242"#));
7625    }
7626
7627    #[test]
7628    fn cmd_sequence_single_unwraps() {
7629        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
7630        // Single element sequence should unwrap to the inner command
7631        assert!(matches!(cmd, Cmd::Quit));
7632    }
7633
7634    #[test]
7635    fn cmd_sequence_multiple() {
7636        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
7637        assert!(matches!(cmd, Cmd::Sequence(_)));
7638    }
7639
7640    #[test]
7641    fn cmd_default_is_none() {
7642        let cmd: Cmd<TestMsg> = Cmd::default();
7643        assert!(matches!(cmd, Cmd::None));
7644    }
7645
7646    #[test]
7647    fn cmd_debug_all_variants() {
7648        // Test Debug impl for all variants
7649        let none: Cmd<TestMsg> = Cmd::none();
7650        assert_eq!(format!("{none:?}"), "None");
7651
7652        let quit: Cmd<TestMsg> = Cmd::quit();
7653        assert_eq!(format!("{quit:?}"), "Quit");
7654
7655        let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7656        assert!(format!("{msg:?}").starts_with("Msg("));
7657
7658        let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
7659        assert!(format!("{batch:?}").starts_with("Batch("));
7660
7661        let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
7662        assert!(format!("{seq:?}").starts_with("Sequence("));
7663
7664        let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
7665        assert!(format!("{tick:?}").starts_with("Tick("));
7666
7667        let log: Cmd<TestMsg> = Cmd::log("test");
7668        assert!(format!("{log:?}").starts_with("Log("));
7669    }
7670
7671    #[test]
7672    fn program_config_with_budget() {
7673        let budget = FrameBudgetConfig {
7674            total: Duration::from_millis(50),
7675            ..Default::default()
7676        };
7677        let config = ProgramConfig::default().with_budget(budget);
7678        assert_eq!(config.budget.total, Duration::from_millis(50));
7679    }
7680
7681    #[test]
7682    fn load_governor_default_is_enabled() {
7683        let config = LoadGovernorConfig::default();
7684        assert!(config.enabled);
7685        assert_eq!(
7686            config.budget_controller.degradation_floor,
7687            DegradationLevel::SimpleBorders
7688        );
7689    }
7690
7691    #[test]
7692    fn program_config_load_governor_builders() {
7693        let governor = LoadGovernorConfig::disabled().with_enabled(true);
7694        let config = ProgramConfig::default().with_load_governor(governor);
7695        assert!(config.load_governor.enabled);
7696
7697        let config = config.without_load_governor();
7698        assert!(!config.load_governor.enabled);
7699    }
7700
7701    #[test]
7702    fn headless_program_default_load_governor_attaches_controller() {
7703        let program =
7704            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
7705        assert!(program.budget.controller().is_some());
7706    }
7707
7708    #[test]
7709    fn headless_program_load_governor_target_tracks_frame_budget() {
7710        let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
7711            total: Duration::from_millis(50),
7712            ..Default::default()
7713        });
7714        let program = headless_program_with_config(TestModel { value: 0 }, config);
7715        assert_eq!(
7716            program.budget.controller().unwrap().config().target,
7717            Duration::from_millis(50)
7718        );
7719    }
7720
7721    #[test]
7722    fn headless_program_without_load_governor_uses_legacy_budget() {
7723        let program = headless_program_with_config(
7724            TestModel { value: 0 },
7725            ProgramConfig::default().without_load_governor(),
7726        );
7727        assert!(program.budget.controller().is_none());
7728    }
7729
7730    #[test]
7731    fn app_builder_without_load_governor_sets_config() {
7732        let builder = App::new(TestModel { value: 0 }).without_load_governor();
7733        assert!(!builder.config.load_governor.enabled);
7734    }
7735
7736    #[test]
7737    fn program_config_with_conformal() {
7738        let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
7739            alpha: 0.2,
7740            ..Default::default()
7741        });
7742        assert!(config.conformal_config.is_some());
7743        assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
7744    }
7745
7746    #[test]
7747    fn program_config_forced_size_clamps_minimums() {
7748        let config = ProgramConfig::default().with_forced_size(0, 0);
7749        assert_eq!(config.forced_size, Some((1, 1)));
7750
7751        let cleared = config.without_forced_size();
7752        assert!(cleared.forced_size.is_none());
7753    }
7754
7755    #[test]
7756    fn effect_queue_config_defaults_are_safe() {
7757        let config = EffectQueueConfig::default();
7758        assert!(!config.enabled);
7759        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
7760        assert!(config.scheduler.smith_enabled);
7761        assert!(!config.scheduler.preemptive);
7762        assert_eq!(config.scheduler.aging_factor, 0.0);
7763        assert_eq!(config.scheduler.wait_starve_ms, 0.0);
7764    }
7765
7766    #[test]
7767    fn handle_effect_command_enqueues_or_executes_inline() {
7768        let (result_tx, result_rx) = mpsc::channel::<u32>();
7769        let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
7770        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
7771
7772        let ran = Arc::new(AtomicUsize::new(0));
7773        let ran_task = ran.clone();
7774        let cmd = EffectCommand::Enqueue(
7775            TaskSpec::default(),
7776            Box::new(move || {
7777                ran_task.fetch_add(1, Ordering::SeqCst);
7778                7
7779            }),
7780        );
7781
7782        let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
7783        assert_eq!(shutdown, EffectLoopControl::Continue);
7784        assert_eq!(ran.load(Ordering::SeqCst), 0);
7785        assert_eq!(tasks.len(), 1);
7786        assert!(result_rx.try_recv().is_err());
7787
7788        let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
7789            max_queue_size: 0,
7790            ..Default::default()
7791        });
7792        let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
7793        let ran_full = Arc::new(AtomicUsize::new(0));
7794        let ran_full_task = ran_full.clone();
7795        let cmd_full = EffectCommand::Enqueue(
7796            TaskSpec::default(),
7797            Box::new(move || {
7798                ran_full_task.fetch_add(1, Ordering::SeqCst);
7799                42
7800            }),
7801        );
7802
7803        let shutdown_full = handle_effect_command(
7804            cmd_full,
7805            &mut full_scheduler,
7806            &mut full_tasks,
7807            &result_tx,
7808            None,
7809            0,
7810        );
7811        assert_eq!(shutdown_full, EffectLoopControl::Continue);
7812        assert!(full_tasks.is_empty());
7813        assert_eq!(ran_full.load(Ordering::SeqCst), 1);
7814        assert_eq!(
7815            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
7816            42
7817        );
7818
7819        let shutdown = handle_effect_command(
7820            EffectCommand::Shutdown,
7821            &mut full_scheduler,
7822            &mut full_tasks,
7823            &result_tx,
7824            None,
7825            0,
7826        );
7827        assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
7828    }
7829
7830    #[test]
7831    fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
7832        let evidence_path = temp_evidence_path("task_executor_backpressure");
7833        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
7834        let sink = EvidenceSink::from_config(&sink_config)
7835            .expect("evidence sink config")
7836            .expect("evidence sink enabled");
7837        let (result_tx, result_rx) = mpsc::channel::<u32>();
7838        let mut scheduler = QueueingScheduler::new(SchedulerConfig {
7839            max_queue_size: 0,
7840            ..Default::default()
7841        });
7842        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
7843
7844        let shutdown = handle_effect_command(
7845            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
7846            &mut scheduler,
7847            &mut tasks,
7848            &result_tx,
7849            Some(&sink),
7850            0,
7851        );
7852
7853        assert_eq!(shutdown, EffectLoopControl::Continue);
7854        assert!(tasks.is_empty());
7855        assert_eq!(
7856            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
7857            7
7858        );
7859
7860        let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
7861        assert_eq!(backpressure_line["backend"], "queued");
7862        assert_eq!(backpressure_line["action"], "inline_fallback");
7863        assert_eq!(backpressure_line["max_queue_size"], 0);
7864        assert_eq!(backpressure_line["total_rejected"], 1);
7865
7866        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
7867        assert_eq!(completion_line["backend"], "queued-inline-fallback");
7868        assert!(completion_line["duration_us"].is_number());
7869    }
7870
7871    #[test]
7872    fn effect_queue_loop_executes_tasks_and_shutdowns() {
7873        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
7874        let (result_tx, result_rx) = mpsc::channel::<u32>();
7875        let config = EffectQueueConfig {
7876            enabled: true,
7877            backend: TaskExecutorBackend::EffectQueue,
7878            scheduler: SchedulerConfig {
7879                preemptive: false,
7880                ..Default::default()
7881            },
7882            explicit_backend: true,
7883            ..Default::default()
7884        };
7885
7886        let handle = std::thread::spawn(move || {
7887            effect_queue_loop(config, cmd_rx, result_tx, None);
7888        });
7889
7890        cmd_tx
7891            .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
7892            .unwrap();
7893        cmd_tx
7894            .send(EffectCommand::Enqueue(
7895                TaskSpec::new(2.0, 5.0).with_name("second"),
7896                Box::new(|| 20),
7897            ))
7898            .unwrap();
7899
7900        let mut results = vec![
7901            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7902            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7903        ];
7904        results.sort_unstable();
7905        assert_eq!(results, vec![10, 20]);
7906
7907        cmd_tx.send(EffectCommand::Shutdown).unwrap();
7908        let _ = handle.join();
7909    }
7910
7911    #[test]
7912    fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
7913        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
7914        let (result_tx, result_rx) = mpsc::channel::<u32>();
7915        let config = EffectQueueConfig {
7916            enabled: true,
7917            backend: TaskExecutorBackend::EffectQueue,
7918            scheduler: SchedulerConfig {
7919                preemptive: false,
7920                ..Default::default()
7921            },
7922            explicit_backend: true,
7923            ..Default::default()
7924        };
7925
7926        let handle = std::thread::spawn(move || {
7927            effect_queue_loop(config, cmd_rx, result_tx, None);
7928        });
7929
7930        cmd_tx
7931            .send(EffectCommand::Enqueue(
7932                TaskSpec::default().with_name("slow"),
7933                Box::new(|| {
7934                    std::thread::sleep(Duration::from_millis(20));
7935                    10
7936                }),
7937            ))
7938            .unwrap();
7939        cmd_tx
7940            .send(EffectCommand::Enqueue(
7941                TaskSpec::new(2.0, 5.0).with_name("fast"),
7942                Box::new(|| 20),
7943            ))
7944            .unwrap();
7945        cmd_tx.send(EffectCommand::Shutdown).unwrap();
7946
7947        let mut results = vec![
7948            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7949            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7950        ];
7951        results.sort_unstable();
7952        assert_eq!(results, vec![10, 20]);
7953
7954        handle
7955            .join()
7956            .expect("effect queue thread joins after draining");
7957    }
7958
7959    #[test]
7960    fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
7961        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
7962        let (result_tx, result_rx) = mpsc::channel::<u32>();
7963        let config = EffectQueueConfig {
7964            enabled: true,
7965            backend: TaskExecutorBackend::EffectQueue,
7966            scheduler: SchedulerConfig {
7967                preemptive: false,
7968                ..Default::default()
7969            },
7970            explicit_backend: true,
7971            ..Default::default()
7972        };
7973
7974        let handle = std::thread::spawn(move || {
7975            effect_queue_loop(config, cmd_rx, result_tx, None);
7976        });
7977
7978        cmd_tx
7979            .send(EffectCommand::Enqueue(
7980                TaskSpec::new(3.0, 1.0).with_name("panic"),
7981                Box::new(|| panic!("queued panic")),
7982            ))
7983            .unwrap();
7984        cmd_tx
7985            .send(EffectCommand::Enqueue(
7986                TaskSpec::new(1.0, 5.0).with_name("after"),
7987                Box::new(|| 99),
7988            ))
7989            .unwrap();
7990
7991        assert_eq!(
7992            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7993            99
7994        );
7995
7996        cmd_tx.send(EffectCommand::Shutdown).unwrap();
7997        handle
7998            .join()
7999            .expect("effect queue thread survives task panic");
8000    }
8001
8002    #[test]
8003    fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
8004        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8005        let (result_tx, result_rx) = mpsc::channel::<u32>();
8006        let config = EffectQueueConfig {
8007            enabled: true,
8008            backend: TaskExecutorBackend::EffectQueue,
8009            scheduler: SchedulerConfig {
8010                preemptive: false,
8011                ..Default::default()
8012            },
8013            explicit_backend: true,
8014            ..Default::default()
8015        };
8016
8017        let handle = std::thread::spawn(move || {
8018            effect_queue_loop(config, cmd_rx, result_tx, None);
8019        });
8020
8021        cmd_tx
8022            .send(EffectCommand::Enqueue(
8023                TaskSpec::default().with_name("slow"),
8024                Box::new(|| {
8025                    std::thread::sleep(Duration::from_millis(20));
8026                    10
8027                }),
8028            ))
8029            .unwrap();
8030        cmd_tx.send(EffectCommand::Shutdown).unwrap();
8031        cmd_tx
8032            .send(EffectCommand::Enqueue(
8033                TaskSpec::new(1.0, 1.0).with_name("late"),
8034                Box::new(|| 99),
8035            ))
8036            .unwrap();
8037
8038        assert_eq!(
8039            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8040            10
8041        );
8042        assert!(
8043            result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
8044            "post-shutdown enqueue should not execute"
8045        );
8046
8047        handle
8048            .join()
8049            .expect("effect queue thread joins after rejecting post-shutdown work");
8050    }
8051
8052    #[test]
8053    fn effect_queue_enqueue_after_shutdown_records_drop() {
8054        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
8055        drop(rx);
8056
8057        let queue = EffectQueue {
8058            sender: tx,
8059            handle: None,
8060            closed: true,
8061        };
8062        let runs = Arc::new(AtomicUsize::new(0));
8063        let before = crate::effect_system::effects_queue_dropped();
8064
8065        queue.enqueue(
8066            TaskSpec::default(),
8067            Box::new({
8068                let runs = Arc::clone(&runs);
8069                move || {
8070                    runs.fetch_add(1, Ordering::SeqCst);
8071                    7
8072                }
8073            }),
8074        );
8075
8076        let after = crate::effect_system::effects_queue_dropped();
8077        assert_eq!(runs.load(Ordering::SeqCst), 0);
8078        assert!(
8079            after > before,
8080            "enqueue after shutdown should increment dropped counter"
8081        );
8082    }
8083
8084    #[test]
8085    fn effect_queue_enqueue_with_closed_channel_records_drop() {
8086        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
8087        drop(rx);
8088
8089        let queue = EffectQueue {
8090            sender: tx,
8091            handle: None,
8092            closed: false,
8093        };
8094        let runs = Arc::new(AtomicUsize::new(0));
8095        let before = crate::effect_system::effects_queue_dropped();
8096
8097        queue.enqueue(
8098            TaskSpec::default(),
8099            Box::new({
8100                let runs = Arc::clone(&runs);
8101                move || {
8102                    runs.fetch_add(1, Ordering::SeqCst);
8103                    9
8104                }
8105            }),
8106        );
8107
8108        let after = crate::effect_system::effects_queue_dropped();
8109        assert_eq!(runs.load(Ordering::SeqCst), 0);
8110        assert!(
8111            after > before,
8112            "enqueue into a closed queue channel should increment dropped counter"
8113        );
8114    }
8115
8116    // =========================================================================
8117    // Backpressure tests (bd-2zd0a)
8118    // =========================================================================
8119
8120    #[test]
8121    fn backpressure_drops_tasks_beyond_max_depth() {
8122        let (result_tx, _result_rx) = mpsc::channel::<u32>();
8123        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
8124        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8125
8126        // Enqueue 2 tasks with max_depth=2 — should succeed
8127        let r1 = handle_effect_command(
8128            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
8129            &mut scheduler,
8130            &mut tasks,
8131            &result_tx,
8132            None,
8133            2,
8134        );
8135        assert_eq!(r1, EffectLoopControl::Continue);
8136        assert_eq!(tasks.len(), 1);
8137
8138        let r2 = handle_effect_command(
8139            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
8140            &mut scheduler,
8141            &mut tasks,
8142            &result_tx,
8143            None,
8144            2,
8145        );
8146        assert_eq!(r2, EffectLoopControl::Continue);
8147        assert_eq!(tasks.len(), 2);
8148
8149        // 3rd task should be dropped (depth=2 >= max_depth=2)
8150        let dropped_before = crate::effect_system::effects_queue_dropped();
8151        let r3 = handle_effect_command(
8152            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
8153            &mut scheduler,
8154            &mut tasks,
8155            &result_tx,
8156            None,
8157            2,
8158        );
8159        assert_eq!(r3, EffectLoopControl::Continue);
8160        assert_eq!(
8161            tasks.len(),
8162            2,
8163            "task should have been dropped, not enqueued"
8164        );
8165        assert!(
8166            crate::effect_system::effects_queue_dropped() > dropped_before,
8167            "dropped counter should increment"
8168        );
8169    }
8170
8171    #[test]
8172    fn backpressure_zero_depth_means_unbounded() {
8173        let (result_tx, _result_rx) = mpsc::channel::<u32>();
8174        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
8175        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8176
8177        // With max_depth=0, can enqueue many tasks
8178        for i in 0..20 {
8179            let r = handle_effect_command(
8180                EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
8181                &mut scheduler,
8182                &mut tasks,
8183                &result_tx,
8184                None,
8185                0,
8186            );
8187            assert_eq!(r, EffectLoopControl::Continue);
8188        }
8189        // All should be enqueued (some may have been inlined by scheduler, but none dropped)
8190    }
8191
8192    #[test]
8193    fn inline_auto_remeasure_reset_clears_decision() {
8194        let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
8195        state.sampler.decide(Instant::now());
8196        assert!(state.sampler.last_decision().is_some());
8197
8198        state.reset();
8199        assert!(state.sampler.last_decision().is_none());
8200    }
8201
8202    #[test]
8203    fn budget_decision_jsonl_contains_required_fields() {
8204        let evidence = BudgetDecisionEvidence {
8205            frame_idx: 7,
8206            decision: BudgetDecision::Degrade,
8207            controller_decision: BudgetDecision::Hold,
8208            degradation_before: DegradationLevel::Full,
8209            degradation_after: DegradationLevel::NoStyling,
8210            frame_time_us: 12_345.678,
8211            budget_us: 16_000.0,
8212            pid_output: 1.25,
8213            pid_p: 0.5,
8214            pid_i: 0.25,
8215            pid_d: 0.5,
8216            e_value: 2.0,
8217            frames_observed: 42,
8218            frames_since_change: 3,
8219            in_warmup: false,
8220            conformal: Some(ConformalEvidence {
8221                bucket_key: "inline:dirty:10".to_string(),
8222                n_b: 32,
8223                alpha: 0.05,
8224                q_b: 1000.0,
8225                y_hat: 12_000.0,
8226                upper_us: 13_000.0,
8227                risk: true,
8228                fallback_level: 1,
8229                window_size: 256,
8230                reset_count: 2,
8231            }),
8232        };
8233
8234        let jsonl = evidence.to_jsonl();
8235        assert!(jsonl.contains("\"event\":\"budget_decision\""));
8236        assert!(jsonl.contains("\"decision\":\"degrade\""));
8237        assert!(jsonl.contains("\"decision_controller\":\"stay\""));
8238        assert!(jsonl.contains("\"degradation_before\":\"Full\""));
8239        assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
8240        assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
8241        assert!(jsonl.contains("\"budget_us\":16000.000000"));
8242        assert!(jsonl.contains("\"pid_output\":1.250000"));
8243        assert!(jsonl.contains("\"e_value\":2.000000"));
8244        assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
8245        assert!(jsonl.contains("\"n_b\":32"));
8246        assert!(jsonl.contains("\"alpha\":0.050000"));
8247        assert!(jsonl.contains("\"q_b\":1000.000000"));
8248        assert!(jsonl.contains("\"y_hat\":12000.000000"));
8249        assert!(jsonl.contains("\"upper_us\":13000.000000"));
8250        assert!(jsonl.contains("\"risk\":true"));
8251        assert!(jsonl.contains("\"fallback_level\":1"));
8252        assert!(jsonl.contains("\"window_size\":256"));
8253        assert!(jsonl.contains("\"reset_count\":2"));
8254    }
8255
8256    fn make_signal(
8257        widget_id: u64,
8258        essential: bool,
8259        priority: f32,
8260        staleness_ms: u64,
8261        cost_us: f32,
8262    ) -> WidgetSignal {
8263        WidgetSignal {
8264            widget_id,
8265            essential,
8266            priority,
8267            staleness_ms,
8268            focus_boost: 0.0,
8269            interaction_boost: 0.0,
8270            area_cells: 1,
8271            cost_estimate_us: cost_us,
8272            recent_cost_us: 0.0,
8273            estimate_source: CostEstimateSource::FixedDefault,
8274        }
8275    }
8276
8277    fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
8278        let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
8279        let staleness_window = config.staleness_window_ms.max(1) as f32;
8280        let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
8281        let mut value = config.weight_priority * signal.priority
8282            + config.weight_staleness * staleness_score
8283            + config.weight_focus * signal.focus_boost
8284            + config.weight_interaction * signal.interaction_boost;
8285        if starved {
8286            value += config.starve_boost;
8287        }
8288        let raw_cost = if signal.recent_cost_us > 0.0 {
8289            signal.recent_cost_us
8290        } else {
8291            signal.cost_estimate_us
8292        };
8293        let cost_us = raw_cost.max(config.min_cost_us);
8294        (value, cost_us, starved)
8295    }
8296
8297    fn fifo_select(
8298        signals: &[WidgetSignal],
8299        budget_us: f64,
8300        config: &WidgetRefreshConfig,
8301    ) -> (Vec<u64>, f64, usize) {
8302        let mut selected = Vec::new();
8303        let mut total_value = 0.0f64;
8304        let mut starved_selected = 0usize;
8305        let mut remaining = budget_us;
8306
8307        for signal in signals {
8308            if !signal.essential {
8309                continue;
8310            }
8311            let (value, cost_us, starved) = signal_value_cost(signal, config);
8312            remaining -= cost_us as f64;
8313            total_value += value as f64;
8314            if starved {
8315                starved_selected = starved_selected.saturating_add(1);
8316            }
8317            selected.push(signal.widget_id);
8318        }
8319        for signal in signals {
8320            if signal.essential {
8321                continue;
8322            }
8323            let (value, cost_us, starved) = signal_value_cost(signal, config);
8324            if remaining >= cost_us as f64 {
8325                remaining -= cost_us as f64;
8326                total_value += value as f64;
8327                if starved {
8328                    starved_selected = starved_selected.saturating_add(1);
8329                }
8330                selected.push(signal.widget_id);
8331            }
8332        }
8333
8334        (selected, total_value, starved_selected)
8335    }
8336
8337    fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
8338        if signals.is_empty() {
8339            return Vec::new();
8340        }
8341        let mut rotated = Vec::with_capacity(signals.len());
8342        for idx in 0..signals.len() {
8343            rotated.push(signals[(idx + offset) % signals.len()].clone());
8344        }
8345        rotated
8346    }
8347
8348    #[test]
8349    fn widget_refresh_selects_essentials_first() {
8350        let signals = vec![
8351            make_signal(1, true, 0.6, 0, 5.0),
8352            make_signal(2, false, 0.9, 0, 4.0),
8353        ];
8354        let mut plan = WidgetRefreshPlan::new();
8355        let config = WidgetRefreshConfig::default();
8356        plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
8357        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8358        assert_eq!(selected, vec![1]);
8359        assert!(!plan.over_budget);
8360    }
8361
8362    #[test]
8363    fn widget_refresh_degradation_essential_only_skips_nonessential() {
8364        let signals = vec![
8365            make_signal(1, true, 0.5, 0, 2.0),
8366            make_signal(2, false, 1.0, 0, 1.0),
8367        ];
8368        let mut plan = WidgetRefreshPlan::new();
8369        let config = WidgetRefreshConfig::default();
8370        plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
8371        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8372        assert_eq!(selected, vec![1]);
8373        assert_eq!(plan.skipped_count, 1);
8374    }
8375
8376    #[test]
8377    fn widget_refresh_starvation_guard_forces_one_starved() {
8378        let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
8379        let mut plan = WidgetRefreshPlan::new();
8380        let config = WidgetRefreshConfig {
8381            starve_ms: 1_000,
8382            max_starved_per_frame: 1,
8383            ..Default::default()
8384        };
8385        plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
8386        assert_eq!(plan.selected.len(), 1);
8387        assert!(plan.selected[0].starved);
8388        assert!(plan.over_budget);
8389    }
8390
8391    #[test]
8392    fn widget_refresh_budget_blocks_when_no_selection() {
8393        let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
8394        let mut plan = WidgetRefreshPlan::new();
8395        let config = WidgetRefreshConfig {
8396            starve_ms: 0,
8397            max_starved_per_frame: 0,
8398            ..Default::default()
8399        };
8400        plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
8401        let budget = plan.as_budget();
8402        assert!(!budget.allows(42, false));
8403    }
8404
8405    #[test]
8406    fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
8407        let signals = vec![
8408            make_signal(1, false, 0.4, 0, 10.0),
8409            make_signal(2, false, 0.4, 0, 10.0),
8410            make_signal(3, false, 0.4, 0, 10.0),
8411            make_signal(4, false, 0.4, 0, 10.0),
8412        ];
8413        let mut plan = WidgetRefreshPlan::new();
8414        let config = WidgetRefreshConfig {
8415            starve_ms: 0,
8416            max_starved_per_frame: 0,
8417            max_drop_fraction: 0.5,
8418            ..Default::default()
8419        };
8420        plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
8421        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8422        assert_eq!(selected, vec![1, 2]);
8423    }
8424
8425    #[test]
8426    fn widget_refresh_greedy_beats_fifo_and_round_robin() {
8427        let signals = vec![
8428            make_signal(1, false, 0.1, 0, 6.0),
8429            make_signal(2, false, 0.2, 0, 6.0),
8430            make_signal(3, false, 1.0, 0, 4.0),
8431            make_signal(4, false, 0.9, 0, 3.0),
8432            make_signal(5, false, 0.8, 0, 3.0),
8433            make_signal(6, false, 0.1, 4_000, 2.0),
8434        ];
8435        let budget_us = 10.0;
8436        let config = WidgetRefreshConfig::default();
8437
8438        let mut plan = WidgetRefreshPlan::new();
8439        plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
8440        let greedy_value = plan.selected_value;
8441        let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8442
8443        let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
8444        let rotated = rotate_signals(&signals, 2);
8445        let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
8446
8447        assert!(
8448            greedy_value > fifo_value,
8449            "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
8450            greedy_selected,
8451            fifo_selected
8452        );
8453        assert!(
8454            greedy_value > rr_value,
8455            "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
8456            greedy_selected,
8457            rr_selected
8458        );
8459        assert!(
8460            plan.starved_selected > 0,
8461            "greedy did not select starved widget; greedy={:?}",
8462            greedy_selected
8463        );
8464    }
8465
8466    #[test]
8467    fn widget_refresh_jsonl_contains_required_fields() {
8468        let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
8469        let mut plan = WidgetRefreshPlan::new();
8470        let config = WidgetRefreshConfig::default();
8471        plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
8472        let jsonl = plan.to_jsonl();
8473        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
8474        assert!(jsonl.contains("\"frame_idx\":9"));
8475        assert!(jsonl.contains("\"selected_count\":1"));
8476        assert!(jsonl.contains("\"id\":7"));
8477    }
8478
8479    #[test]
8480    fn program_config_with_resize_coalescer() {
8481        let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
8482            steady_delay_ms: 8,
8483            burst_delay_ms: 20,
8484            hard_deadline_ms: 80,
8485            burst_enter_rate: 12.0,
8486            burst_exit_rate: 6.0,
8487            cooldown_frames: 2,
8488            rate_window_size: 6,
8489            enable_logging: true,
8490            enable_bocpd: false,
8491            bocpd_config: None,
8492        });
8493        assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
8494        assert!(config.resize_coalescer.enable_logging);
8495    }
8496
8497    #[test]
8498    fn program_config_with_resize_behavior() {
8499        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
8500        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
8501    }
8502
8503    #[test]
8504    fn program_config_with_legacy_resize_enabled() {
8505        let config = ProgramConfig::default().with_legacy_resize(true);
8506        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
8507    }
8508
8509    #[test]
8510    fn program_config_with_legacy_resize_disabled_keeps_default() {
8511        let config = ProgramConfig::default().with_legacy_resize(false);
8512        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
8513    }
8514
8515    fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
8516        let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
8517        let mut writer = TerminalWriter::with_diff_config(
8518            Vec::<u8>::new(),
8519            ScreenMode::AltScreen,
8520            UiAnchor::Bottom,
8521            TerminalCapabilities::basic(),
8522            config,
8523        );
8524        writer.set_size(8, 4);
8525
8526        let mut buffer = Buffer::new(8, 4);
8527        let mut trace = Vec::new();
8528
8529        writer.present_ui(&buffer, None, false).unwrap();
8530        trace.push(
8531            writer
8532                .last_diff_strategy()
8533                .unwrap_or(DiffStrategy::FullRedraw),
8534        );
8535
8536        buffer.set_raw(0, 0, Cell::from_char('A'));
8537        writer.present_ui(&buffer, None, false).unwrap();
8538        trace.push(
8539            writer
8540                .last_diff_strategy()
8541                .unwrap_or(DiffStrategy::FullRedraw),
8542        );
8543
8544        buffer.set_raw(1, 1, Cell::from_char('B'));
8545        writer.present_ui(&buffer, None, false).unwrap();
8546        trace.push(
8547            writer
8548                .last_diff_strategy()
8549                .unwrap_or(DiffStrategy::FullRedraw),
8550        );
8551
8552        trace
8553    }
8554
8555    fn coalescer_checksum(enable_bocpd: bool) -> String {
8556        let mut config = CoalescerConfig::default().with_logging(true);
8557        if enable_bocpd {
8558            config = config.with_bocpd();
8559        }
8560
8561        let base = Instant::now();
8562        let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
8563
8564        let events = [
8565            (0_u64, (82_u16, 24_u16)),
8566            (10, (83, 25)),
8567            (20, (84, 26)),
8568            (35, (90, 28)),
8569            (55, (92, 30)),
8570        ];
8571
8572        let mut idx = 0usize;
8573        for t_ms in (0_u64..=160).step_by(8) {
8574            let now = base + Duration::from_millis(t_ms);
8575            while idx < events.len() && events[idx].0 == t_ms {
8576                let (w, h) = events[idx].1;
8577                coalescer.handle_resize_at(w, h, now);
8578                idx += 1;
8579            }
8580            coalescer.tick_at(now);
8581        }
8582
8583        coalescer.decision_checksum_hex()
8584    }
8585
8586    fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
8587        if !enabled {
8588            return Vec::new();
8589        }
8590
8591        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
8592        let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
8593        let mut trace = Vec::new();
8594
8595        for i in 0..30 {
8596            let y_hat = 16_000.0 + (i as f64) * 15.0;
8597            let observed = y_hat + (i % 7) as f64 * 120.0;
8598            predictor.observe(key, y_hat, observed);
8599            let prediction = predictor.predict(key, y_hat, 20_000.0);
8600            trace.push((prediction.upper_us, prediction.risk));
8601        }
8602
8603        trace
8604    }
8605
8606    #[test]
8607    fn policy_toggle_matrix_determinism() {
8608        for &bayesian in &[false, true] {
8609            for &bocpd in &[false, true] {
8610                for &conformal in &[false, true] {
8611                    let diff_a = diff_strategy_trace(bayesian);
8612                    let diff_b = diff_strategy_trace(bayesian);
8613                    assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
8614
8615                    let checksum_a = coalescer_checksum(bocpd);
8616                    let checksum_b = coalescer_checksum(bocpd);
8617                    assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
8618
8619                    let conf_a = conformal_trace(conformal);
8620                    let conf_b = conformal_trace(conformal);
8621                    assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
8622
8623                    if conformal {
8624                        assert!(!conf_a.is_empty(), "conformal trace should be populated");
8625                    } else {
8626                        assert!(conf_a.is_empty(), "conformal trace should be empty");
8627                    }
8628                }
8629            }
8630        }
8631    }
8632
8633    #[test]
8634    fn resize_behavior_uses_coalescer_flag() {
8635        assert!(ResizeBehavior::Throttled.uses_coalescer());
8636        assert!(!ResizeBehavior::Immediate.uses_coalescer());
8637    }
8638
8639    #[test]
8640    fn nested_cmd_msg_executes_recursively() {
8641        // Verify that Cmd::Msg triggers recursive update
8642        use crate::simulator::ProgramSimulator;
8643
8644        struct NestedModel {
8645            depth: usize,
8646        }
8647
8648        #[derive(Debug)]
8649        enum NestedMsg {
8650            Nest(usize),
8651        }
8652
8653        impl From<Event> for NestedMsg {
8654            fn from(_: Event) -> Self {
8655                NestedMsg::Nest(0)
8656            }
8657        }
8658
8659        impl Model for NestedModel {
8660            type Message = NestedMsg;
8661
8662            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8663                match msg {
8664                    NestedMsg::Nest(n) => {
8665                        self.depth += 1;
8666                        if n > 0 {
8667                            Cmd::msg(NestedMsg::Nest(n - 1))
8668                        } else {
8669                            Cmd::none()
8670                        }
8671                    }
8672                }
8673            }
8674
8675            fn view(&self, _frame: &mut Frame) {}
8676        }
8677
8678        let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
8679        sim.init();
8680        sim.send(NestedMsg::Nest(3));
8681
8682        // Should have recursed 4 times (3, 2, 1, 0)
8683        assert_eq!(sim.model().depth, 4);
8684    }
8685
8686    #[test]
8687    fn task_executes_synchronously_in_simulator() {
8688        // In simulator, tasks execute synchronously
8689        use crate::simulator::ProgramSimulator;
8690
8691        struct TaskModel {
8692            completed: bool,
8693        }
8694
8695        #[derive(Debug)]
8696        enum TaskMsg {
8697            Complete,
8698            SpawnTask,
8699        }
8700
8701        impl From<Event> for TaskMsg {
8702            fn from(_: Event) -> Self {
8703                TaskMsg::Complete
8704            }
8705        }
8706
8707        impl Model for TaskModel {
8708            type Message = TaskMsg;
8709
8710            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8711                match msg {
8712                    TaskMsg::Complete => {
8713                        self.completed = true;
8714                        Cmd::none()
8715                    }
8716                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
8717                }
8718            }
8719
8720            fn view(&self, _frame: &mut Frame) {}
8721        }
8722
8723        let mut sim = ProgramSimulator::new(TaskModel { completed: false });
8724        sim.init();
8725        sim.send(TaskMsg::SpawnTask);
8726
8727        // Task should have completed synchronously
8728        assert!(sim.model().completed);
8729    }
8730
8731    #[test]
8732    fn multiple_updates_accumulate_correctly() {
8733        // Verify state accumulates correctly across multiple updates
8734        use crate::simulator::ProgramSimulator;
8735
8736        struct AccumModel {
8737            sum: i32,
8738        }
8739
8740        #[derive(Debug)]
8741        enum AccumMsg {
8742            Add(i32),
8743            Multiply(i32),
8744        }
8745
8746        impl From<Event> for AccumMsg {
8747            fn from(_: Event) -> Self {
8748                AccumMsg::Add(1)
8749            }
8750        }
8751
8752        impl Model for AccumModel {
8753            type Message = AccumMsg;
8754
8755            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8756                match msg {
8757                    AccumMsg::Add(n) => {
8758                        self.sum += n;
8759                        Cmd::none()
8760                    }
8761                    AccumMsg::Multiply(n) => {
8762                        self.sum *= n;
8763                        Cmd::none()
8764                    }
8765                }
8766            }
8767
8768            fn view(&self, _frame: &mut Frame) {}
8769        }
8770
8771        let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
8772        sim.init();
8773
8774        // (0 + 5) * 2 + 3 = 13
8775        sim.send(AccumMsg::Add(5));
8776        sim.send(AccumMsg::Multiply(2));
8777        sim.send(AccumMsg::Add(3));
8778
8779        assert_eq!(sim.model().sum, 13);
8780    }
8781
8782    #[test]
8783    fn init_command_executes_before_first_update() {
8784        // Verify init() command executes before any update
8785        use crate::simulator::ProgramSimulator;
8786
8787        struct InitModel {
8788            initialized: bool,
8789            updates: usize,
8790        }
8791
8792        #[derive(Debug)]
8793        enum InitMsg {
8794            Update,
8795            MarkInit,
8796        }
8797
8798        impl From<Event> for InitMsg {
8799            fn from(_: Event) -> Self {
8800                InitMsg::Update
8801            }
8802        }
8803
8804        impl Model for InitModel {
8805            type Message = InitMsg;
8806
8807            fn init(&mut self) -> Cmd<Self::Message> {
8808                Cmd::msg(InitMsg::MarkInit)
8809            }
8810
8811            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8812                match msg {
8813                    InitMsg::MarkInit => {
8814                        self.initialized = true;
8815                        Cmd::none()
8816                    }
8817                    InitMsg::Update => {
8818                        self.updates += 1;
8819                        Cmd::none()
8820                    }
8821                }
8822            }
8823
8824            fn view(&self, _frame: &mut Frame) {}
8825        }
8826
8827        let mut sim = ProgramSimulator::new(InitModel {
8828            initialized: false,
8829            updates: 0,
8830        });
8831        sim.init();
8832
8833        assert!(sim.model().initialized);
8834        sim.send(InitMsg::Update);
8835        assert_eq!(sim.model().updates, 1);
8836    }
8837
8838    // =========================================================================
8839    // INLINE MODE FRAME SIZING TESTS (bd-20vg)
8840    // =========================================================================
8841
8842    #[test]
8843    fn ui_height_returns_correct_value_inline_mode() {
8844        // Verify TerminalWriter.ui_height() returns ui_height in inline mode
8845        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8846        use ftui_core::terminal_capabilities::TerminalCapabilities;
8847
8848        let output = Vec::new();
8849        let writer = TerminalWriter::new(
8850            output,
8851            ScreenMode::Inline { ui_height: 10 },
8852            UiAnchor::Bottom,
8853            TerminalCapabilities::basic(),
8854        );
8855        assert_eq!(writer.ui_height(), 10);
8856    }
8857
8858    #[test]
8859    fn ui_height_returns_term_height_altscreen_mode() {
8860        // Verify TerminalWriter.ui_height() returns full terminal height in alt-screen mode
8861        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8862        use ftui_core::terminal_capabilities::TerminalCapabilities;
8863
8864        let output = Vec::new();
8865        let mut writer = TerminalWriter::new(
8866            output,
8867            ScreenMode::AltScreen,
8868            UiAnchor::Bottom,
8869            TerminalCapabilities::basic(),
8870        );
8871        writer.set_size(80, 24);
8872        assert_eq!(writer.ui_height(), 24);
8873    }
8874
8875    #[test]
8876    fn inline_mode_frame_uses_ui_height_not_terminal_height() {
8877        // Verify that in inline mode, the model receives a frame with ui_height,
8878        // not the full terminal height. This is the core fix for bd-20vg.
8879        use crate::simulator::ProgramSimulator;
8880        use std::cell::Cell as StdCell;
8881
8882        thread_local! {
8883            static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
8884        }
8885
8886        struct FrameSizeTracker;
8887
8888        #[derive(Debug)]
8889        enum SizeMsg {
8890            Check,
8891        }
8892
8893        impl From<Event> for SizeMsg {
8894            fn from(_: Event) -> Self {
8895                SizeMsg::Check
8896            }
8897        }
8898
8899        impl Model for FrameSizeTracker {
8900            type Message = SizeMsg;
8901
8902            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
8903                Cmd::none()
8904            }
8905
8906            fn view(&self, frame: &mut Frame) {
8907                // Capture the frame height we receive
8908                CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
8909            }
8910        }
8911
8912        // Use simulator to verify frame dimension handling
8913        let mut sim = ProgramSimulator::new(FrameSizeTracker);
8914        sim.init();
8915
8916        // Capture with specific dimensions (simulates inline mode ui_height=10)
8917        let buf = sim.capture_frame(80, 10);
8918        assert_eq!(buf.height(), 10);
8919        assert_eq!(buf.width(), 80);
8920
8921        // Verify the frame has the correct dimensions
8922        // In inline mode with ui_height=10, the frame should be 10 rows tall,
8923        // NOT the full terminal height (e.g., 24).
8924    }
8925
8926    #[test]
8927    fn altscreen_frame_uses_full_terminal_height() {
8928        // Regression test: in alt-screen mode, frame should use full terminal height.
8929        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8930        use ftui_core::terminal_capabilities::TerminalCapabilities;
8931
8932        let output = Vec::new();
8933        let mut writer = TerminalWriter::new(
8934            output,
8935            ScreenMode::AltScreen,
8936            UiAnchor::Bottom,
8937            TerminalCapabilities::basic(),
8938        );
8939        writer.set_size(80, 40);
8940
8941        // In alt-screen, ui_height equals terminal height
8942        assert_eq!(writer.ui_height(), 40);
8943    }
8944
8945    #[test]
8946    fn ui_height_clamped_to_terminal_height() {
8947        // Verify ui_height doesn't exceed terminal height
8948        // (This is handled in present_inline, but ui_height() returns the configured value)
8949        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8950        use ftui_core::terminal_capabilities::TerminalCapabilities;
8951
8952        let output = Vec::new();
8953        let mut writer = TerminalWriter::new(
8954            output,
8955            ScreenMode::Inline { ui_height: 100 },
8956            UiAnchor::Bottom,
8957            TerminalCapabilities::basic(),
8958        );
8959        writer.set_size(80, 10);
8960
8961        // ui_height() returns configured value, but present_inline clamps
8962        // The Frame should be created with ui_height (100), which is later
8963        // clamped during presentation. For safety, we should use the min.
8964        // Note: This documents current behavior. A stricter fix might
8965        // have ui_height() return min(ui_height, term_height).
8966        assert_eq!(writer.ui_height(), 100);
8967    }
8968
8969    // =========================================================================
8970    // TICK DELIVERY TESTS (bd-3ufh)
8971    // =========================================================================
8972
8973    #[test]
8974    fn tick_event_delivered_to_model_update() {
8975        // Verify that Event::Tick is delivered to model.update()
8976        // This is the core fix: ticks now flow through the update pipeline.
8977        use crate::simulator::ProgramSimulator;
8978
8979        struct TickTracker {
8980            tick_count: usize,
8981        }
8982
8983        #[derive(Debug)]
8984        enum TickMsg {
8985            Tick,
8986            Other,
8987        }
8988
8989        impl From<Event> for TickMsg {
8990            fn from(event: Event) -> Self {
8991                match event {
8992                    Event::Tick => TickMsg::Tick,
8993                    _ => TickMsg::Other,
8994                }
8995            }
8996        }
8997
8998        impl Model for TickTracker {
8999            type Message = TickMsg;
9000
9001            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9002                match msg {
9003                    TickMsg::Tick => {
9004                        self.tick_count += 1;
9005                        Cmd::none()
9006                    }
9007                    TickMsg::Other => Cmd::none(),
9008                }
9009            }
9010
9011            fn view(&self, _frame: &mut Frame) {}
9012        }
9013
9014        let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
9015        sim.init();
9016
9017        // Manually inject tick event to simulate what the runtime does
9018        sim.inject_event(Event::Tick);
9019        assert_eq!(sim.model().tick_count, 1);
9020
9021        sim.inject_event(Event::Tick);
9022        sim.inject_event(Event::Tick);
9023        assert_eq!(sim.model().tick_count, 3);
9024    }
9025
9026    #[test]
9027    fn tick_command_sets_tick_rate() {
9028        // Verify Cmd::tick() sets the tick rate in the simulator
9029        use crate::simulator::{CmdRecord, ProgramSimulator};
9030
9031        struct TickModel;
9032
9033        #[derive(Debug)]
9034        enum Msg {
9035            SetTick,
9036            Noop,
9037        }
9038
9039        impl From<Event> for Msg {
9040            fn from(_: Event) -> Self {
9041                Msg::Noop
9042            }
9043        }
9044
9045        impl Model for TickModel {
9046            type Message = Msg;
9047
9048            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9049                match msg {
9050                    Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
9051                    Msg::Noop => Cmd::none(),
9052                }
9053            }
9054
9055            fn view(&self, _frame: &mut Frame) {}
9056        }
9057
9058        let mut sim = ProgramSimulator::new(TickModel);
9059        sim.init();
9060        sim.send(Msg::SetTick);
9061
9062        // Check that tick was recorded
9063        let commands = sim.command_log();
9064        assert!(
9065            commands
9066                .iter()
9067                .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
9068        );
9069    }
9070
9071    #[test]
9072    fn tick_can_trigger_further_commands() {
9073        // Verify that tick handling can return commands that are executed
9074        use crate::simulator::ProgramSimulator;
9075
9076        struct ChainModel {
9077            stage: usize,
9078        }
9079
9080        #[derive(Debug)]
9081        enum ChainMsg {
9082            Tick,
9083            Advance,
9084            Noop,
9085        }
9086
9087        impl From<Event> for ChainMsg {
9088            fn from(event: Event) -> Self {
9089                match event {
9090                    Event::Tick => ChainMsg::Tick,
9091                    _ => ChainMsg::Noop,
9092                }
9093            }
9094        }
9095
9096        impl Model for ChainModel {
9097            type Message = ChainMsg;
9098
9099            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9100                match msg {
9101                    ChainMsg::Tick => {
9102                        self.stage += 1;
9103                        // Return another message to be processed
9104                        Cmd::msg(ChainMsg::Advance)
9105                    }
9106                    ChainMsg::Advance => {
9107                        self.stage += 10;
9108                        Cmd::none()
9109                    }
9110                    ChainMsg::Noop => Cmd::none(),
9111                }
9112            }
9113
9114            fn view(&self, _frame: &mut Frame) {}
9115        }
9116
9117        let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
9118        sim.init();
9119        sim.inject_event(Event::Tick);
9120
9121        // Tick increments by 1, then Advance increments by 10
9122        assert_eq!(sim.model().stage, 11);
9123    }
9124
9125    #[test]
9126    fn tick_disabled_with_zero_duration() {
9127        // Verify that Duration::ZERO disables ticks (no busy loop)
9128        use crate::simulator::ProgramSimulator;
9129
9130        struct ZeroTickModel {
9131            disabled: bool,
9132        }
9133
9134        #[derive(Debug)]
9135        enum ZeroMsg {
9136            DisableTick,
9137            Noop,
9138        }
9139
9140        impl From<Event> for ZeroMsg {
9141            fn from(_: Event) -> Self {
9142                ZeroMsg::Noop
9143            }
9144        }
9145
9146        impl Model for ZeroTickModel {
9147            type Message = ZeroMsg;
9148
9149            fn init(&mut self) -> Cmd<Self::Message> {
9150                // Start with a tick enabled
9151                Cmd::tick(Duration::from_millis(100))
9152            }
9153
9154            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9155                match msg {
9156                    ZeroMsg::DisableTick => {
9157                        self.disabled = true;
9158                        // Setting tick to ZERO should effectively disable
9159                        Cmd::tick(Duration::ZERO)
9160                    }
9161                    ZeroMsg::Noop => Cmd::none(),
9162                }
9163            }
9164
9165            fn view(&self, _frame: &mut Frame) {}
9166        }
9167
9168        let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
9169        sim.init();
9170
9171        // Verify initial tick rate is set
9172        assert!(sim.tick_rate().is_some());
9173        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
9174
9175        // Disable ticks
9176        sim.send(ZeroMsg::DisableTick);
9177        assert!(sim.model().disabled);
9178
9179        // Note: The simulator still records the ZERO tick, but the runtime's
9180        // should_tick() handles ZERO duration appropriately
9181        assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
9182    }
9183
9184    #[test]
9185    fn tick_event_distinguishable_from_other_events() {
9186        // Verify Event::Tick can be distinguished in pattern matching
9187        let tick = Event::Tick;
9188        let key = Event::Key(ftui_core::event::KeyEvent::new(
9189            ftui_core::event::KeyCode::Char('a'),
9190        ));
9191
9192        assert!(matches!(tick, Event::Tick));
9193        assert!(!matches!(key, Event::Tick));
9194    }
9195
9196    #[test]
9197    fn tick_event_clone_and_eq() {
9198        // Verify Event::Tick implements Clone and Eq correctly
9199        let tick1 = Event::Tick;
9200        let tick2 = tick1.clone();
9201        assert_eq!(tick1, tick2);
9202    }
9203
9204    #[test]
9205    fn model_receives_tick_and_input_events() {
9206        // Verify model can handle both tick and input events correctly
9207        use crate::simulator::ProgramSimulator;
9208
9209        struct MixedModel {
9210            ticks: usize,
9211            keys: usize,
9212        }
9213
9214        #[derive(Debug)]
9215        enum MixedMsg {
9216            Tick,
9217            Key,
9218        }
9219
9220        impl From<Event> for MixedMsg {
9221            fn from(event: Event) -> Self {
9222                match event {
9223                    Event::Tick => MixedMsg::Tick,
9224                    _ => MixedMsg::Key,
9225                }
9226            }
9227        }
9228
9229        impl Model for MixedModel {
9230            type Message = MixedMsg;
9231
9232            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9233                match msg {
9234                    MixedMsg::Tick => {
9235                        self.ticks += 1;
9236                        Cmd::none()
9237                    }
9238                    MixedMsg::Key => {
9239                        self.keys += 1;
9240                        Cmd::none()
9241                    }
9242                }
9243            }
9244
9245            fn view(&self, _frame: &mut Frame) {}
9246        }
9247
9248        let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
9249        sim.init();
9250
9251        // Interleave tick and input events
9252        sim.inject_event(Event::Tick);
9253        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
9254            ftui_core::event::KeyCode::Char('a'),
9255        )));
9256        sim.inject_event(Event::Tick);
9257        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
9258            ftui_core::event::KeyCode::Char('b'),
9259        )));
9260        sim.inject_event(Event::Tick);
9261
9262        assert_eq!(sim.model().ticks, 3);
9263        assert_eq!(sim.model().keys, 2);
9264    }
9265
9266    // =========================================================================
9267    // HEADLESS PROGRAM TESTS (bd-1av4o.2)
9268    // =========================================================================
9269
9270    fn headless_program_with_resolved_config<M: Model>(
9271        model: M,
9272        config: ProgramConfig,
9273    ) -> Program<M, HeadlessEventSource, Vec<u8>>
9274    where
9275        M::Message: Send + 'static,
9276    {
9277        clear_termination_signal();
9278        let effect_queue_config = config.resolved_effect_queue_config();
9279        let capabilities = TerminalCapabilities::basic();
9280        let mut writer = TerminalWriter::with_diff_config(
9281            Vec::new(),
9282            config.screen_mode,
9283            config.ui_anchor,
9284            capabilities,
9285            config.diff_config.clone(),
9286        );
9287        let frame_timing = config.frame_timing.clone();
9288        writer.set_timing_enabled(frame_timing.is_some());
9289
9290        let (width, height) = config.forced_size.unwrap_or((80, 24));
9291        let width = width.max(1);
9292        let height = height.max(1);
9293        writer.set_size(width, height);
9294
9295        let mouse_capture = config.resolved_mouse_capture();
9296        let initial_features = BackendFeatures {
9297            mouse_capture,
9298            bracketed_paste: config.bracketed_paste,
9299            focus_events: config.focus_reporting,
9300            kitty_keyboard: config.kitty_keyboard,
9301        };
9302        let events = HeadlessEventSource::new(width, height, initial_features);
9303        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
9304            .expect("headless evidence sink config");
9305
9306        let budget = render_budget_from_program_config(&config);
9307        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
9308        let locale_context = config.locale_context.clone();
9309        let locale_version = locale_context.version();
9310        let mut resize_coalescer =
9311            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
9312        if let Some(ref sink) = evidence_sink {
9313            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
9314        }
9315        let subscriptions = SubscriptionManager::new();
9316        let (task_sender, task_receiver) = std::sync::mpsc::channel();
9317        let inline_auto_remeasure = config
9318            .inline_auto_remeasure
9319            .clone()
9320            .map(InlineAutoRemeasureState::new);
9321        let guardrails = FrameGuardrails::new(config.guardrails);
9322        let task_executor = TaskExecutor::new(
9323            &effect_queue_config,
9324            task_sender.clone(),
9325            evidence_sink.clone(),
9326        )
9327        .expect("task executor");
9328
9329        Program {
9330            model,
9331            writer,
9332            events,
9333            backend_features: initial_features,
9334            running: true,
9335            tick_rate: None,
9336            executed_cmd_count: 0,
9337            last_tick: Instant::now(),
9338            dirty: true,
9339            frame_idx: 0,
9340            tick_count: 0,
9341            widget_signals: Vec::new(),
9342            widget_refresh_config: config.widget_refresh,
9343            widget_refresh_plan: WidgetRefreshPlan::new(),
9344            width,
9345            height,
9346            forced_size: config.forced_size,
9347            poll_timeout: config.poll_timeout,
9348            intercept_signals: config.intercept_signals,
9349            immediate_drain_config: config.immediate_drain,
9350            immediate_drain_stats: ImmediateDrainStats::default(),
9351            budget,
9352            conformal_predictor,
9353            last_frame_time_us: None,
9354            last_update_us: None,
9355            frame_timing,
9356            locale_context,
9357            locale_version,
9358            resize_coalescer,
9359            evidence_sink,
9360            fairness_config_logged: false,
9361            resize_behavior: config.resize_behavior,
9362            fairness_guard: InputFairnessGuard::new(),
9363            event_recorder: None,
9364            subscriptions,
9365            #[cfg(test)]
9366            task_sender,
9367            task_receiver,
9368            task_executor,
9369            state_registry: config.persistence.registry.clone(),
9370            persistence_config: config.persistence,
9371            last_checkpoint: Instant::now(),
9372            inline_auto_remeasure,
9373            frame_arena: FrameArena::default(),
9374            guardrails,
9375            tick_strategy: config
9376                .tick_strategy
9377                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
9378            last_active_screen_for_strategy: None,
9379        }
9380    }
9381
9382    fn headless_program_with_config<M: Model>(
9383        model: M,
9384        config: ProgramConfig,
9385    ) -> Program<M, HeadlessEventSource, Vec<u8>>
9386    where
9387        M::Message: Send + 'static,
9388    {
9389        // Headless unit tests should not observe process-global shutdown state
9390        // unless they explicitly opt into signal interception.
9391        headless_program_with_resolved_config(model, config.with_signal_interception(false))
9392    }
9393
9394    fn headless_signal_program_with_config<M: Model>(
9395        model: M,
9396        config: ProgramConfig,
9397    ) -> Program<M, HeadlessEventSource, Vec<u8>>
9398    where
9399        M::Message: Send + 'static,
9400    {
9401        headless_program_with_resolved_config(model, config)
9402    }
9403
9404    fn temp_evidence_path(label: &str) -> PathBuf {
9405        static COUNTER: AtomicUsize = AtomicUsize::new(0);
9406        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
9407        let pid = std::process::id();
9408        let mut path = std::env::temp_dir();
9409        path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
9410        path
9411    }
9412
9413    fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
9414        let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
9415        let needle = format!("\"event\":\"{event}\"");
9416        let missing_msg = format!("missing {event} line");
9417        let line = jsonl
9418            .lines()
9419            .find(|line| line.contains(&needle))
9420            .expect(&missing_msg);
9421        serde_json::from_str(line).expect("valid evidence json")
9422    }
9423
9424    #[test]
9425    fn headless_apply_resize_updates_model_and_dimensions() {
9426        struct ResizeModel {
9427            last_size: Option<(u16, u16)>,
9428        }
9429
9430        #[derive(Debug)]
9431        enum ResizeMsg {
9432            Resize(u16, u16),
9433            Other,
9434        }
9435
9436        impl From<Event> for ResizeMsg {
9437            fn from(event: Event) -> Self {
9438                match event {
9439                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
9440                    _ => ResizeMsg::Other,
9441                }
9442            }
9443        }
9444
9445        impl Model for ResizeModel {
9446            type Message = ResizeMsg;
9447
9448            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9449                if let ResizeMsg::Resize(w, h) = msg {
9450                    self.last_size = Some((w, h));
9451                }
9452                Cmd::none()
9453            }
9454
9455            fn view(&self, _frame: &mut Frame) {}
9456        }
9457
9458        let mut program =
9459            headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
9460        program.dirty = false;
9461
9462        program
9463            .apply_resize(0, 0, Duration::ZERO, false)
9464            .expect("resize");
9465
9466        assert_eq!(program.width, 1);
9467        assert_eq!(program.height, 1);
9468        assert_eq!(program.model().last_size, Some((1, 1)));
9469        assert!(program.dirty);
9470    }
9471
9472    #[test]
9473    fn headless_apply_resize_reconciles_subscriptions() {
9474        use crate::subscription::{StopSignal, SubId, Subscription};
9475
9476        struct ResizeSubModel {
9477            subscribed: bool,
9478        }
9479
9480        #[derive(Debug)]
9481        enum ResizeSubMsg {
9482            Resize,
9483            Other,
9484        }
9485
9486        impl From<Event> for ResizeSubMsg {
9487            fn from(event: Event) -> Self {
9488                match event {
9489                    Event::Resize { .. } => Self::Resize,
9490                    _ => Self::Other,
9491                }
9492            }
9493        }
9494
9495        impl Model for ResizeSubModel {
9496            type Message = ResizeSubMsg;
9497
9498            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9499                if matches!(msg, ResizeSubMsg::Resize) {
9500                    self.subscribed = true;
9501                }
9502                Cmd::none()
9503            }
9504
9505            fn view(&self, _frame: &mut Frame) {}
9506
9507            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
9508                if self.subscribed {
9509                    vec![Box::new(ResizeSubscription)]
9510                } else {
9511                    vec![]
9512                }
9513            }
9514        }
9515
9516        struct ResizeSubscription;
9517
9518        impl Subscription<ResizeSubMsg> for ResizeSubscription {
9519            fn id(&self) -> SubId {
9520                1
9521            }
9522
9523            fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
9524        }
9525
9526        let mut program = headless_program_with_config(
9527            ResizeSubModel { subscribed: false },
9528            ProgramConfig::default(),
9529        );
9530
9531        assert_eq!(program.subscriptions.active_count(), 0);
9532        program
9533            .apply_resize(120, 40, Duration::ZERO, false)
9534            .expect("resize");
9535
9536        assert!(program.model().subscribed);
9537        assert_eq!(program.subscriptions.active_count(), 1);
9538    }
9539
9540    #[test]
9541    fn headless_execute_cmd_log_writes_output() {
9542        let mut program =
9543            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9544        program.execute_cmd(Cmd::log("hello world")).expect("log");
9545
9546        let bytes = program.writer.into_inner().expect("writer output");
9547        let output = String::from_utf8_lossy(&bytes);
9548        assert!(output.contains("hello world"));
9549    }
9550
9551    #[test]
9552    fn headless_process_task_results_updates_model() {
9553        struct TaskModel {
9554            updates: usize,
9555        }
9556
9557        #[derive(Debug)]
9558        enum TaskMsg {
9559            Done,
9560        }
9561
9562        impl From<Event> for TaskMsg {
9563            fn from(_: Event) -> Self {
9564                TaskMsg::Done
9565            }
9566        }
9567
9568        impl Model for TaskModel {
9569            type Message = TaskMsg;
9570
9571            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9572                self.updates += 1;
9573                Cmd::none()
9574            }
9575
9576            fn view(&self, _frame: &mut Frame) {}
9577        }
9578
9579        let mut program =
9580            headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
9581        program.dirty = false;
9582        program.task_sender.send(TaskMsg::Done).unwrap();
9583
9584        program
9585            .process_task_results()
9586            .expect("process task results");
9587        assert_eq!(program.model().updates, 1);
9588        assert!(program.dirty);
9589    }
9590
9591    #[test]
9592    fn run_invokes_on_shutdown_after_quit() {
9593        use std::sync::{
9594            Arc,
9595            atomic::{AtomicUsize, Ordering},
9596        };
9597
9598        struct ShutdownModel {
9599            shutdowns: Arc<AtomicUsize>,
9600        }
9601
9602        #[derive(Debug, Clone, Copy)]
9603        enum ShutdownMsg {
9604            Quit,
9605            ShutdownRan,
9606        }
9607
9608        impl From<Event> for ShutdownMsg {
9609            fn from(_: Event) -> Self {
9610                ShutdownMsg::Quit
9611            }
9612        }
9613
9614        impl Model for ShutdownModel {
9615            type Message = ShutdownMsg;
9616
9617            fn init(&mut self) -> Cmd<Self::Message> {
9618                Cmd::quit()
9619            }
9620
9621            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9622                match msg {
9623                    ShutdownMsg::Quit => Cmd::quit(),
9624                    ShutdownMsg::ShutdownRan => {
9625                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9626                        Cmd::none()
9627                    }
9628                }
9629            }
9630
9631            fn view(&self, _frame: &mut Frame) {}
9632
9633            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9634                Cmd::msg(ShutdownMsg::ShutdownRan)
9635            }
9636        }
9637
9638        let shutdowns = Arc::new(AtomicUsize::new(0));
9639        let mut program = headless_program_with_config(
9640            ShutdownModel {
9641                shutdowns: Arc::clone(&shutdowns),
9642            },
9643            ProgramConfig::default(),
9644        );
9645
9646        program.run().expect("program run");
9647
9648        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9649    }
9650
9651    #[test]
9652    fn run_processes_shutdown_task_results_before_exit() {
9653        use std::sync::{
9654            Arc,
9655            atomic::{AtomicUsize, Ordering},
9656        };
9657
9658        struct ShutdownTaskModel {
9659            shutdowns: Arc<AtomicUsize>,
9660        }
9661
9662        #[derive(Debug, Clone, Copy)]
9663        enum ShutdownTaskMsg {
9664            Quit,
9665            ShutdownRan,
9666        }
9667
9668        impl From<Event> for ShutdownTaskMsg {
9669            fn from(_: Event) -> Self {
9670                ShutdownTaskMsg::Quit
9671            }
9672        }
9673
9674        impl Model for ShutdownTaskModel {
9675            type Message = ShutdownTaskMsg;
9676
9677            fn init(&mut self) -> Cmd<Self::Message> {
9678                Cmd::quit()
9679            }
9680
9681            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9682                match msg {
9683                    ShutdownTaskMsg::Quit => Cmd::quit(),
9684                    ShutdownTaskMsg::ShutdownRan => {
9685                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9686                        Cmd::none()
9687                    }
9688                }
9689            }
9690
9691            fn view(&self, _frame: &mut Frame) {}
9692
9693            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9694                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
9695            }
9696        }
9697
9698        let shutdowns = Arc::new(AtomicUsize::new(0));
9699        let mut program = headless_program_with_config(
9700            ShutdownTaskModel {
9701                shutdowns: Arc::clone(&shutdowns),
9702            },
9703            ProgramConfig::default(),
9704        );
9705
9706        program.run().expect("program run");
9707
9708        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9709    }
9710
9711    #[test]
9712    fn run_processes_shutdown_task_results_with_effect_queue_backend() {
9713        use std::sync::{
9714            Arc,
9715            atomic::{AtomicUsize, Ordering},
9716        };
9717
9718        struct ShutdownTaskModel {
9719            shutdowns: Arc<AtomicUsize>,
9720        }
9721
9722        #[derive(Debug, Clone, Copy)]
9723        enum ShutdownTaskMsg {
9724            Quit,
9725            ShutdownRan,
9726        }
9727
9728        impl From<Event> for ShutdownTaskMsg {
9729            fn from(_: Event) -> Self {
9730                ShutdownTaskMsg::Quit
9731            }
9732        }
9733
9734        impl Model for ShutdownTaskModel {
9735            type Message = ShutdownTaskMsg;
9736
9737            fn init(&mut self) -> Cmd<Self::Message> {
9738                Cmd::quit()
9739            }
9740
9741            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9742                match msg {
9743                    ShutdownTaskMsg::Quit => Cmd::quit(),
9744                    ShutdownTaskMsg::ShutdownRan => {
9745                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9746                        Cmd::none()
9747                    }
9748                }
9749            }
9750
9751            fn view(&self, _frame: &mut Frame) {}
9752
9753            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9754                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
9755            }
9756        }
9757
9758        let shutdowns = Arc::new(AtomicUsize::new(0));
9759        let mut program = headless_program_with_config(
9760            ShutdownTaskModel {
9761                shutdowns: Arc::clone(&shutdowns),
9762            },
9763            ProgramConfig::default().with_effect_queue(
9764                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
9765            ),
9766        );
9767
9768        program.run().expect("program run");
9769
9770        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9771    }
9772
9773    #[test]
9774    fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
9775        use std::sync::{
9776            Arc,
9777            atomic::{AtomicUsize, Ordering},
9778        };
9779
9780        struct ShutdownTaskModel {
9781            shutdowns: Arc<AtomicUsize>,
9782            follow_up_runs: Arc<AtomicUsize>,
9783        }
9784
9785        #[derive(Debug, Clone, Copy)]
9786        enum ShutdownTaskMsg {
9787            Quit,
9788            ShutdownRan,
9789            FollowUp,
9790        }
9791
9792        impl From<Event> for ShutdownTaskMsg {
9793            fn from(_: Event) -> Self {
9794                ShutdownTaskMsg::Quit
9795            }
9796        }
9797
9798        impl Model for ShutdownTaskModel {
9799            type Message = ShutdownTaskMsg;
9800
9801            fn init(&mut self) -> Cmd<Self::Message> {
9802                Cmd::quit()
9803            }
9804
9805            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9806                match msg {
9807                    ShutdownTaskMsg::Quit => Cmd::quit(),
9808                    ShutdownTaskMsg::ShutdownRan => {
9809                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9810                        let follow_up_runs = Arc::clone(&self.follow_up_runs);
9811                        Cmd::task(move || {
9812                            follow_up_runs.fetch_add(1, Ordering::SeqCst);
9813                            ShutdownTaskMsg::FollowUp
9814                        })
9815                    }
9816                    ShutdownTaskMsg::FollowUp => {
9817                        self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
9818                        Cmd::none()
9819                    }
9820                }
9821            }
9822
9823            fn view(&self, _frame: &mut Frame) {}
9824
9825            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9826                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
9827            }
9828        }
9829
9830        let shutdowns = Arc::new(AtomicUsize::new(0));
9831        let follow_up_runs = Arc::new(AtomicUsize::new(0));
9832        let mut program = headless_program_with_config(
9833            ShutdownTaskModel {
9834                shutdowns: Arc::clone(&shutdowns),
9835                follow_up_runs: Arc::clone(&follow_up_runs),
9836            },
9837            ProgramConfig::default(),
9838        );
9839
9840        program.run().expect("program run");
9841
9842        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9843        assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
9844    }
9845
9846    #[test]
9847    fn run_quit_from_init_skips_initial_render_and_subscription_start() {
9848        use crate::subscription::{StopSignal, SubId, Subscription};
9849
9850        struct InitQuitModel {
9851            render_calls: Arc<AtomicUsize>,
9852            subscription_starts: Arc<AtomicUsize>,
9853        }
9854
9855        #[derive(Debug, Clone, Copy)]
9856        enum InitQuitMsg {
9857            Noop,
9858        }
9859
9860        impl From<Event> for InitQuitMsg {
9861            fn from(_: Event) -> Self {
9862                Self::Noop
9863            }
9864        }
9865
9866        impl Model for InitQuitModel {
9867            type Message = InitQuitMsg;
9868
9869            fn init(&mut self) -> Cmd<Self::Message> {
9870                Cmd::quit()
9871            }
9872
9873            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
9874                Cmd::none()
9875            }
9876
9877            fn view(&self, _frame: &mut Frame) {
9878                self.render_calls.fetch_add(1, Ordering::SeqCst);
9879            }
9880
9881            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
9882                vec![Box::new(InitQuitSubscription {
9883                    starts: Arc::clone(&self.subscription_starts),
9884                })]
9885            }
9886        }
9887
9888        struct InitQuitSubscription {
9889            starts: Arc<AtomicUsize>,
9890        }
9891
9892        impl Subscription<InitQuitMsg> for InitQuitSubscription {
9893            fn id(&self) -> SubId {
9894                1
9895            }
9896
9897            fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
9898                self.starts.fetch_add(1, Ordering::SeqCst);
9899                let _ = stop.wait_timeout(Duration::from_millis(10));
9900            }
9901        }
9902
9903        let render_calls = Arc::new(AtomicUsize::new(0));
9904        let subscription_starts = Arc::new(AtomicUsize::new(0));
9905        let mut program = headless_program_with_config(
9906            InitQuitModel {
9907                render_calls: Arc::clone(&render_calls),
9908                subscription_starts: Arc::clone(&subscription_starts),
9909            },
9910            ProgramConfig::default(),
9911        );
9912
9913        program.run().expect("program run");
9914
9915        assert_eq!(render_calls.load(Ordering::SeqCst), 0);
9916        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
9917    }
9918
9919    #[test]
9920    fn run_invokes_on_shutdown_before_returning_signal_error() {
9921        use std::sync::{
9922            Arc,
9923            atomic::{AtomicUsize, Ordering},
9924        };
9925
9926        struct ShutdownModel {
9927            shutdowns: Arc<AtomicUsize>,
9928        }
9929
9930        #[derive(Debug, Clone, Copy)]
9931        enum ShutdownMsg {
9932            Noop,
9933            ShutdownRan,
9934        }
9935
9936        impl From<Event> for ShutdownMsg {
9937            fn from(_: Event) -> Self {
9938                ShutdownMsg::Noop
9939            }
9940        }
9941
9942        impl Model for ShutdownModel {
9943            type Message = ShutdownMsg;
9944
9945            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9946                match msg {
9947                    ShutdownMsg::Noop => Cmd::none(),
9948                    ShutdownMsg::ShutdownRan => {
9949                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9950                        Cmd::none()
9951                    }
9952                }
9953            }
9954
9955            fn view(&self, _frame: &mut Frame) {}
9956
9957            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9958                Cmd::msg(ShutdownMsg::ShutdownRan)
9959            }
9960        }
9961
9962        let shutdowns = Arc::new(AtomicUsize::new(0));
9963        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
9964            let mut program = headless_signal_program_with_config(
9965                ShutdownModel {
9966                    shutdowns: Arc::clone(&shutdowns),
9967                },
9968                ProgramConfig::default().with_signal_interception(true),
9969            );
9970
9971            ftui_core::shutdown_signal::record_pending_termination_signal(2);
9972            let err = program.run().expect_err("signal should stop runtime");
9973
9974            assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9975            assert_eq!(signal_termination_from_error(&err), Some(2));
9976            assert_eq!(check_termination_signal(), None);
9977        });
9978    }
9979
9980    #[test]
9981    fn run_pending_signal_skips_initial_render_and_subscription_start() {
9982        use crate::subscription::{StopSignal, SubId, Subscription};
9983
9984        struct SignalStopModel {
9985            render_calls: Arc<AtomicUsize>,
9986            subscription_starts: Arc<AtomicUsize>,
9987        }
9988
9989        #[derive(Debug, Clone, Copy)]
9990        enum SignalStopMsg {
9991            Noop,
9992        }
9993
9994        impl From<Event> for SignalStopMsg {
9995            fn from(_: Event) -> Self {
9996                Self::Noop
9997            }
9998        }
9999
10000        impl Model for SignalStopModel {
10001            type Message = SignalStopMsg;
10002
10003            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
10004                Cmd::none()
10005            }
10006
10007            fn view(&self, _frame: &mut Frame) {
10008                self.render_calls.fetch_add(1, Ordering::SeqCst);
10009            }
10010
10011            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10012                vec![Box::new(SignalStopSubscription {
10013                    starts: Arc::clone(&self.subscription_starts),
10014                })]
10015            }
10016        }
10017
10018        struct SignalStopSubscription {
10019            starts: Arc<AtomicUsize>,
10020        }
10021
10022        impl Subscription<SignalStopMsg> for SignalStopSubscription {
10023            fn id(&self) -> SubId {
10024                11
10025            }
10026
10027            fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
10028                self.starts.fetch_add(1, Ordering::SeqCst);
10029                let _ = stop.wait_timeout(Duration::from_millis(10));
10030            }
10031        }
10032
10033        let render_calls = Arc::new(AtomicUsize::new(0));
10034        let subscription_starts = Arc::new(AtomicUsize::new(0));
10035        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
10036            let mut program = headless_signal_program_with_config(
10037                SignalStopModel {
10038                    render_calls: Arc::clone(&render_calls),
10039                    subscription_starts: Arc::clone(&subscription_starts),
10040                },
10041                ProgramConfig::default().with_signal_interception(true),
10042            );
10043
10044            ftui_core::shutdown_signal::record_pending_termination_signal(15);
10045            let err = program.run().expect_err("signal should stop runtime");
10046
10047            assert_eq!(signal_termination_from_error(&err), Some(15));
10048            assert_eq!(render_calls.load(Ordering::SeqCst), 0);
10049            assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
10050            assert_eq!(check_termination_signal(), None);
10051        });
10052    }
10053
10054    #[test]
10055    fn headless_should_tick_and_timeout_behaviors() {
10056        let mut program =
10057            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10058        program.tick_rate = Some(Duration::from_millis(5));
10059        program.last_tick = Instant::now() - Duration::from_millis(10);
10060
10061        assert!(program.should_tick());
10062        assert!(!program.should_tick());
10063
10064        let timeout = program.effective_timeout();
10065        assert!(timeout <= Duration::from_millis(5));
10066
10067        program.tick_rate = None;
10068        program.poll_timeout = Duration::from_millis(33);
10069        assert_eq!(program.effective_timeout(), Duration::from_millis(33));
10070    }
10071
10072    #[test]
10073    fn headless_effective_timeout_respects_resize_coalescer() {
10074        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
10075        config.resize_coalescer.steady_delay_ms = 0;
10076        config.resize_coalescer.burst_delay_ms = 0;
10077
10078        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
10079        program.tick_rate = Some(Duration::from_millis(50));
10080
10081        program.resize_coalescer.handle_resize(120, 40);
10082        assert!(program.resize_coalescer.has_pending());
10083
10084        let timeout = program.effective_timeout();
10085        assert_eq!(timeout, Duration::ZERO);
10086    }
10087
10088    #[test]
10089    fn headless_ui_height_remeasure_clears_auto_height() {
10090        let mut config = ProgramConfig::inline_auto(2, 6);
10091        config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
10092
10093        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
10094        program.dirty = false;
10095        program.writer.set_auto_ui_height(5);
10096
10097        assert_eq!(program.writer.auto_ui_height(), Some(5));
10098        program.request_ui_height_remeasure();
10099
10100        assert_eq!(program.writer.auto_ui_height(), None);
10101        assert!(program.dirty);
10102    }
10103
10104    #[test]
10105    fn headless_recording_lifecycle_and_locale_change() {
10106        let mut program =
10107            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10108        program.dirty = false;
10109
10110        program.start_recording("demo");
10111        assert!(program.is_recording());
10112        let recorded = program.stop_recording();
10113        assert!(recorded.is_some());
10114        assert!(!program.is_recording());
10115
10116        let prev_dirty = program.dirty;
10117        program.locale_context.set_locale("fr");
10118        program.check_locale_change();
10119        assert!(program.dirty || prev_dirty);
10120    }
10121
10122    #[test]
10123    fn headless_render_frame_marks_clean_and_sets_diff() {
10124        struct RenderModel;
10125
10126        #[derive(Debug)]
10127        enum RenderMsg {
10128            Noop,
10129        }
10130
10131        impl From<Event> for RenderMsg {
10132            fn from(_: Event) -> Self {
10133                RenderMsg::Noop
10134            }
10135        }
10136
10137        impl Model for RenderModel {
10138            type Message = RenderMsg;
10139
10140            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10141                Cmd::none()
10142            }
10143
10144            fn view(&self, frame: &mut Frame) {
10145                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
10146            }
10147        }
10148
10149        let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
10150        program.render_frame().expect("render frame");
10151
10152        assert!(!program.dirty);
10153        assert!(program.writer.last_diff_strategy().is_some());
10154        assert_eq!(program.frame_idx, 1);
10155    }
10156
10157    #[test]
10158    fn headless_render_frame_skips_when_budget_exhausted() {
10159        let config = ProgramConfig {
10160            budget: FrameBudgetConfig::with_total(Duration::ZERO),
10161            ..Default::default()
10162        };
10163
10164        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
10165        program.dirty = true;
10166        program.render_frame().expect("render frame");
10167
10168        // Dirty state is preserved when frame is skipped — the UI update
10169        // was never presented and must be retried.
10170        assert!(program.dirty);
10171        assert_eq!(program.frame_idx, 1);
10172    }
10173
10174    #[test]
10175    fn headless_render_frame_emits_budget_evidence_with_controller() {
10176        use ftui_render::budget::BudgetControllerConfig;
10177
10178        struct RenderModel;
10179
10180        #[derive(Debug)]
10181        enum RenderMsg {
10182            Noop,
10183        }
10184
10185        impl From<Event> for RenderMsg {
10186            fn from(_: Event) -> Self {
10187                RenderMsg::Noop
10188            }
10189        }
10190
10191        impl Model for RenderModel {
10192            type Message = RenderMsg;
10193
10194            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10195                Cmd::none()
10196            }
10197
10198            fn view(&self, frame: &mut Frame) {
10199                frame.buffer.set_raw(0, 0, Cell::from_char('E'));
10200            }
10201        }
10202
10203        let config =
10204            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
10205        let mut program = headless_program_with_config(RenderModel, config);
10206        program.budget = program
10207            .budget
10208            .with_controller(BudgetControllerConfig::default());
10209
10210        program.render_frame().expect("render frame");
10211        assert!(program.budget.telemetry().is_some());
10212        assert_eq!(program.frame_idx, 1);
10213    }
10214
10215    #[test]
10216    fn headless_handle_event_updates_model() {
10217        struct EventModel {
10218            events: usize,
10219            last_resize: Option<(u16, u16)>,
10220        }
10221
10222        #[derive(Debug)]
10223        enum EventMsg {
10224            Resize(u16, u16),
10225            Other,
10226        }
10227
10228        impl From<Event> for EventMsg {
10229            fn from(event: Event) -> Self {
10230                match event {
10231                    Event::Resize { width, height } => EventMsg::Resize(width, height),
10232                    _ => EventMsg::Other,
10233                }
10234            }
10235        }
10236
10237        impl Model for EventModel {
10238            type Message = EventMsg;
10239
10240            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10241                self.events += 1;
10242                if let EventMsg::Resize(w, h) = msg {
10243                    self.last_resize = Some((w, h));
10244                }
10245                Cmd::none()
10246            }
10247
10248            fn view(&self, _frame: &mut Frame) {}
10249        }
10250
10251        let mut program = headless_program_with_config(
10252            EventModel {
10253                events: 0,
10254                last_resize: None,
10255            },
10256            ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
10257        );
10258
10259        program
10260            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
10261                ftui_core::event::KeyCode::Char('x'),
10262            )))
10263            .expect("handle key");
10264        assert_eq!(program.model().events, 1);
10265
10266        program
10267            .handle_event(Event::Resize {
10268                width: 10,
10269                height: 5,
10270            })
10271            .expect("handle resize");
10272        assert_eq!(program.model().events, 2);
10273        assert_eq!(program.model().last_resize, Some((10, 5)));
10274        assert_eq!(program.width, 10);
10275        assert_eq!(program.height, 5);
10276    }
10277
10278    #[test]
10279    fn headless_handle_event_quit_skips_subscription_reconcile() {
10280        use crate::subscription::{StopSignal, SubId, Subscription};
10281
10282        struct QuitSubModel {
10283            quitting: bool,
10284            subscription_starts: Arc<AtomicUsize>,
10285        }
10286
10287        #[derive(Debug)]
10288        enum QuitSubMsg {
10289            Quit,
10290            Other,
10291        }
10292
10293        impl From<Event> for QuitSubMsg {
10294            fn from(event: Event) -> Self {
10295                match event {
10296                    Event::Key(_) => Self::Quit,
10297                    _ => Self::Other,
10298                }
10299            }
10300        }
10301
10302        impl Model for QuitSubModel {
10303            type Message = QuitSubMsg;
10304
10305            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10306                match msg {
10307                    QuitSubMsg::Quit => {
10308                        self.quitting = true;
10309                        Cmd::quit()
10310                    }
10311                    QuitSubMsg::Other => Cmd::none(),
10312                }
10313            }
10314
10315            fn view(&self, _frame: &mut Frame) {}
10316
10317            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10318                if self.quitting {
10319                    vec![Box::new(QuitSubSubscription {
10320                        starts: Arc::clone(&self.subscription_starts),
10321                    })]
10322                } else {
10323                    vec![]
10324                }
10325            }
10326        }
10327
10328        struct QuitSubSubscription {
10329            starts: Arc<AtomicUsize>,
10330        }
10331
10332        impl Subscription<QuitSubMsg> for QuitSubSubscription {
10333            fn id(&self) -> SubId {
10334                7
10335            }
10336
10337            fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
10338                self.starts.fetch_add(1, Ordering::SeqCst);
10339                let _ = stop.wait_timeout(Duration::from_millis(10));
10340            }
10341        }
10342
10343        let subscription_starts = Arc::new(AtomicUsize::new(0));
10344        let mut program = headless_program_with_config(
10345            QuitSubModel {
10346                quitting: false,
10347                subscription_starts: Arc::clone(&subscription_starts),
10348            },
10349            ProgramConfig::default(),
10350        );
10351
10352        program
10353            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
10354                ftui_core::event::KeyCode::Char('q'),
10355            )))
10356            .expect("handle event");
10357
10358        assert!(!program.is_running());
10359        assert_eq!(program.subscriptions.active_count(), 0);
10360        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
10361    }
10362
10363    #[test]
10364    fn headless_handle_resize_ignored_when_forced_size() {
10365        struct ResizeModel {
10366            resized: bool,
10367        }
10368
10369        #[derive(Debug)]
10370        enum ResizeMsg {
10371            Resize,
10372            Other,
10373        }
10374
10375        impl From<Event> for ResizeMsg {
10376            fn from(event: Event) -> Self {
10377                match event {
10378                    Event::Resize { .. } => ResizeMsg::Resize,
10379                    _ => ResizeMsg::Other,
10380                }
10381            }
10382        }
10383
10384        impl Model for ResizeModel {
10385            type Message = ResizeMsg;
10386
10387            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10388                if matches!(msg, ResizeMsg::Resize) {
10389                    self.resized = true;
10390                }
10391                Cmd::none()
10392            }
10393
10394            fn view(&self, _frame: &mut Frame) {}
10395        }
10396
10397        let config = ProgramConfig::default().with_forced_size(80, 24);
10398        let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
10399
10400        program
10401            .handle_event(Event::Resize {
10402                width: 120,
10403                height: 40,
10404            })
10405            .expect("handle resize");
10406
10407        assert_eq!(program.width, 80);
10408        assert_eq!(program.height, 24);
10409        assert!(!program.model().resized);
10410    }
10411
10412    #[test]
10413    fn headless_execute_cmd_batch_sequence_and_quit() {
10414        struct BatchModel {
10415            count: usize,
10416        }
10417
10418        #[derive(Debug)]
10419        enum BatchMsg {
10420            Inc,
10421        }
10422
10423        impl From<Event> for BatchMsg {
10424            fn from(_: Event) -> Self {
10425                BatchMsg::Inc
10426            }
10427        }
10428
10429        impl Model for BatchModel {
10430            type Message = BatchMsg;
10431
10432            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10433                match msg {
10434                    BatchMsg::Inc => {
10435                        self.count += 1;
10436                        Cmd::none()
10437                    }
10438                }
10439            }
10440
10441            fn view(&self, _frame: &mut Frame) {}
10442        }
10443
10444        let mut program =
10445            headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
10446
10447        program
10448            .execute_cmd(Cmd::Batch(vec![
10449                Cmd::msg(BatchMsg::Inc),
10450                Cmd::Sequence(vec![
10451                    Cmd::msg(BatchMsg::Inc),
10452                    Cmd::quit(),
10453                    Cmd::msg(BatchMsg::Inc),
10454                ]),
10455            ]))
10456            .expect("batch cmd");
10457
10458        assert_eq!(program.model().count, 2);
10459        assert!(!program.running);
10460    }
10461
10462    #[test]
10463    fn headless_process_subscription_messages_updates_model() {
10464        use crate::subscription::{StopSignal, SubId, Subscription};
10465
10466        struct SubModel {
10467            pings: usize,
10468            ready_tx: mpsc::Sender<()>,
10469        }
10470
10471        #[derive(Debug)]
10472        enum SubMsg {
10473            Ping,
10474            Other,
10475        }
10476
10477        impl From<Event> for SubMsg {
10478            fn from(_: Event) -> Self {
10479                SubMsg::Other
10480            }
10481        }
10482
10483        impl Model for SubModel {
10484            type Message = SubMsg;
10485
10486            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10487                if let SubMsg::Ping = msg {
10488                    self.pings += 1;
10489                }
10490                Cmd::none()
10491            }
10492
10493            fn view(&self, _frame: &mut Frame) {}
10494
10495            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10496                vec![Box::new(TestSubscription {
10497                    ready_tx: self.ready_tx.clone(),
10498                })]
10499            }
10500        }
10501
10502        struct TestSubscription {
10503            ready_tx: mpsc::Sender<()>,
10504        }
10505
10506        impl Subscription<SubMsg> for TestSubscription {
10507            fn id(&self) -> SubId {
10508                1
10509            }
10510
10511            fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
10512                let _ = sender.send(SubMsg::Ping);
10513                let _ = self.ready_tx.send(());
10514            }
10515        }
10516
10517        let (ready_tx, ready_rx) = mpsc::channel();
10518        let mut program =
10519            headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
10520
10521        program.reconcile_subscriptions();
10522        ready_rx
10523            .recv_timeout(Duration::from_millis(200))
10524            .expect("subscription started");
10525        program
10526            .process_subscription_messages()
10527            .expect("process subscriptions");
10528
10529        assert_eq!(program.model().pings, 1);
10530    }
10531
10532    #[test]
10533    fn headless_execute_cmd_task_spawns_and_reaps() {
10534        struct TaskModel {
10535            done: bool,
10536        }
10537
10538        #[derive(Debug)]
10539        enum TaskMsg {
10540            Done,
10541        }
10542
10543        impl From<Event> for TaskMsg {
10544            fn from(_: Event) -> Self {
10545                TaskMsg::Done
10546            }
10547        }
10548
10549        impl Model for TaskModel {
10550            type Message = TaskMsg;
10551
10552            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10553                match msg {
10554                    TaskMsg::Done => {
10555                        self.done = true;
10556                        Cmd::none()
10557                    }
10558                }
10559            }
10560
10561            fn view(&self, _frame: &mut Frame) {}
10562        }
10563
10564        let mut program =
10565            headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
10566        program
10567            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10568            .expect("task cmd");
10569
10570        let deadline = Instant::now() + Duration::from_millis(200);
10571        while !program.model().done && Instant::now() <= deadline {
10572            program
10573                .process_task_results()
10574                .expect("process task results");
10575            program.reap_finished_tasks();
10576        }
10577
10578        assert!(program.model().done, "task result did not arrive in time");
10579    }
10580
10581    #[test]
10582    fn headless_default_task_executor_is_queued_for_structured_lane() {
10583        let program =
10584            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10585        assert_eq!(program.task_executor.kind_name(), "queued");
10586    }
10587
10588    #[test]
10589    fn headless_structured_lane_task_executor_writes_queued_backend_evidence() {
10590        let evidence_path = temp_evidence_path("task_executor_queued_backend");
10591        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10592        let config = ProgramConfig::default().with_evidence_sink(sink_config);
10593        let _program = headless_program_with_config(TestModel { value: 0 }, config);
10594
10595        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
10596        assert_eq!(backend_line["backend"], "queued");
10597    }
10598
10599    #[test]
10600    fn headless_legacy_lane_task_executor_is_spawned() {
10601        let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
10602        let program = headless_program_with_config(TestModel { value: 0 }, config);
10603        assert_eq!(program.task_executor.kind_name(), "spawned");
10604    }
10605
10606    #[test]
10607    fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
10608        let config = ProgramConfig::default().with_effect_queue(
10609            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
10610        );
10611        let program = headless_program_with_config(TestModel { value: 0 }, config);
10612        assert_eq!(program.task_executor.kind_name(), "spawned");
10613    }
10614
10615    #[cfg(feature = "asupersync-executor")]
10616    #[test]
10617    fn headless_asupersync_task_executor_is_selected() {
10618        let config = ProgramConfig::default().with_effect_queue(
10619            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
10620        );
10621        let program = headless_program_with_config(TestModel { value: 0 }, config);
10622        assert_eq!(program.task_executor.kind_name(), "asupersync");
10623    }
10624
10625    #[test]
10626    fn headless_persistence_commands_with_registry() {
10627        use crate::state_persistence::{MemoryStorage, StateRegistry};
10628        use std::sync::Arc;
10629
10630        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
10631        let config = ProgramConfig::default().with_registry(registry.clone());
10632        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
10633
10634        assert!(program.has_persistence());
10635        assert!(program.state_registry().is_some());
10636
10637        program.execute_cmd(Cmd::save_state()).expect("save");
10638        program.execute_cmd(Cmd::restore_state()).expect("restore");
10639
10640        let saved = program.trigger_save().expect("trigger save");
10641        let loaded = program.trigger_load().expect("trigger load");
10642        assert!(!saved);
10643        assert_eq!(loaded, 0);
10644    }
10645
10646    #[test]
10647    fn headless_process_resize_coalescer_applies_pending_resize() {
10648        struct ResizeModel {
10649            last_size: Option<(u16, u16)>,
10650        }
10651
10652        #[derive(Debug)]
10653        enum ResizeMsg {
10654            Resize(u16, u16),
10655            Other,
10656        }
10657
10658        impl From<Event> for ResizeMsg {
10659            fn from(event: Event) -> Self {
10660                match event {
10661                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
10662                    _ => ResizeMsg::Other,
10663                }
10664            }
10665        }
10666
10667        impl Model for ResizeModel {
10668            type Message = ResizeMsg;
10669
10670            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10671                if let ResizeMsg::Resize(w, h) = msg {
10672                    self.last_size = Some((w, h));
10673                }
10674                Cmd::none()
10675            }
10676
10677            fn view(&self, _frame: &mut Frame) {}
10678        }
10679
10680        let evidence_path = temp_evidence_path("fairness_allow");
10681        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10682        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
10683        config.resize_coalescer.steady_delay_ms = 0;
10684        config.resize_coalescer.burst_delay_ms = 0;
10685        config.resize_coalescer.hard_deadline_ms = 1_000;
10686        config.evidence_sink = sink_config.clone();
10687
10688        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
10689        let sink = EvidenceSink::from_config(&sink_config)
10690            .expect("evidence sink config")
10691            .expect("evidence sink enabled");
10692        program.evidence_sink = Some(sink);
10693
10694        program.resize_coalescer.handle_resize(120, 40);
10695        assert!(program.resize_coalescer.has_pending());
10696
10697        program
10698            .process_resize_coalescer()
10699            .expect("process resize coalescer");
10700
10701        assert_eq!(program.width, 120);
10702        assert_eq!(program.height, 40);
10703        assert_eq!(program.model().last_size, Some((120, 40)));
10704
10705        let config_line = read_evidence_event(&evidence_path, "fairness_config");
10706        assert_eq!(config_line["event"], "fairness_config");
10707        assert!(config_line["enabled"].is_boolean());
10708        assert!(config_line["input_priority_threshold_ms"].is_number());
10709        assert!(config_line["dominance_threshold"].is_number());
10710        assert!(config_line["fairness_threshold"].is_number());
10711
10712        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
10713        assert_eq!(decision_line["event"], "fairness_decision");
10714        assert_eq!(decision_line["decision"], "allow");
10715        assert_eq!(decision_line["reason"], "none");
10716        assert!(decision_line["pending_input_latency_ms"].is_null());
10717        assert!(decision_line["jain_index"].is_number());
10718        assert!(decision_line["resize_dominance_count"].is_number());
10719        assert!(decision_line["dominance_threshold"].is_number());
10720        assert!(decision_line["fairness_threshold"].is_number());
10721        assert!(decision_line["input_priority_threshold_ms"].is_number());
10722    }
10723
10724    #[test]
10725    fn headless_process_resize_coalescer_yields_to_input() {
10726        struct ResizeModel {
10727            last_size: Option<(u16, u16)>,
10728        }
10729
10730        #[derive(Debug)]
10731        enum ResizeMsg {
10732            Resize(u16, u16),
10733            Other,
10734        }
10735
10736        impl From<Event> for ResizeMsg {
10737            fn from(event: Event) -> Self {
10738                match event {
10739                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
10740                    _ => ResizeMsg::Other,
10741                }
10742            }
10743        }
10744
10745        impl Model for ResizeModel {
10746            type Message = ResizeMsg;
10747
10748            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10749                if let ResizeMsg::Resize(w, h) = msg {
10750                    self.last_size = Some((w, h));
10751                }
10752                Cmd::none()
10753            }
10754
10755            fn view(&self, _frame: &mut Frame) {}
10756        }
10757
10758        let evidence_path = temp_evidence_path("fairness_yield");
10759        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10760        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
10761        config.resize_coalescer.steady_delay_ms = 0;
10762        config.resize_coalescer.burst_delay_ms = 0;
10763        // Use a large hard deadline so elapsed wall-clock time between coalescer
10764        // construction and `handle_resize` never triggers an immediate apply.
10765        config.resize_coalescer.hard_deadline_ms = 10_000;
10766        config.evidence_sink = sink_config.clone();
10767
10768        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
10769        let sink = EvidenceSink::from_config(&sink_config)
10770            .expect("evidence sink config")
10771            .expect("evidence sink enabled");
10772        program.evidence_sink = Some(sink);
10773
10774        program.fairness_guard = InputFairnessGuard::with_config(
10775            crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
10776        );
10777        program
10778            .fairness_guard
10779            .input_arrived(Instant::now() - Duration::from_millis(1));
10780
10781        program.resize_coalescer.handle_resize(120, 40);
10782        assert!(program.resize_coalescer.has_pending());
10783
10784        program
10785            .process_resize_coalescer()
10786            .expect("process resize coalescer");
10787
10788        assert_eq!(program.width, 80);
10789        assert_eq!(program.height, 24);
10790        assert_eq!(program.model().last_size, None);
10791        assert!(program.resize_coalescer.has_pending());
10792
10793        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
10794        assert_eq!(decision_line["event"], "fairness_decision");
10795        assert_eq!(decision_line["decision"], "yield");
10796        assert_eq!(decision_line["reason"], "input_latency");
10797        assert!(decision_line["pending_input_latency_ms"].is_number());
10798        assert!(decision_line["jain_index"].is_number());
10799        assert!(decision_line["resize_dominance_count"].is_number());
10800        assert!(decision_line["dominance_threshold"].is_number());
10801        assert!(decision_line["fairness_threshold"].is_number());
10802        assert!(decision_line["input_priority_threshold_ms"].is_number());
10803    }
10804
10805    #[test]
10806    fn headless_execute_cmd_task_with_effect_queue() {
10807        struct TaskModel {
10808            done: bool,
10809        }
10810
10811        #[derive(Debug)]
10812        enum TaskMsg {
10813            Done,
10814        }
10815
10816        impl From<Event> for TaskMsg {
10817            fn from(_: Event) -> Self {
10818                TaskMsg::Done
10819            }
10820        }
10821
10822        impl Model for TaskModel {
10823            type Message = TaskMsg;
10824
10825            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10826                match msg {
10827                    TaskMsg::Done => {
10828                        self.done = true;
10829                        Cmd::none()
10830                    }
10831                }
10832            }
10833
10834            fn view(&self, _frame: &mut Frame) {}
10835        }
10836
10837        let effect_queue = EffectQueueConfig {
10838            enabled: true,
10839            backend: TaskExecutorBackend::EffectQueue,
10840            scheduler: SchedulerConfig {
10841                max_queue_size: 0,
10842                ..Default::default()
10843            },
10844            explicit_backend: true,
10845            ..Default::default()
10846        };
10847        let config = ProgramConfig::default().with_effect_queue(effect_queue);
10848        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10849
10850        program
10851            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10852            .expect("task cmd");
10853
10854        let deadline = Instant::now() + Duration::from_millis(200);
10855        while !program.model().done && Instant::now() <= deadline {
10856            program
10857                .process_task_results()
10858                .expect("process task results");
10859        }
10860
10861        assert!(
10862            program.model().done,
10863            "effect queue task result did not arrive in time"
10864        );
10865        assert_eq!(program.task_executor.kind_name(), "queued");
10866    }
10867
10868    #[test]
10869    fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
10870        struct TaskModel {
10871            done: bool,
10872        }
10873
10874        #[derive(Debug)]
10875        enum TaskMsg {
10876            Done,
10877        }
10878
10879        impl From<Event> for TaskMsg {
10880            fn from(_: Event) -> Self {
10881                TaskMsg::Done
10882            }
10883        }
10884
10885        impl Model for TaskModel {
10886            type Message = TaskMsg;
10887
10888            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10889                match msg {
10890                    TaskMsg::Done => {
10891                        self.done = true;
10892                        Cmd::none()
10893                    }
10894                }
10895            }
10896
10897            fn view(&self, _frame: &mut Frame) {}
10898        }
10899
10900        let evidence_path = temp_evidence_path("task_executor_spawned_complete");
10901        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10902        let config = ProgramConfig::default()
10903            .with_lane(RuntimeLane::Legacy)
10904            .with_evidence_sink(sink_config);
10905        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10906
10907        program
10908            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10909            .expect("task cmd");
10910
10911        let deadline = Instant::now() + Duration::from_millis(200);
10912        while !program.model().done && Instant::now() <= deadline {
10913            program
10914                .process_task_results()
10915                .expect("process task results");
10916            program.reap_finished_tasks();
10917        }
10918
10919        assert!(
10920            program.model().done,
10921            "spawned task result did not arrive in time"
10922        );
10923
10924        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
10925        assert_eq!(completion_line["backend"], "spawned");
10926        assert!(completion_line["duration_us"].is_number());
10927    }
10928
10929    #[test]
10930    fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
10931        struct TaskModel {
10932            done: bool,
10933        }
10934
10935        #[derive(Debug)]
10936        enum TaskMsg {
10937            Done,
10938        }
10939
10940        impl From<Event> for TaskMsg {
10941            fn from(_: Event) -> Self {
10942                TaskMsg::Done
10943            }
10944        }
10945
10946        impl Model for TaskModel {
10947            type Message = TaskMsg;
10948
10949            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10950                match msg {
10951                    TaskMsg::Done => {
10952                        self.done = true;
10953                        Cmd::none()
10954                    }
10955                }
10956            }
10957
10958            fn view(&self, _frame: &mut Frame) {}
10959        }
10960
10961        let evidence_path = temp_evidence_path("task_executor_queued_panic");
10962        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10963        let config = ProgramConfig::default()
10964            .with_evidence_sink(sink_config)
10965            .with_effect_queue(
10966                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
10967            );
10968        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10969
10970        program
10971            .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
10972            .expect("panic task cmd");
10973        program
10974            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10975            .expect("follow-up task cmd");
10976
10977        let deadline = Instant::now() + Duration::from_millis(500);
10978        while !program.model().done && Instant::now() <= deadline {
10979            program
10980                .process_task_results()
10981                .expect("process task results");
10982        }
10983
10984        assert!(
10985            program.model().done,
10986            "effect queue should continue after a panicking task"
10987        );
10988
10989        let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
10990        assert_eq!(panic_line["backend"], "queued");
10991        assert_eq!(panic_line["panic_msg"], "queued panic evidence");
10992    }
10993
10994    #[cfg(feature = "asupersync-executor")]
10995    #[test]
10996    fn headless_execute_cmd_task_with_asupersync_backend() {
10997        struct TaskModel {
10998            done: bool,
10999        }
11000
11001        #[derive(Debug)]
11002        enum TaskMsg {
11003            Done,
11004        }
11005
11006        impl From<Event> for TaskMsg {
11007            fn from(_: Event) -> Self {
11008                TaskMsg::Done
11009            }
11010        }
11011
11012        impl Model for TaskModel {
11013            type Message = TaskMsg;
11014
11015            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11016                match msg {
11017                    TaskMsg::Done => {
11018                        self.done = true;
11019                        Cmd::none()
11020                    }
11021                }
11022            }
11023
11024            fn view(&self, _frame: &mut Frame) {}
11025        }
11026
11027        let config = ProgramConfig::default().with_effect_queue(
11028            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
11029        );
11030        let mut program = headless_program_with_config(TaskModel { done: false }, config);
11031
11032        program
11033            .execute_cmd(Cmd::task(|| TaskMsg::Done))
11034            .expect("task cmd");
11035
11036        let deadline = Instant::now() + Duration::from_millis(200);
11037        while !program.model().done && Instant::now() <= deadline {
11038            program
11039                .process_task_results()
11040                .expect("process task results");
11041            program.reap_finished_tasks();
11042        }
11043
11044        assert!(
11045            program.model().done,
11046            "asupersync task result did not arrive in time"
11047        );
11048        assert_eq!(program.task_executor.kind_name(), "asupersync");
11049    }
11050
11051    #[cfg(feature = "asupersync-executor")]
11052    #[test]
11053    fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
11054        struct TaskModel {
11055            done: bool,
11056        }
11057
11058        #[derive(Debug)]
11059        enum TaskMsg {
11060            Done,
11061        }
11062
11063        impl From<Event> for TaskMsg {
11064            fn from(_: Event) -> Self {
11065                TaskMsg::Done
11066            }
11067        }
11068
11069        impl Model for TaskModel {
11070            type Message = TaskMsg;
11071
11072            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11073                match msg {
11074                    TaskMsg::Done => {
11075                        self.done = true;
11076                        Cmd::none()
11077                    }
11078                }
11079            }
11080
11081            fn view(&self, _frame: &mut Frame) {}
11082        }
11083
11084        let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
11085        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11086        let config = ProgramConfig::default()
11087            .with_evidence_sink(sink_config)
11088            .with_effect_queue(
11089                EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
11090            );
11091        let mut program = headless_program_with_config(TaskModel { done: false }, config);
11092
11093        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
11094        assert_eq!(backend_line["backend"], "asupersync");
11095
11096        program
11097            .execute_cmd(Cmd::task(|| TaskMsg::Done))
11098            .expect("task cmd");
11099
11100        let deadline = Instant::now() + Duration::from_millis(200);
11101        while !program.model().done && Instant::now() <= deadline {
11102            program
11103                .process_task_results()
11104                .expect("process task results");
11105            program.reap_finished_tasks();
11106        }
11107
11108        assert!(
11109            program.model().done,
11110            "asupersync task result did not arrive in time"
11111        );
11112
11113        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
11114        assert_eq!(completion_line["backend"], "asupersync");
11115        assert!(completion_line["duration_us"].is_number());
11116    }
11117
11118    // =========================================================================
11119    // BatchController Tests (bd-4kq0.8.1)
11120    // =========================================================================
11121
11122    #[test]
11123    fn unit_tau_monotone() {
11124        // τ should decrease (or stay constant) as service time decreases,
11125        // since τ = E[S] × headroom.
11126        let mut bc = BatchController::new();
11127
11128        // High service time → high τ
11129        bc.observe_service(Duration::from_millis(20));
11130        bc.observe_service(Duration::from_millis(20));
11131        bc.observe_service(Duration::from_millis(20));
11132        let tau_high = bc.tau_s();
11133
11134        // Low service time → lower τ
11135        for _ in 0..20 {
11136            bc.observe_service(Duration::from_millis(1));
11137        }
11138        let tau_low = bc.tau_s();
11139
11140        assert!(
11141            tau_low <= tau_high,
11142            "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
11143        );
11144    }
11145
11146    #[test]
11147    fn unit_tau_monotone_lambda() {
11148        // As arrival rate λ decreases (longer inter-arrival times),
11149        // τ should not increase (it's based on service time, not λ).
11150        // But ρ should decrease.
11151        let mut bc = BatchController::new();
11152        let base = Instant::now();
11153
11154        // Fast arrivals (λ high)
11155        for i in 0..10 {
11156            bc.observe_arrival(base + Duration::from_millis(i * 10));
11157        }
11158        let rho_fast = bc.rho_est();
11159
11160        // Slow arrivals (λ low)
11161        for i in 10..20 {
11162            bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
11163        }
11164        let rho_slow = bc.rho_est();
11165
11166        assert!(
11167            rho_slow < rho_fast,
11168            "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
11169        );
11170    }
11171
11172    #[test]
11173    fn unit_stability() {
11174        // With reasonable service times, the controller should keep ρ < 1.
11175        let mut bc = BatchController::new();
11176        let base = Instant::now();
11177
11178        // Moderate arrival rate: 30 events/sec
11179        for i in 0..30 {
11180            bc.observe_arrival(base + Duration::from_millis(i * 33));
11181            bc.observe_service(Duration::from_millis(5)); // 5ms render
11182        }
11183
11184        assert!(
11185            bc.is_stable(),
11186            "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
11187            bc.rho_est()
11188        );
11189        assert!(
11190            bc.rho_est() < 1.0,
11191            "utilization should be < 1: ρ={:.4}",
11192            bc.rho_est()
11193        );
11194
11195        // τ must be > E[S] (stability requirement)
11196        assert!(
11197            bc.tau_s() > bc.service_est_s(),
11198            "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
11199            bc.tau_s(),
11200            bc.service_est_s()
11201        );
11202    }
11203
11204    #[test]
11205    fn unit_stability_high_load() {
11206        // Even under high load, τ keeps the system stable.
11207        let mut bc = BatchController::new();
11208        let base = Instant::now();
11209
11210        // 100 events/sec with 8ms render
11211        for i in 0..50 {
11212            bc.observe_arrival(base + Duration::from_millis(i * 10));
11213            bc.observe_service(Duration::from_millis(8));
11214        }
11215
11216        // τ × ρ_eff = E[S]/τ should be < 1
11217        let tau = bc.tau_s();
11218        let rho_eff = bc.service_est_s() / tau;
11219        assert!(
11220            rho_eff < 1.0,
11221            "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
11222            bc.service_est_s()
11223        );
11224    }
11225
11226    #[test]
11227    fn batch_controller_defaults() {
11228        let bc = BatchController::new();
11229        assert!(bc.tau_s() >= bc.tau_min_s);
11230        assert!(bc.tau_s() <= bc.tau_max_s);
11231        assert_eq!(bc.observations(), 0);
11232        assert!(bc.is_stable());
11233    }
11234
11235    #[test]
11236    fn batch_controller_tau_clamped() {
11237        let mut bc = BatchController::new();
11238
11239        // Very fast service → τ clamped to tau_min
11240        for _ in 0..20 {
11241            bc.observe_service(Duration::from_micros(10));
11242        }
11243        assert!(
11244            bc.tau_s() >= bc.tau_min_s,
11245            "τ should be >= tau_min: τ={:.6}, min={:.6}",
11246            bc.tau_s(),
11247            bc.tau_min_s
11248        );
11249
11250        // Very slow service → τ clamped to tau_max
11251        for _ in 0..20 {
11252            bc.observe_service(Duration::from_millis(100));
11253        }
11254        assert!(
11255            bc.tau_s() <= bc.tau_max_s,
11256            "τ should be <= tau_max: τ={:.6}, max={:.6}",
11257            bc.tau_s(),
11258            bc.tau_max_s
11259        );
11260    }
11261
11262    #[test]
11263    fn batch_controller_duration_conversion() {
11264        let bc = BatchController::new();
11265        let tau = bc.tau();
11266        let tau_s = bc.tau_s();
11267        // Duration should match f64 representation
11268        let diff = (tau.as_secs_f64() - tau_s).abs();
11269        assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
11270    }
11271
11272    #[test]
11273    fn batch_controller_lambda_estimation() {
11274        let mut bc = BatchController::new();
11275        let base = Instant::now();
11276
11277        // 50 events/sec (20ms apart)
11278        for i in 0..20 {
11279            bc.observe_arrival(base + Duration::from_millis(i * 20));
11280        }
11281
11282        // λ should converge near 50
11283        let lambda = bc.lambda_est();
11284        assert!(
11285            lambda > 20.0 && lambda < 100.0,
11286            "λ should be near 50: got {lambda:.1}"
11287        );
11288    }
11289
11290    // ─────────────────────────────────────────────────────────────────────────────
11291    // Persistence Config Tests
11292    // ─────────────────────────────────────────────────────────────────────────────
11293
11294    #[test]
11295    fn cmd_save_state() {
11296        let cmd: Cmd<TestMsg> = Cmd::save_state();
11297        assert!(matches!(cmd, Cmd::SaveState));
11298    }
11299
11300    #[test]
11301    fn cmd_restore_state() {
11302        let cmd: Cmd<TestMsg> = Cmd::restore_state();
11303        assert!(matches!(cmd, Cmd::RestoreState));
11304    }
11305
11306    #[test]
11307    fn persistence_config_default() {
11308        let config = PersistenceConfig::default();
11309        assert!(config.registry.is_none());
11310        assert!(config.checkpoint_interval.is_none());
11311        assert!(config.auto_load);
11312        assert!(config.auto_save);
11313    }
11314
11315    #[test]
11316    fn persistence_config_disabled() {
11317        let config = PersistenceConfig::disabled();
11318        assert!(config.registry.is_none());
11319    }
11320
11321    #[test]
11322    fn persistence_config_with_registry() {
11323        use crate::state_persistence::{MemoryStorage, StateRegistry};
11324        use std::sync::Arc;
11325
11326        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11327        let config = PersistenceConfig::with_registry(registry.clone());
11328
11329        assert!(config.registry.is_some());
11330        assert!(config.auto_load);
11331        assert!(config.auto_save);
11332    }
11333
11334    #[test]
11335    fn persistence_config_checkpoint_interval() {
11336        use crate::state_persistence::{MemoryStorage, StateRegistry};
11337        use std::sync::Arc;
11338
11339        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11340        let config = PersistenceConfig::with_registry(registry)
11341            .checkpoint_every(Duration::from_secs(30))
11342            .auto_load(false)
11343            .auto_save(true);
11344
11345        assert!(config.checkpoint_interval.is_some());
11346        assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
11347        assert!(!config.auto_load);
11348        assert!(config.auto_save);
11349    }
11350
11351    #[test]
11352    fn program_config_with_persistence() {
11353        use crate::state_persistence::{MemoryStorage, StateRegistry};
11354        use std::sync::Arc;
11355
11356        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11357        let config = ProgramConfig::default().with_registry(registry);
11358
11359        assert!(config.persistence.registry.is_some());
11360    }
11361
11362    // =========================================================================
11363    // TaskSpec tests (bd-2yjus)
11364    // =========================================================================
11365
11366    #[test]
11367    fn task_spec_default() {
11368        let spec = TaskSpec::default();
11369        assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
11370        assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
11371        assert!(spec.name.is_none());
11372    }
11373
11374    #[test]
11375    fn task_spec_new() {
11376        let spec = TaskSpec::new(5.0, 20.0);
11377        assert_eq!(spec.weight, 5.0);
11378        assert_eq!(spec.estimate_ms, 20.0);
11379        assert!(spec.name.is_none());
11380    }
11381
11382    #[test]
11383    fn task_spec_with_name() {
11384        let spec = TaskSpec::default().with_name("fetch_data");
11385        assert_eq!(spec.name.as_deref(), Some("fetch_data"));
11386    }
11387
11388    #[test]
11389    fn task_spec_debug() {
11390        let spec = TaskSpec::new(2.0, 15.0).with_name("test");
11391        let debug = format!("{spec:?}");
11392        assert!(debug.contains("2.0"));
11393        assert!(debug.contains("15.0"));
11394        assert!(debug.contains("test"));
11395    }
11396
11397    // =========================================================================
11398    // Cmd::count() tests (bd-2yjus)
11399    // =========================================================================
11400
11401    #[test]
11402    fn cmd_count_none() {
11403        let cmd: Cmd<TestMsg> = Cmd::none();
11404        assert_eq!(cmd.count(), 0);
11405    }
11406
11407    #[test]
11408    fn cmd_count_atomic() {
11409        assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
11410        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
11411        assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
11412        assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
11413        assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
11414        assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
11415        assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
11416    }
11417
11418    #[test]
11419    fn cmd_count_batch() {
11420        let cmd: Cmd<TestMsg> =
11421            Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
11422        assert_eq!(cmd.count(), 2); // quit + msg, none counts 0
11423    }
11424
11425    #[test]
11426    fn cmd_count_nested() {
11427        let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
11428            Cmd::msg(TestMsg::Increment),
11429            Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
11430        ]);
11431        assert_eq!(cmd.count(), 3);
11432    }
11433
11434    // =========================================================================
11435    // Cmd::type_name() tests (bd-2yjus)
11436    // =========================================================================
11437
11438    #[test]
11439    fn cmd_type_name_all_variants() {
11440        assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
11441        assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
11442        assert_eq!(
11443            Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
11444            "Batch"
11445        );
11446        assert_eq!(
11447            Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
11448            "Sequence"
11449        );
11450        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
11451        assert_eq!(
11452            Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
11453            "Tick"
11454        );
11455        assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
11456        assert_eq!(
11457            Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
11458            "Task"
11459        );
11460        assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
11461        assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
11462        assert_eq!(
11463            Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
11464            "SetMouseCapture"
11465        );
11466    }
11467
11468    // =========================================================================
11469    // Cmd::batch() / Cmd::sequence() edge-case tests (bd-2yjus)
11470    // =========================================================================
11471
11472    #[test]
11473    fn cmd_batch_empty_returns_none() {
11474        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
11475        assert!(matches!(cmd, Cmd::None));
11476    }
11477
11478    #[test]
11479    fn cmd_batch_single_unwraps() {
11480        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
11481        assert!(matches!(cmd, Cmd::Quit));
11482    }
11483
11484    #[test]
11485    fn cmd_batch_multiple_stays_batch() {
11486        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
11487        assert!(matches!(cmd, Cmd::Batch(_)));
11488    }
11489
11490    #[test]
11491    fn cmd_sequence_empty_returns_none() {
11492        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
11493        assert!(matches!(cmd, Cmd::None));
11494    }
11495
11496    #[test]
11497    fn cmd_sequence_single_unwraps_to_inner() {
11498        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
11499        assert!(matches!(cmd, Cmd::Quit));
11500    }
11501
11502    #[test]
11503    fn cmd_sequence_multiple_stays_sequence() {
11504        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
11505        assert!(matches!(cmd, Cmd::Sequence(_)));
11506    }
11507
11508    // =========================================================================
11509    // Cmd task constructor variants (bd-2yjus)
11510    // =========================================================================
11511
11512    #[test]
11513    fn cmd_task_with_spec() {
11514        let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
11515        let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
11516        match cmd {
11517            Cmd::Task(s, _) => {
11518                assert_eq!(s.weight, 3.0);
11519                assert_eq!(s.estimate_ms, 25.0);
11520                assert_eq!(s.name.as_deref(), Some("my_task"));
11521            }
11522            _ => panic!("expected Task variant"),
11523        }
11524    }
11525
11526    #[test]
11527    fn cmd_task_weighted() {
11528        let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
11529        match cmd {
11530            Cmd::Task(s, _) => {
11531                assert_eq!(s.weight, 2.0);
11532                assert_eq!(s.estimate_ms, 50.0);
11533                assert!(s.name.is_none());
11534            }
11535            _ => panic!("expected Task variant"),
11536        }
11537    }
11538
11539    #[test]
11540    fn cmd_task_named() {
11541        let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
11542        match cmd {
11543            Cmd::Task(s, _) => {
11544                assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
11545                assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
11546                assert_eq!(s.name.as_deref(), Some("background_fetch"));
11547            }
11548            _ => panic!("expected Task variant"),
11549        }
11550    }
11551
11552    // =========================================================================
11553    // Cmd Debug formatting (bd-2yjus)
11554    // =========================================================================
11555
11556    #[test]
11557    fn cmd_debug_all_variant_strings() {
11558        assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
11559        assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
11560        assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
11561        assert!(
11562            format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
11563        );
11564        assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
11565        assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
11566        assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
11567        assert_eq!(
11568            format!("{:?}", Cmd::<TestMsg>::restore_state()),
11569            "RestoreState"
11570        );
11571        assert_eq!(
11572            format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
11573            "SetMouseCapture(true)"
11574        );
11575    }
11576
11577    // =========================================================================
11578    // Cmd::set_mouse_capture headless execution (bd-2yjus)
11579    // =========================================================================
11580
11581    #[test]
11582    fn headless_execute_cmd_set_mouse_capture() {
11583        let mut program =
11584            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11585        assert!(!program.backend_features.mouse_capture);
11586
11587        program
11588            .execute_cmd(Cmd::set_mouse_capture(true))
11589            .expect("set mouse capture true");
11590        assert!(program.backend_features.mouse_capture);
11591
11592        program
11593            .execute_cmd(Cmd::set_mouse_capture(false))
11594            .expect("set mouse capture false");
11595        assert!(!program.backend_features.mouse_capture);
11596    }
11597
11598    // =========================================================================
11599    // ResizeBehavior tests (bd-2yjus)
11600    // =========================================================================
11601
11602    #[test]
11603    fn resize_behavior_uses_coalescer() {
11604        assert!(ResizeBehavior::Throttled.uses_coalescer());
11605        assert!(!ResizeBehavior::Immediate.uses_coalescer());
11606    }
11607
11608    #[test]
11609    fn resize_behavior_eq_and_debug() {
11610        assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
11611        assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
11612        let debug = format!("{:?}", ResizeBehavior::Throttled);
11613        assert_eq!(debug, "Throttled");
11614    }
11615
11616    // =========================================================================
11617    // WidgetRefreshConfig default values (bd-2yjus)
11618    // =========================================================================
11619
11620    #[test]
11621    fn widget_refresh_config_defaults() {
11622        let config = WidgetRefreshConfig::default();
11623        assert!(config.enabled);
11624        assert_eq!(config.staleness_window_ms, 1_000);
11625        assert_eq!(config.starve_ms, 3_000);
11626        assert_eq!(config.max_starved_per_frame, 2);
11627        assert_eq!(config.max_drop_fraction, 1.0);
11628        assert_eq!(config.weight_priority, 1.0);
11629        assert_eq!(config.weight_staleness, 0.5);
11630        assert_eq!(config.weight_focus, 0.75);
11631        assert_eq!(config.weight_interaction, 0.5);
11632        assert_eq!(config.starve_boost, 1.5);
11633        assert_eq!(config.min_cost_us, 1.0);
11634    }
11635
11636    // =========================================================================
11637    // EffectQueueConfig tests (bd-2yjus)
11638    // =========================================================================
11639
11640    #[test]
11641    fn effect_queue_config_default() {
11642        let config = EffectQueueConfig::default();
11643        assert!(!config.enabled);
11644        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
11645        assert!(!config.explicit_backend);
11646        assert!(config.scheduler.smith_enabled);
11647        assert!(!config.scheduler.force_fifo);
11648        assert!(!config.scheduler.preemptive);
11649    }
11650
11651    #[test]
11652    fn effect_queue_config_with_enabled() {
11653        let config = EffectQueueConfig::default().with_enabled(true);
11654        assert!(config.enabled);
11655        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
11656        assert!(config.explicit_backend);
11657    }
11658
11659    #[test]
11660    fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
11661        let config = EffectQueueConfig::default().with_enabled(false);
11662        assert!(!config.enabled);
11663        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
11664        assert!(config.explicit_backend);
11665    }
11666
11667    #[test]
11668    fn effect_queue_config_with_backend() {
11669        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
11670        assert!(config.enabled);
11671        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
11672        assert!(config.explicit_backend);
11673    }
11674
11675    #[cfg(feature = "asupersync-executor")]
11676    #[test]
11677    fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
11678        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
11679        assert!(!config.enabled);
11680        assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
11681    }
11682
11683    #[test]
11684    fn effect_queue_config_with_scheduler() {
11685        let sched = SchedulerConfig {
11686            force_fifo: true,
11687            ..Default::default()
11688        };
11689        let config = EffectQueueConfig::default().with_scheduler(sched);
11690        assert!(config.scheduler.force_fifo);
11691    }
11692
11693    // =========================================================================
11694    // InlineAutoRemeasureConfig defaults (bd-2yjus)
11695    // =========================================================================
11696
11697    #[test]
11698    fn inline_auto_remeasure_config_defaults() {
11699        let config = InlineAutoRemeasureConfig::default();
11700        assert_eq!(config.change_threshold_rows, 1);
11701        assert_eq!(config.voi.prior_alpha, 1.0);
11702        assert_eq!(config.voi.prior_beta, 9.0);
11703        assert_eq!(config.voi.max_interval_ms, 1000);
11704        assert_eq!(config.voi.min_interval_ms, 100);
11705        assert_eq!(config.voi.sample_cost, 0.08);
11706    }
11707
11708    // =========================================================================
11709    // HeadlessEventSource direct tests (bd-2yjus)
11710    // =========================================================================
11711
11712    #[test]
11713    fn headless_event_source_size() {
11714        let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
11715        assert_eq!(source.size().unwrap(), (120, 40));
11716    }
11717
11718    #[test]
11719    fn headless_event_source_poll_always_false() {
11720        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
11721        assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
11722    }
11723
11724    #[test]
11725    fn headless_event_source_read_always_none() {
11726        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
11727        assert!(source.read_event().unwrap().is_none());
11728    }
11729
11730    #[test]
11731    fn headless_event_source_set_features() {
11732        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
11733        let features = BackendFeatures {
11734            mouse_capture: true,
11735            bracketed_paste: true,
11736            focus_events: true,
11737            kitty_keyboard: true,
11738        };
11739        source.set_features(features).unwrap();
11740        assert_eq!(source.features, features);
11741    }
11742
11743    #[test]
11744    fn immediate_drain_budget_adds_backoff_poll_under_burst() {
11745        use ftui_core::event::{KeyCode, KeyEvent};
11746
11747        struct DrainBurstModel {
11748            processed: usize,
11749            quit_after: usize,
11750        }
11751
11752        #[derive(Debug)]
11753        #[allow(dead_code)]
11754        enum DrainBurstMsg {
11755            Event(Event),
11756        }
11757
11758        impl From<Event> for DrainBurstMsg {
11759            fn from(event: Event) -> Self {
11760                DrainBurstMsg::Event(event)
11761            }
11762        }
11763
11764        impl Model for DrainBurstModel {
11765            type Message = DrainBurstMsg;
11766
11767            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11768                match msg {
11769                    DrainBurstMsg::Event(_) => {
11770                        self.processed = self.processed.saturating_add(1);
11771                        if self.processed >= self.quit_after {
11772                            Cmd::quit()
11773                        } else {
11774                            Cmd::none()
11775                        }
11776                    }
11777                }
11778            }
11779
11780            fn view(&self, _frame: &mut Frame) {}
11781        }
11782
11783        struct DrainBurstEventSource {
11784            queue: VecDeque<Event>,
11785            poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
11786            size: (u16, u16),
11787        }
11788
11789        impl BackendEventSource for DrainBurstEventSource {
11790            type Error = io::Error;
11791
11792            fn size(&self) -> Result<(u16, u16), Self::Error> {
11793                Ok(self.size)
11794            }
11795
11796            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
11797                Ok(())
11798            }
11799
11800            fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
11801                self.poll_timeouts.lock().unwrap().push(timeout);
11802                Ok(!self.queue.is_empty())
11803            }
11804
11805            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
11806                Ok(self.queue.pop_front())
11807            }
11808        }
11809
11810        let burst_events = 24usize;
11811        let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
11812        let mut queue = VecDeque::new();
11813        for _ in 0..burst_events {
11814            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
11815        }
11816
11817        let events = DrainBurstEventSource {
11818            queue,
11819            poll_timeouts: poll_timeouts.clone(),
11820            size: (80, 24),
11821        };
11822        let writer = TerminalWriter::new(
11823            Vec::<u8>::new(),
11824            ScreenMode::AltScreen,
11825            UiAnchor::Bottom,
11826            TerminalCapabilities::dumb(),
11827        );
11828        let config = ProgramConfig::default()
11829            .with_forced_size(80, 24)
11830            .with_signal_interception(false)
11831            .with_immediate_drain(ImmediateDrainConfig {
11832                max_zero_timeout_polls_per_burst: 3,
11833                max_burst_duration: Duration::from_secs(1),
11834                backoff_timeout: Duration::from_millis(1),
11835            });
11836
11837        let model = DrainBurstModel {
11838            processed: 0,
11839            quit_after: burst_events,
11840        };
11841        let mut program =
11842            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
11843                .expect("program creation");
11844        program.run().expect("run burst");
11845
11846        assert_eq!(program.model().processed, burst_events);
11847
11848        let stats = program.immediate_drain_stats();
11849        assert_eq!(stats.bursts, 1);
11850        assert!(stats.capped_bursts >= 1);
11851        assert!(stats.backoff_polls >= 1);
11852        assert!(stats.zero_timeout_polls >= 1);
11853        assert!(stats.max_zero_timeout_polls_in_burst <= 3);
11854
11855        let timeouts = poll_timeouts.lock().unwrap();
11856        assert!(timeouts.contains(&Duration::ZERO));
11857        assert!(timeouts.contains(&Duration::from_millis(1)));
11858    }
11859
11860    #[test]
11861    fn immediate_drain_zero_poll_limit_is_clamped() {
11862        use ftui_core::event::{KeyCode, KeyEvent};
11863
11864        struct ClampModel {
11865            processed: usize,
11866            quit_after: usize,
11867        }
11868
11869        #[derive(Debug)]
11870        #[allow(dead_code)]
11871        enum ClampMsg {
11872            Event(Event),
11873        }
11874
11875        impl From<Event> for ClampMsg {
11876            fn from(event: Event) -> Self {
11877                ClampMsg::Event(event)
11878            }
11879        }
11880
11881        impl Model for ClampModel {
11882            type Message = ClampMsg;
11883
11884            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11885                match msg {
11886                    ClampMsg::Event(_) => {
11887                        self.processed = self.processed.saturating_add(1);
11888                        if self.processed >= self.quit_after {
11889                            Cmd::quit()
11890                        } else {
11891                            Cmd::none()
11892                        }
11893                    }
11894                }
11895            }
11896
11897            fn view(&self, _frame: &mut Frame) {}
11898        }
11899
11900        struct ClampSource {
11901            queue: VecDeque<Event>,
11902        }
11903
11904        impl BackendEventSource for ClampSource {
11905            type Error = io::Error;
11906
11907            fn size(&self) -> Result<(u16, u16), Self::Error> {
11908                Ok((80, 24))
11909            }
11910
11911            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
11912                Ok(())
11913            }
11914
11915            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
11916                Ok(!self.queue.is_empty())
11917            }
11918
11919            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
11920                Ok(self.queue.pop_front())
11921            }
11922        }
11923
11924        let burst_events = 8usize;
11925        let mut queue = VecDeque::new();
11926        for _ in 0..burst_events {
11927            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
11928        }
11929        let events = ClampSource { queue };
11930
11931        let writer = TerminalWriter::new(
11932            Vec::<u8>::new(),
11933            ScreenMode::AltScreen,
11934            UiAnchor::Bottom,
11935            TerminalCapabilities::dumb(),
11936        );
11937        let config = ProgramConfig::default()
11938            .with_forced_size(80, 24)
11939            .with_signal_interception(false)
11940            .with_immediate_drain(ImmediateDrainConfig {
11941                max_zero_timeout_polls_per_burst: 0,
11942                max_burst_duration: Duration::from_secs(1),
11943                backoff_timeout: Duration::from_millis(1),
11944            });
11945        let model = ClampModel {
11946            processed: 0,
11947            quit_after: burst_events,
11948        };
11949
11950        let mut program =
11951            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
11952                .expect("program creation");
11953        program.run().expect("run clamp");
11954
11955        let stats = program.immediate_drain_stats();
11956        assert!(stats.max_zero_timeout_polls_in_burst <= 1);
11957    }
11958
11959    #[test]
11960    fn quit_stops_draining_remaining_burst_events() {
11961        use ftui_core::event::{KeyCode, KeyEvent};
11962
11963        struct QuitBurstModel {
11964            processed: usize,
11965            quit_after: usize,
11966        }
11967
11968        #[derive(Debug)]
11969        #[allow(dead_code)]
11970        enum QuitBurstMsg {
11971            Event(Event),
11972        }
11973
11974        impl From<Event> for QuitBurstMsg {
11975            fn from(event: Event) -> Self {
11976                Self::Event(event)
11977            }
11978        }
11979
11980        impl Model for QuitBurstModel {
11981            type Message = QuitBurstMsg;
11982
11983            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11984                match msg {
11985                    QuitBurstMsg::Event(_) => {
11986                        self.processed = self.processed.saturating_add(1);
11987                        if self.processed >= self.quit_after {
11988                            Cmd::quit()
11989                        } else {
11990                            Cmd::none()
11991                        }
11992                    }
11993                }
11994            }
11995
11996            fn view(&self, _frame: &mut Frame) {}
11997        }
11998
11999        struct QuitBurstSource {
12000            queue: VecDeque<Event>,
12001        }
12002
12003        impl BackendEventSource for QuitBurstSource {
12004            type Error = io::Error;
12005
12006            fn size(&self) -> Result<(u16, u16), Self::Error> {
12007                Ok((80, 24))
12008            }
12009
12010            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
12011                Ok(())
12012            }
12013
12014            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
12015                Ok(!self.queue.is_empty())
12016            }
12017
12018            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
12019                Ok(self.queue.pop_front())
12020            }
12021        }
12022
12023        let total_events = 8usize;
12024        let quit_after = 3usize;
12025        let mut queue = VecDeque::new();
12026        for _ in 0..total_events {
12027            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
12028        }
12029
12030        let writer = TerminalWriter::new(
12031            Vec::<u8>::new(),
12032            ScreenMode::AltScreen,
12033            UiAnchor::Bottom,
12034            TerminalCapabilities::dumb(),
12035        );
12036        let config = ProgramConfig::default()
12037            .with_forced_size(80, 24)
12038            .with_signal_interception(false)
12039            .with_immediate_drain(ImmediateDrainConfig {
12040                max_zero_timeout_polls_per_burst: 64,
12041                max_burst_duration: Duration::from_secs(1),
12042                backoff_timeout: Duration::from_millis(1),
12043            });
12044        let model = QuitBurstModel {
12045            processed: 0,
12046            quit_after,
12047        };
12048        let events = QuitBurstSource { queue };
12049
12050        let mut program =
12051            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
12052                .expect("program creation");
12053        program.run().expect("run burst quit");
12054
12055        assert_eq!(program.model().processed, quit_after);
12056    }
12057
12058    // =========================================================================
12059    // Program helper methods (bd-2yjus)
12060    // =========================================================================
12061
12062    #[test]
12063    fn headless_program_quit_and_is_running() {
12064        let mut program =
12065            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12066        assert!(program.is_running());
12067
12068        program.quit();
12069        assert!(!program.is_running());
12070    }
12071
12072    #[test]
12073    fn headless_program_model_mut() {
12074        let mut program =
12075            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12076        assert_eq!(program.model().value, 0);
12077
12078        program.model_mut().value = 42;
12079        assert_eq!(program.model().value, 42);
12080    }
12081
12082    #[test]
12083    fn headless_program_request_redraw() {
12084        let mut program =
12085            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12086        program.dirty = false;
12087
12088        program.request_redraw();
12089        assert!(program.dirty);
12090    }
12091
12092    #[test]
12093    fn headless_program_last_widget_signals_initially_empty() {
12094        let program =
12095            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12096        assert!(program.last_widget_signals().is_empty());
12097    }
12098
12099    #[test]
12100    fn headless_program_no_persistence_by_default() {
12101        let program =
12102            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12103        assert!(!program.has_persistence());
12104        assert!(program.state_registry().is_none());
12105    }
12106
12107    // =========================================================================
12108    // classify_event_for_fairness (bd-2yjus)
12109    // =========================================================================
12110
12111    #[test]
12112    fn classify_event_fairness_key_is_input() {
12113        let event = Event::Key(ftui_core::event::KeyEvent::new(
12114            ftui_core::event::KeyCode::Char('a'),
12115        ));
12116        let classification =
12117            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
12118        assert_eq!(classification, FairnessEventType::Input);
12119    }
12120
12121    #[test]
12122    fn classify_event_fairness_resize_is_resize() {
12123        let event = Event::Resize {
12124            width: 80,
12125            height: 24,
12126        };
12127        let classification =
12128            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
12129        assert_eq!(classification, FairnessEventType::Resize);
12130    }
12131
12132    #[test]
12133    fn classify_event_fairness_tick_is_tick() {
12134        let event = Event::Tick;
12135        let classification =
12136            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
12137        assert_eq!(classification, FairnessEventType::Tick);
12138    }
12139
12140    #[test]
12141    fn classify_event_fairness_paste_is_input() {
12142        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
12143        let classification =
12144            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
12145        assert_eq!(classification, FairnessEventType::Input);
12146    }
12147
12148    #[test]
12149    fn classify_event_fairness_focus_is_input() {
12150        let event = Event::Focus(true);
12151        let classification =
12152            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
12153        assert_eq!(classification, FairnessEventType::Input);
12154    }
12155
12156    // =========================================================================
12157    // ProgramConfig builder methods (bd-2yjus)
12158    // =========================================================================
12159
12160    #[test]
12161    fn program_config_with_diff_config() {
12162        let diff = RuntimeDiffConfig::default();
12163        let config = ProgramConfig::default().with_diff_config(diff.clone());
12164        // Just verify it doesn't panic and the field is set
12165        let _ = format!("{:?}", config);
12166    }
12167
12168    #[test]
12169    fn program_config_with_evidence_sink() {
12170        let config =
12171            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
12172        let _ = format!("{:?}", config);
12173    }
12174
12175    #[test]
12176    fn program_config_with_render_trace() {
12177        let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
12178        let _ = format!("{:?}", config);
12179    }
12180
12181    #[test]
12182    fn program_config_with_locale() {
12183        let config = ProgramConfig::default().with_locale("fr");
12184        let _ = format!("{:?}", config);
12185    }
12186
12187    #[test]
12188    fn program_config_with_locale_context() {
12189        let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
12190        let _ = format!("{:?}", config);
12191    }
12192
12193    #[test]
12194    fn program_config_without_forced_size() {
12195        let config = ProgramConfig::default()
12196            .with_forced_size(80, 24)
12197            .without_forced_size();
12198        assert!(config.forced_size.is_none());
12199    }
12200
12201    #[test]
12202    fn program_config_forced_size_clamps_min() {
12203        let config = ProgramConfig::default().with_forced_size(0, 0);
12204        assert_eq!(config.forced_size, Some((1, 1)));
12205    }
12206
12207    #[test]
12208    fn program_config_with_widget_refresh() {
12209        let wrc = WidgetRefreshConfig {
12210            enabled: false,
12211            ..Default::default()
12212        };
12213        let config = ProgramConfig::default().with_widget_refresh(wrc);
12214        assert!(!config.widget_refresh.enabled);
12215    }
12216
12217    #[test]
12218    fn program_config_with_effect_queue() {
12219        let eqc = EffectQueueConfig::default().with_enabled(true);
12220        let config = ProgramConfig::default().with_effect_queue(eqc);
12221        assert!(config.effect_queue.enabled);
12222        assert_eq!(
12223            config.effect_queue.backend,
12224            TaskExecutorBackend::EffectQueue
12225        );
12226    }
12227
12228    #[test]
12229    fn program_config_with_resize_coalescer_custom() {
12230        let cc = CoalescerConfig {
12231            steady_delay_ms: 42,
12232            ..Default::default()
12233        };
12234        let config = ProgramConfig::default().with_resize_coalescer(cc);
12235        assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
12236    }
12237
12238    #[test]
12239    fn program_config_with_inline_auto_remeasure() {
12240        let config = ProgramConfig::default()
12241            .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
12242        assert!(config.inline_auto_remeasure.is_some());
12243
12244        let config = config.without_inline_auto_remeasure();
12245        assert!(config.inline_auto_remeasure.is_none());
12246    }
12247
12248    #[test]
12249    fn program_config_with_persistence_full() {
12250        let pc = PersistenceConfig::disabled();
12251        let config = ProgramConfig::default().with_persistence(pc);
12252        assert!(config.persistence.registry.is_none());
12253    }
12254
12255    #[test]
12256    fn program_config_with_conformal_config() {
12257        let config = ProgramConfig::default()
12258            .with_conformal_config(ConformalConfig::default())
12259            .without_conformal();
12260        assert!(config.conformal_config.is_none());
12261    }
12262
12263    // =========================================================================
12264    // Rollout config builder methods (bd-2crbt)
12265    // =========================================================================
12266
12267    #[test]
12268    fn program_config_with_lane() {
12269        let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
12270        assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
12271    }
12272
12273    #[test]
12274    fn program_config_default_lane_resolves_to_effect_queue_backend() {
12275        let resolved = ProgramConfig::default().resolved_effect_queue_config();
12276        assert!(resolved.enabled);
12277        assert_eq!(resolved.backend, TaskExecutorBackend::EffectQueue);
12278    }
12279
12280    #[test]
12281    fn program_config_legacy_lane_resolves_to_spawned_backend() {
12282        let resolved = ProgramConfig::default()
12283            .with_lane(RuntimeLane::Legacy)
12284            .resolved_effect_queue_config();
12285        assert!(!resolved.enabled);
12286        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
12287    }
12288
12289    #[test]
12290    fn program_config_explicit_spawned_backend_is_preserved() {
12291        let resolved = ProgramConfig::default()
12292            .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
12293            .resolved_effect_queue_config();
12294        assert!(!resolved.enabled);
12295        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
12296    }
12297
12298    #[test]
12299    fn program_config_with_rollout_policy() {
12300        let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
12301        assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
12302    }
12303
12304    #[test]
12305    fn rollout_policy_labels() {
12306        assert_eq!(RolloutPolicy::Off.label(), "off");
12307        assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
12308        assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
12309        assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
12310    }
12311
12312    #[test]
12313    fn rollout_policy_is_shadow() {
12314        assert!(!RolloutPolicy::Off.is_shadow());
12315        assert!(RolloutPolicy::Shadow.is_shadow());
12316        assert!(!RolloutPolicy::Enabled.is_shadow());
12317    }
12318
12319    #[test]
12320    fn rollout_policy_default_is_off() {
12321        assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
12322    }
12323
12324    #[test]
12325    fn runtime_lane_parse_legacy() {
12326        assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
12327    }
12328
12329    #[test]
12330    fn runtime_lane_parse_structured_case_insensitive() {
12331        assert_eq!(
12332            RuntimeLane::parse("Structured"),
12333            Some(RuntimeLane::Structured)
12334        );
12335    }
12336
12337    #[test]
12338    fn runtime_lane_parse_asupersync_uppercase() {
12339        assert_eq!(
12340            RuntimeLane::parse("ASUPERSYNC"),
12341            Some(RuntimeLane::Asupersync)
12342        );
12343    }
12344
12345    #[test]
12346    fn runtime_lane_parse_unrecognized() {
12347        assert_eq!(RuntimeLane::parse("bogus"), None);
12348    }
12349
12350    #[test]
12351    fn rollout_policy_parse_shadow() {
12352        assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
12353    }
12354
12355    #[test]
12356    fn rollout_policy_parse_enabled() {
12357        assert_eq!(
12358            RolloutPolicy::parse("enabled"),
12359            Some(RolloutPolicy::Enabled)
12360        );
12361    }
12362
12363    #[test]
12364    fn rollout_policy_parse_off() {
12365        assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
12366    }
12367
12368    #[test]
12369    fn rollout_policy_parse_unrecognized() {
12370        assert_eq!(RolloutPolicy::parse("bogus"), None);
12371    }
12372
12373    // =========================================================================
12374    // PersistenceConfig Debug (bd-2yjus)
12375    // =========================================================================
12376
12377    #[test]
12378    fn persistence_config_debug() {
12379        let config = PersistenceConfig::default();
12380        let debug = format!("{config:?}");
12381        assert!(debug.contains("PersistenceConfig"));
12382        assert!(debug.contains("auto_load"));
12383        assert!(debug.contains("auto_save"));
12384    }
12385
12386    // =========================================================================
12387    // FrameTimingConfig (bd-2yjus)
12388    // =========================================================================
12389
12390    #[test]
12391    fn frame_timing_config_debug() {
12392        use std::sync::Arc;
12393
12394        struct DummySink;
12395        impl FrameTimingSink for DummySink {
12396            fn record_frame(&self, _timing: &FrameTiming) {}
12397        }
12398
12399        let config = FrameTimingConfig::new(Arc::new(DummySink));
12400        let debug = format!("{config:?}");
12401        assert!(debug.contains("FrameTimingConfig"));
12402    }
12403
12404    #[test]
12405    fn program_config_with_frame_timing() {
12406        use std::sync::Arc;
12407
12408        struct DummySink;
12409        impl FrameTimingSink for DummySink {
12410            fn record_frame(&self, _timing: &FrameTiming) {}
12411        }
12412
12413        let config =
12414            ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
12415        assert!(config.frame_timing.is_some());
12416    }
12417
12418    // =========================================================================
12419    // BudgetDecisionEvidence helper functions (bd-2yjus)
12420    // =========================================================================
12421
12422    #[test]
12423    fn budget_decision_evidence_decision_from_levels() {
12424        use ftui_render::budget::DegradationLevel;
12425        // Degrade: after > before
12426        assert_eq!(
12427            BudgetDecisionEvidence::decision_from_levels(
12428                DegradationLevel::Full,
12429                DegradationLevel::SimpleBorders
12430            ),
12431            BudgetDecision::Degrade
12432        );
12433        // Upgrade: after < before
12434        assert_eq!(
12435            BudgetDecisionEvidence::decision_from_levels(
12436                DegradationLevel::SimpleBorders,
12437                DegradationLevel::Full
12438            ),
12439            BudgetDecision::Upgrade
12440        );
12441        // Hold: same
12442        assert_eq!(
12443            BudgetDecisionEvidence::decision_from_levels(
12444                DegradationLevel::Full,
12445                DegradationLevel::Full
12446            ),
12447            BudgetDecision::Hold
12448        );
12449    }
12450
12451    // =========================================================================
12452    // WidgetRefreshPlan (bd-2yjus)
12453    // =========================================================================
12454
12455    #[test]
12456    fn widget_refresh_plan_clear() {
12457        let mut plan = WidgetRefreshPlan::new();
12458        plan.frame_idx = 5;
12459        plan.budget_us = 100.0;
12460        plan.signal_count = 3;
12461        plan.over_budget = true;
12462        plan.clear();
12463        assert_eq!(plan.frame_idx, 0);
12464        assert_eq!(plan.budget_us, 0.0);
12465        assert_eq!(plan.signal_count, 0);
12466        assert!(!plan.over_budget);
12467    }
12468
12469    #[test]
12470    fn widget_refresh_plan_as_budget_empty_signals() {
12471        let plan = WidgetRefreshPlan::new();
12472        let budget = plan.as_budget();
12473        // With signal_count == 0, should be allow_all (allows any widget)
12474        assert!(budget.allows(0, false));
12475        assert!(budget.allows(999, false));
12476    }
12477
12478    #[test]
12479    fn widget_refresh_plan_to_jsonl_structure() {
12480        let plan = WidgetRefreshPlan::new();
12481        let jsonl = plan.to_jsonl();
12482        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
12483        assert!(jsonl.contains("\"frame_idx\":0"));
12484        assert!(jsonl.contains("\"selected\":[]"));
12485    }
12486
12487    // =========================================================================
12488    // BatchController Default trait (bd-2yjus)
12489    // =========================================================================
12490
12491    #[test]
12492    fn batch_controller_default_trait() {
12493        let bc = BatchController::default();
12494        let bc2 = BatchController::new();
12495        // Should be equivalent
12496        assert_eq!(bc.tau_s(), bc2.tau_s());
12497        assert_eq!(bc.observations(), bc2.observations());
12498    }
12499
12500    #[test]
12501    fn batch_controller_observe_arrival_stale_gap_ignored() {
12502        let mut bc = BatchController::new();
12503        let base = Instant::now();
12504        // First arrival
12505        bc.observe_arrival(base);
12506        // Stale gap > 10s should be ignored
12507        bc.observe_arrival(base + Duration::from_secs(15));
12508        assert_eq!(bc.observations(), 0);
12509    }
12510
12511    #[test]
12512    fn batch_controller_observe_service_out_of_range() {
12513        let mut bc = BatchController::new();
12514        let original_service = bc.service_est_s();
12515        // Out-of-range (>= 10s) should be ignored
12516        bc.observe_service(Duration::from_secs(15));
12517        assert_eq!(bc.service_est_s(), original_service);
12518    }
12519
12520    #[test]
12521    fn batch_controller_lambda_zero_inter_arrival() {
12522        // When ema_inter_arrival_s is effectively 0, lambda should be 0
12523        let bc = BatchController {
12524            ema_inter_arrival_s: 0.0,
12525            ..BatchController::new()
12526        };
12527        assert_eq!(bc.lambda_est(), 0.0);
12528    }
12529
12530    // =========================================================================
12531    // Headless program: Cmd::Log with and without trailing newline (bd-2yjus)
12532    // =========================================================================
12533
12534    #[test]
12535    fn headless_execute_cmd_log_appends_newline_if_missing() {
12536        let mut program =
12537            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12538        program.execute_cmd(Cmd::log("no newline")).expect("log");
12539
12540        let bytes = program.writer.into_inner().expect("writer output");
12541        let output = String::from_utf8_lossy(&bytes);
12542        // The sanitized output should end with a newline
12543        assert!(output.contains("no newline"));
12544    }
12545
12546    #[test]
12547    fn headless_execute_cmd_log_preserves_trailing_newline() {
12548        let mut program =
12549            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12550        program
12551            .execute_cmd(Cmd::log("with newline\n"))
12552            .expect("log");
12553
12554        let bytes = program.writer.into_inner().expect("writer output");
12555        let output = String::from_utf8_lossy(&bytes);
12556        assert!(output.contains("with newline"));
12557    }
12558
12559    // =========================================================================
12560    // Headless program: immediate resize behavior (bd-2yjus)
12561    // =========================================================================
12562
12563    #[test]
12564    fn headless_handle_event_immediate_resize() {
12565        struct ResizeModel {
12566            last_size: Option<(u16, u16)>,
12567        }
12568
12569        #[derive(Debug)]
12570        enum ResizeMsg {
12571            Resize(u16, u16),
12572            Other,
12573        }
12574
12575        impl From<Event> for ResizeMsg {
12576            fn from(event: Event) -> Self {
12577                match event {
12578                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12579                    _ => ResizeMsg::Other,
12580                }
12581            }
12582        }
12583
12584        impl Model for ResizeModel {
12585            type Message = ResizeMsg;
12586
12587            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12588                if let ResizeMsg::Resize(w, h) = msg {
12589                    self.last_size = Some((w, h));
12590                }
12591                Cmd::none()
12592            }
12593
12594            fn view(&self, _frame: &mut Frame) {}
12595        }
12596
12597        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
12598        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12599
12600        program
12601            .handle_event(Event::Resize {
12602                width: 120,
12603                height: 40,
12604            })
12605            .expect("handle resize");
12606
12607        assert_eq!(program.width, 120);
12608        assert_eq!(program.height, 40);
12609        assert_eq!(program.model().last_size, Some((120, 40)));
12610    }
12611
12612    // =========================================================================
12613    // Headless program: resize clamps zero dimensions (bd-2yjus)
12614    // =========================================================================
12615
12616    #[test]
12617    fn headless_apply_resize_clamps_zero_to_one() {
12618        struct SimpleModel;
12619
12620        #[derive(Debug)]
12621        enum SimpleMsg {
12622            Noop,
12623        }
12624
12625        impl From<Event> for SimpleMsg {
12626            fn from(_: Event) -> Self {
12627                SimpleMsg::Noop
12628            }
12629        }
12630
12631        impl Model for SimpleModel {
12632            type Message = SimpleMsg;
12633
12634            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12635                Cmd::none()
12636            }
12637
12638            fn view(&self, _frame: &mut Frame) {}
12639        }
12640
12641        let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
12642        program
12643            .apply_resize(0, 0, Duration::ZERO, false)
12644            .expect("resize");
12645
12646        // Zero dimensions should be clamped to 1
12647        assert_eq!(program.width, 1);
12648        assert_eq!(program.height, 1);
12649    }
12650
12651    // =========================================================================
12652    // PaneTerminalAdapter::force_cancel_all (bd-24v9m)
12653    // =========================================================================
12654
12655    #[test]
12656    fn force_cancel_all_idle_returns_none() {
12657        let mut adapter =
12658            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12659        assert!(adapter.force_cancel_all().is_none());
12660    }
12661
12662    #[test]
12663    fn force_cancel_all_after_pointer_down_returns_diagnostics() {
12664        let mut adapter =
12665            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12666        let target = pane_target(SplitAxis::Horizontal);
12667
12668        let down = Event::Mouse(MouseEvent::new(
12669            MouseEventKind::Down(MouseButton::Left),
12670            5,
12671            5,
12672        ));
12673        let _ = adapter.translate(&down, Some(target));
12674        assert!(adapter.active_pointer_id().is_some());
12675
12676        let diag = adapter
12677            .force_cancel_all()
12678            .expect("should produce diagnostics");
12679        assert!(diag.had_active_pointer);
12680        assert_eq!(diag.active_pointer_id, Some(1));
12681        assert!(diag.machine_transition.is_some());
12682
12683        // Adapter should be fully idle afterwards
12684        assert_eq!(adapter.active_pointer_id(), None);
12685        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
12686    }
12687
12688    #[test]
12689    fn force_cancel_all_during_drag_returns_diagnostics() {
12690        let mut adapter =
12691            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12692        let target = pane_target(SplitAxis::Vertical);
12693
12694        // Down → arm
12695        let down = Event::Mouse(MouseEvent::new(
12696            MouseEventKind::Down(MouseButton::Left),
12697            3,
12698            3,
12699        ));
12700        let _ = adapter.translate(&down, Some(target));
12701
12702        // Drag → transition to Dragging
12703        let drag = Event::Mouse(MouseEvent::new(
12704            MouseEventKind::Drag(MouseButton::Left),
12705            8,
12706            3,
12707        ));
12708        let _ = adapter.translate(&drag, None);
12709
12710        let diag = adapter
12711            .force_cancel_all()
12712            .expect("should produce diagnostics");
12713        assert!(diag.had_active_pointer);
12714        assert!(diag.machine_transition.is_some());
12715        let transition = diag.machine_transition.unwrap();
12716        assert!(matches!(
12717            transition.effect,
12718            PaneDragResizeEffect::Canceled {
12719                reason: PaneCancelReason::Programmatic,
12720                ..
12721            }
12722        ));
12723    }
12724
12725    #[test]
12726    fn force_cancel_all_is_idempotent() {
12727        let mut adapter =
12728            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12729        let target = pane_target(SplitAxis::Horizontal);
12730
12731        let down = Event::Mouse(MouseEvent::new(
12732            MouseEventKind::Down(MouseButton::Left),
12733            5,
12734            5,
12735        ));
12736        let _ = adapter.translate(&down, Some(target));
12737
12738        let first = adapter.force_cancel_all();
12739        assert!(first.is_some());
12740
12741        let second = adapter.force_cancel_all();
12742        assert!(second.is_none());
12743    }
12744
12745    // =========================================================================
12746    // PaneInteractionGuard (bd-24v9m)
12747    // =========================================================================
12748
12749    #[test]
12750    fn pane_interaction_guard_finish_when_idle() {
12751        let mut adapter =
12752            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12753        let guard = PaneInteractionGuard::new(&mut adapter);
12754        let diag = guard.finish();
12755        assert!(diag.is_none());
12756    }
12757
12758    #[test]
12759    fn pane_interaction_guard_finish_returns_diagnostics() {
12760        let mut adapter =
12761            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12762        let target = pane_target(SplitAxis::Horizontal);
12763
12764        // Start a drag interaction through the adapter directly
12765        let down = Event::Mouse(MouseEvent::new(
12766            MouseEventKind::Down(MouseButton::Left),
12767            5,
12768            5,
12769        ));
12770        let _ = adapter.translate(&down, Some(target));
12771
12772        let guard = PaneInteractionGuard::new(&mut adapter);
12773        let diag = guard.finish().expect("should produce diagnostics");
12774        assert!(diag.had_active_pointer);
12775        assert_eq!(diag.active_pointer_id, Some(1));
12776    }
12777
12778    #[test]
12779    fn pane_interaction_guard_drop_cancels_active_interaction() {
12780        let mut adapter =
12781            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12782        let target = pane_target(SplitAxis::Vertical);
12783
12784        let down = Event::Mouse(MouseEvent::new(
12785            MouseEventKind::Down(MouseButton::Left),
12786            7,
12787            7,
12788        ));
12789        let _ = adapter.translate(&down, Some(target));
12790        assert!(adapter.active_pointer_id().is_some());
12791
12792        {
12793            let _guard = PaneInteractionGuard::new(&mut adapter);
12794            // guard drops here without finish()
12795        }
12796
12797        // After guard drop, adapter should be idle
12798        assert_eq!(adapter.active_pointer_id(), None);
12799        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
12800    }
12801
12802    #[test]
12803    fn pane_interaction_guard_adapter_access_works() {
12804        let mut adapter =
12805            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12806        let target = pane_target(SplitAxis::Horizontal);
12807
12808        let mut guard = PaneInteractionGuard::new(&mut adapter);
12809
12810        // Use the adapter through the guard
12811        let down = Event::Mouse(MouseEvent::new(
12812            MouseEventKind::Down(MouseButton::Left),
12813            5,
12814            5,
12815        ));
12816        let dispatch = guard.adapter().translate(&down, Some(target));
12817        assert!(dispatch.primary_event.is_some());
12818
12819        // finish should clean up the interaction started through the guard
12820        let diag = guard.finish().expect("should produce diagnostics");
12821        assert!(diag.had_active_pointer);
12822    }
12823
12824    #[test]
12825    fn pane_interaction_guard_finish_then_drop_is_safe() {
12826        let mut adapter =
12827            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12828        let target = pane_target(SplitAxis::Horizontal);
12829
12830        let down = Event::Mouse(MouseEvent::new(
12831            MouseEventKind::Down(MouseButton::Left),
12832            5,
12833            5,
12834        ));
12835        let _ = adapter.translate(&down, Some(target));
12836
12837        let guard = PaneInteractionGuard::new(&mut adapter);
12838        let _diag = guard.finish();
12839        // guard is consumed by finish(), so drop doesn't double-cancel
12840        // This test proves the API is safe: finish() takes `self` not `&mut self`
12841        assert_eq!(adapter.active_pointer_id(), None);
12842    }
12843
12844    // =========================================================================
12845    // PaneCapabilityMatrix (bd-6u66i)
12846    // =========================================================================
12847
12848    fn caps_modern() -> TerminalCapabilities {
12849        TerminalCapabilities::modern()
12850    }
12851
12852    fn caps_with_mux(
12853        mux: PaneMuxEnvironment,
12854    ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
12855        let mut caps = TerminalCapabilities::modern();
12856        match mux {
12857            PaneMuxEnvironment::Tmux => caps.in_tmux = true,
12858            PaneMuxEnvironment::Screen => caps.in_screen = true,
12859            PaneMuxEnvironment::Zellij => caps.in_zellij = true,
12860            PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
12861            PaneMuxEnvironment::None => {}
12862        }
12863        caps
12864    }
12865
12866    #[test]
12867    fn capability_matrix_bare_terminal_modern() {
12868        let caps = caps_modern();
12869        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12870
12871        assert_eq!(mat.mux, PaneMuxEnvironment::None);
12872        assert!(mat.mouse_sgr);
12873        assert!(mat.mouse_drag_reliable);
12874        assert!(mat.mouse_button_discrimination);
12875        assert!(mat.focus_events);
12876        assert!(mat.unicode_box_drawing);
12877        assert!(mat.true_color);
12878        assert!(!mat.degraded);
12879        assert!(mat.drag_enabled());
12880        assert!(mat.focus_cancel_effective());
12881        assert!(mat.limitations().is_empty());
12882    }
12883
12884    #[test]
12885    fn capability_matrix_tmux() {
12886        let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
12887        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12888
12889        assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
12890        // Focus cancel path is conservatively disabled in all muxes.
12891        assert!(mat.mouse_drag_reliable);
12892        assert!(!mat.focus_events);
12893        assert!(mat.drag_enabled());
12894        assert!(!mat.focus_cancel_effective());
12895        assert!(mat.degraded);
12896    }
12897
12898    #[test]
12899    fn capability_matrix_screen_degrades_drag() {
12900        let caps = caps_with_mux(PaneMuxEnvironment::Screen);
12901        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12902
12903        assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
12904        assert!(!mat.mouse_drag_reliable);
12905        assert!(!mat.focus_events);
12906        assert!(!mat.drag_enabled());
12907        assert!(!mat.focus_cancel_effective());
12908        assert!(mat.degraded);
12909
12910        let lims = mat.limitations();
12911        assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
12912        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
12913    }
12914
12915    #[test]
12916    fn capability_matrix_zellij() {
12917        let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
12918        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12919
12920        assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
12921        assert!(mat.mouse_drag_reliable);
12922        assert!(!mat.focus_events);
12923        assert!(mat.drag_enabled());
12924        assert!(!mat.focus_cancel_effective());
12925        assert!(mat.degraded);
12926    }
12927
12928    #[test]
12929    fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
12930        let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
12931        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12932
12933        assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
12934        assert!(mat.mouse_drag_reliable);
12935        assert!(!mat.focus_events);
12936        assert!(mat.drag_enabled());
12937        assert!(!mat.focus_cancel_effective());
12938        assert!(mat.degraded);
12939    }
12940
12941    #[test]
12942    fn capability_matrix_no_sgr_mouse() {
12943        let mut caps = caps_modern();
12944        caps.mouse_sgr = false;
12945        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12946
12947        assert!(!mat.mouse_sgr);
12948        assert!(!mat.mouse_button_discrimination);
12949        assert!(mat.degraded);
12950
12951        let lims = mat.limitations();
12952        assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
12953        assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
12954    }
12955
12956    #[test]
12957    fn capability_matrix_no_focus_events() {
12958        let mut caps = caps_modern();
12959        caps.focus_events = false;
12960        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12961
12962        assert!(!mat.focus_events);
12963        assert!(!mat.focus_cancel_effective());
12964        assert!(mat.degraded);
12965
12966        let lims = mat.limitations();
12967        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
12968    }
12969
12970    #[test]
12971    fn capability_matrix_dumb_terminal() {
12972        let caps = TerminalCapabilities::dumb();
12973        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12974
12975        assert_eq!(mat.mux, PaneMuxEnvironment::None);
12976        assert!(!mat.mouse_sgr);
12977        assert!(!mat.focus_events);
12978        assert!(!mat.unicode_box_drawing);
12979        assert!(!mat.true_color);
12980        assert!(mat.degraded);
12981        assert!(mat.limitations().len() >= 3);
12982    }
12983
12984    #[test]
12985    fn capability_matrix_limitations_have_fallbacks() {
12986        let caps = TerminalCapabilities::dumb();
12987        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12988
12989        for lim in mat.limitations() {
12990            assert!(!lim.id.is_empty());
12991            assert!(!lim.description.is_empty());
12992            assert!(!lim.fallback.is_empty());
12993        }
12994    }
12995
12996    // ========================================================================
12997    // Screen transition detection tests (A.2 + D.3)
12998    // ========================================================================
12999
13000    /// A multi-screen model that implements ScreenTickDispatch, for testing
13001    /// the `check_screen_transition` logic.
13002    struct MultiScreenModel {
13003        active: String,
13004        screens: Vec<String>,
13005        ticked_screens: Vec<(String, u64)>,
13006    }
13007
13008    #[derive(Debug)]
13009    enum MultiScreenMsg {
13010        #[expect(dead_code)]
13011        Event(Event),
13012    }
13013
13014    impl From<Event> for MultiScreenMsg {
13015        fn from(event: Event) -> Self {
13016            MultiScreenMsg::Event(event)
13017        }
13018    }
13019
13020    impl Model for MultiScreenModel {
13021        type Message = MultiScreenMsg;
13022
13023        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13024            match msg {
13025                MultiScreenMsg::Event(_) => Cmd::none(),
13026            }
13027        }
13028
13029        fn view(&self, _frame: &mut Frame) {}
13030
13031        fn as_screen_tick_dispatch(
13032            &mut self,
13033        ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
13034            Some(self)
13035        }
13036    }
13037
13038    impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
13039        fn screen_ids(&self) -> Vec<String> {
13040            self.screens.clone()
13041        }
13042
13043        fn active_screen_id(&self) -> String {
13044            self.active.clone()
13045        }
13046
13047        fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
13048            self.ticked_screens.push((screen_id.to_owned(), tick_count));
13049        }
13050    }
13051
13052    /// Shared log for recording strategy transitions (inspectable after test).
13053    type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
13054
13055    /// A recording tick strategy that logs `on_screen_transition` calls
13056    /// to a shared log that can be inspected from test assertions.
13057    struct RecordingStrategy {
13058        log: TransitionLog,
13059    }
13060
13061    impl RecordingStrategy {
13062        fn new(log: TransitionLog) -> Self {
13063            Self { log }
13064        }
13065    }
13066
13067    impl crate::tick_strategy::TickStrategy for RecordingStrategy {
13068        fn should_tick(
13069            &mut self,
13070            _screen_id: &str,
13071            _tick_count: u64,
13072            _active_screen: &str,
13073        ) -> crate::tick_strategy::TickDecision {
13074            crate::tick_strategy::TickDecision::Skip
13075        }
13076
13077        fn on_screen_transition(&mut self, from: &str, to: &str) {
13078            self.log
13079                .lock()
13080                .unwrap()
13081                .push((from.to_owned(), to.to_owned()));
13082        }
13083
13084        fn name(&self) -> &str {
13085            "Recording"
13086        }
13087
13088        fn debug_stats(&self) -> Vec<(String, String)> {
13089            vec![("strategy".into(), "Recording".into())]
13090        }
13091    }
13092
13093    /// Helper to create a headless Program with a multi-screen model and
13094    /// a recording tick strategy. Returns the program and a shared log of
13095    /// `on_screen_transition` calls for assertions.
13096    fn headless_multi_screen_program(
13097        active: &str,
13098        screens: &[&str],
13099    ) -> (
13100        Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
13101        TransitionLog,
13102    ) {
13103        let model = MultiScreenModel {
13104            active: active.to_owned(),
13105            screens: screens.iter().map(|s| (*s).to_owned()).collect(),
13106            ticked_screens: Vec::new(),
13107        };
13108        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13109        let writer = TerminalWriter::new(
13110            Vec::<u8>::new(),
13111            ScreenMode::AltScreen,
13112            UiAnchor::Bottom,
13113            TerminalCapabilities::dumb(),
13114        );
13115        let config = ProgramConfig {
13116            forced_size: Some((80, 24)),
13117            tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
13118            ..ProgramConfig::default()
13119        };
13120        let mut prog =
13121            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13122                .expect("headless program creation failed");
13123
13124        // Replace the default strategy with our recording strategy.
13125        let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
13126        prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
13127
13128        (prog, log)
13129    }
13130
13131    #[test]
13132    fn check_screen_transition_first_call_records_active() {
13133        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
13134
13135        assert!(prog.last_active_screen_for_strategy.is_none());
13136        prog.check_screen_transition();
13137        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
13138
13139        // First observation: no transition event, no force-tick.
13140        assert!(prog.model.ticked_screens.is_empty());
13141        assert!(log.lock().unwrap().is_empty());
13142    }
13143
13144    #[test]
13145    fn check_screen_transition_no_change_is_noop() {
13146        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
13147
13148        // First call: records.
13149        prog.check_screen_transition();
13150
13151        // Second call with same active screen: no-op.
13152        prog.check_screen_transition();
13153        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
13154
13155        // No force-tick, no transition notification.
13156        assert!(prog.model.ticked_screens.is_empty());
13157        assert!(log.lock().unwrap().is_empty());
13158    }
13159
13160    #[test]
13161    fn check_screen_transition_detects_switch_and_force_ticks() {
13162        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
13163
13164        prog.check_screen_transition(); // records "A"
13165
13166        // Simulate model switching to screen "B".
13167        prog.model.active = "B".to_owned();
13168        prog.check_screen_transition();
13169
13170        // D.3: force-tick should have been dispatched for "B".
13171        assert_eq!(prog.model.ticked_screens.len(), 1);
13172        assert_eq!(prog.model.ticked_screens[0].0, "B");
13173
13174        // A.2: strategy should have been notified of A → B.
13175        let transitions = log.lock().unwrap();
13176        assert_eq!(transitions.len(), 1);
13177        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
13178
13179        // last_active should now be "B".
13180        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
13181    }
13182
13183    #[test]
13184    fn check_screen_transition_marks_dirty_on_change() {
13185        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13186
13187        prog.check_screen_transition();
13188        prog.dirty = false;
13189
13190        prog.model.active = "B".to_owned();
13191        prog.check_screen_transition();
13192
13193        assert!(prog.dirty);
13194    }
13195
13196    #[test]
13197    fn check_screen_transition_not_dirty_when_unchanged() {
13198        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13199
13200        prog.check_screen_transition();
13201        prog.dirty = false;
13202
13203        prog.check_screen_transition();
13204
13205        assert!(!prog.dirty);
13206    }
13207
13208    #[test]
13209    fn check_screen_transition_noop_without_strategy() {
13210        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13211
13212        // Remove the tick strategy.
13213        prog.tick_strategy = None;
13214
13215        prog.check_screen_transition();
13216        assert!(prog.last_active_screen_for_strategy.is_none());
13217    }
13218
13219    #[test]
13220    fn check_screen_transition_multiple_switches_notifies_strategy() {
13221        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
13222
13223        prog.check_screen_transition(); // records "A"
13224
13225        // A → B
13226        prog.model.active = "B".to_owned();
13227        prog.check_screen_transition();
13228        assert_eq!(prog.model.ticked_screens.len(), 1);
13229        assert_eq!(prog.model.ticked_screens[0].0, "B");
13230
13231        // B → C
13232        prog.model.active = "C".to_owned();
13233        prog.check_screen_transition();
13234        assert_eq!(prog.model.ticked_screens.len(), 2);
13235        assert_eq!(prog.model.ticked_screens[1].0, "C");
13236
13237        // C → A
13238        prog.model.active = "A".to_owned();
13239        prog.check_screen_transition();
13240        assert_eq!(prog.model.ticked_screens.len(), 3);
13241        assert_eq!(prog.model.ticked_screens[2].0, "A");
13242
13243        // A.2: strategy should have all three transitions.
13244        let transitions = log.lock().unwrap();
13245        assert_eq!(transitions.len(), 3);
13246        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
13247        assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
13248        assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
13249    }
13250
13251    #[test]
13252    fn check_screen_transition_uses_current_tick_count() {
13253        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13254        prog.tick_count = 42;
13255
13256        prog.check_screen_transition(); // records "A"
13257
13258        prog.model.active = "B".to_owned();
13259        prog.check_screen_transition();
13260
13261        // Force-tick should use the current tick_count.
13262        assert_eq!(prog.model.ticked_screens[0].1, 42);
13263    }
13264
13265    #[test]
13266    fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
13267        use crate::subscription::{StopSignal, SubId, Subscription};
13268
13269        struct TransitionSubModel {
13270            active: String,
13271            screens: Vec<String>,
13272            subscribed: bool,
13273        }
13274
13275        #[derive(Debug)]
13276        #[allow(dead_code)]
13277        enum TransitionSubMsg {
13278            Event(Event),
13279        }
13280
13281        impl From<Event> for TransitionSubMsg {
13282            fn from(event: Event) -> Self {
13283                Self::Event(event)
13284            }
13285        }
13286
13287        impl Model for TransitionSubModel {
13288            type Message = TransitionSubMsg;
13289
13290            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
13291                Cmd::none()
13292            }
13293
13294            fn view(&self, _frame: &mut Frame) {}
13295
13296            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
13297                if self.subscribed {
13298                    vec![Box::new(TransitionSubscription)]
13299                } else {
13300                    vec![]
13301                }
13302            }
13303
13304            fn as_screen_tick_dispatch(
13305                &mut self,
13306            ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
13307                Some(self)
13308            }
13309        }
13310
13311        impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
13312            fn screen_ids(&self) -> Vec<String> {
13313                self.screens.clone()
13314            }
13315
13316            fn active_screen_id(&self) -> String {
13317                self.active.clone()
13318            }
13319
13320            fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
13321                if screen_id == self.active {
13322                    self.subscribed = true;
13323                }
13324            }
13325        }
13326
13327        struct TransitionSubscription;
13328
13329        impl Subscription<TransitionSubMsg> for TransitionSubscription {
13330            fn id(&self) -> SubId {
13331                1
13332            }
13333
13334            fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
13335        }
13336
13337        struct TransitionStrategy;
13338
13339        impl crate::tick_strategy::TickStrategy for TransitionStrategy {
13340            fn should_tick(
13341                &mut self,
13342                _screen_id: &str,
13343                _tick_count: u64,
13344                _active_screen: &str,
13345            ) -> crate::tick_strategy::TickDecision {
13346                crate::tick_strategy::TickDecision::Skip
13347            }
13348
13349            fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
13350
13351            fn name(&self) -> &str {
13352                "TransitionStrategy"
13353            }
13354
13355            fn debug_stats(&self) -> Vec<(String, String)> {
13356                vec![]
13357            }
13358        }
13359
13360        let model = TransitionSubModel {
13361            active: "A".to_owned(),
13362            screens: vec!["A".to_owned(), "B".to_owned()],
13363            subscribed: false,
13364        };
13365        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13366        let writer = TerminalWriter::new(
13367            Vec::<u8>::new(),
13368            ScreenMode::AltScreen,
13369            UiAnchor::Bottom,
13370            TerminalCapabilities::dumb(),
13371        );
13372        let config = ProgramConfig::default().with_forced_size(80, 24);
13373
13374        let mut program =
13375            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13376                .expect("program creation");
13377        program.tick_strategy = Some(Box::new(TransitionStrategy));
13378
13379        program.check_screen_transition();
13380        assert_eq!(program.subscriptions.active_count(), 0);
13381
13382        program.model.active = "B".to_owned();
13383        program.check_screen_transition();
13384
13385        assert!(program.model().subscribed);
13386        assert_eq!(program.subscriptions.active_count(), 1);
13387    }
13388
13389    #[test]
13390    fn tick_strategy_stats_returns_empty_without_strategy() {
13391        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13392        prog.tick_strategy = None;
13393        assert!(prog.tick_strategy_stats().is_empty());
13394    }
13395
13396    #[test]
13397    fn tick_strategy_stats_returns_strategy_fields() {
13398        let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13399        let stats = prog.tick_strategy_stats();
13400        // RecordingStrategy returns [("strategy", "Recording")]
13401        assert!(
13402            !stats.is_empty(),
13403            "stats should not be empty when strategy is configured"
13404        );
13405    }
13406}