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, BudgetDecisionReason, DegradationLevel,
92    FrameBudgetConfig, RenderBudget,
93};
94use ftui_render::buffer::Buffer;
95use ftui_render::diff_strategy::DiffStrategy;
96use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
97use ftui_render::frame_guardrails::{FrameGuardrails, GuardrailsConfig};
98use ftui_render::sanitize::sanitize;
99use std::any::Any;
100use std::collections::HashMap;
101use std::io::{self, Stdout, Write};
102use std::panic::{self, AssertUnwindSafe};
103use std::sync::Arc;
104
105/// Check for pending termination signal. Returns `None` when crossterm is not
106/// enabled (headless / wasm builds don't install signal handlers).
107#[inline]
108fn check_termination_signal() -> Option<i32> {
109    ftui_core::shutdown_signal::pending_termination_signal()
110}
111
112/// Clear the pending termination signal.
113#[inline]
114fn clear_termination_signal() {
115    ftui_core::shutdown_signal::clear_pending_termination_signal();
116}
117use std::sync::mpsc;
118use std::thread::{self, JoinHandle};
119use tracing::{debug, debug_span, info, info_span, trace};
120use web_time::{Duration, Instant};
121
122/// The Model trait defines application state and behavior.
123///
124/// Implementations define how the application responds to events
125/// and renders its current state.
126pub trait Model: Sized {
127    /// The message type for this model.
128    ///
129    /// Messages represent actions that update the model state.
130    /// Must be convertible from terminal events.
131    type Message: From<Event> + Send + 'static;
132
133    /// Initialize the model with startup commands.
134    ///
135    /// Called once when the program starts. Return commands to execute
136    /// initial side effects like loading data.
137    fn init(&mut self) -> Cmd<Self::Message> {
138        Cmd::none()
139    }
140
141    /// Update the model in response to a message.
142    ///
143    /// This is the core state transition function. Returns commands
144    /// for any side effects that should be executed.
145    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
146
147    /// Render the current state to a frame.
148    ///
149    /// Called after updates when the UI needs to be redrawn.
150    fn view(&self, frame: &mut Frame);
151
152    /// Declare active subscriptions.
153    ///
154    /// Called after each `update()`. The runtime compares the returned set
155    /// (by `SubId`) against currently running subscriptions and starts/stops
156    /// as needed. Returning an empty vec stops all subscriptions.
157    ///
158    /// # Default
159    ///
160    /// Returns an empty vec (no subscriptions).
161    fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
162        vec![]
163    }
164
165    /// Downcast to [`ScreenTickDispatch`](crate::tick_strategy::ScreenTickDispatch)
166    /// for per-screen tick control.
167    ///
168    /// Override this to return `Some(self)` in multi-screen Models. The runtime
169    /// will then consult the active [`TickStrategy`](crate::tick_strategy::TickStrategy)
170    /// for each inactive screen instead of ticking monolithically.
171    ///
172    /// Default: `None` (all screens tick every frame, backwards-compatible).
173    fn as_screen_tick_dispatch(
174        &mut self,
175    ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
176        None
177    }
178
179    /// Called before the runtime exits, whether via [`Cmd::Quit`] or signal.
180    ///
181    /// Return cleanup commands (e.g., saving state, closing connections).
182    /// The runtime executes these before teardown.
183    ///
184    /// # Migration rationale
185    ///
186    /// Source frameworks use `componentWillUnmount`, `useEffect` cleanup, or
187    /// `beforeDestroy` hooks. This provides an equivalent lifecycle point.
188    fn on_shutdown(&mut self) -> Cmd<Self::Message> {
189        Cmd::none()
190    }
191
192    /// Called when an unrecoverable error occurs during the runtime loop.
193    ///
194    /// Return commands for error recovery or graceful degradation. The
195    /// `error` string contains the error description.
196    ///
197    /// # Migration rationale
198    ///
199    /// Source frameworks use `componentDidCatch`, error boundaries, or
200    /// `onError` hooks. This provides an equivalent error recovery point.
201    fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
202        Cmd::none()
203    }
204}
205
206/// Default weight assigned to background tasks.
207const DEFAULT_TASK_WEIGHT: f64 = 1.0;
208
209/// Default estimated task cost (ms) used for scheduling.
210const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
211
212/// Scheduling metadata for background tasks.
213#[derive(Debug, Clone)]
214pub struct TaskSpec {
215    /// Task weight (importance). Higher = more priority.
216    pub weight: f64,
217    /// Estimated task cost in milliseconds.
218    pub estimate_ms: f64,
219    /// Optional task name for evidence logging.
220    pub name: Option<String>,
221}
222
223impl Default for TaskSpec {
224    fn default() -> Self {
225        Self {
226            weight: DEFAULT_TASK_WEIGHT,
227            estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
228            name: None,
229        }
230    }
231}
232
233impl TaskSpec {
234    /// Create a task spec with an explicit weight and estimate.
235    #[must_use]
236    pub fn new(weight: f64, estimate_ms: f64) -> Self {
237        Self {
238            weight,
239            estimate_ms,
240            name: None,
241        }
242    }
243
244    /// Attach a task name for diagnostics.
245    #[must_use]
246    pub fn with_name(mut self, name: impl Into<String>) -> Self {
247        self.name = Some(name.into());
248        self
249    }
250}
251
252/// Per-frame timing data for profiling.
253#[derive(Debug, Clone, Copy)]
254pub struct FrameTiming {
255    pub frame_idx: u64,
256    pub update_us: u64,
257    pub render_us: u64,
258    pub diff_us: u64,
259    pub present_us: u64,
260    pub total_us: u64,
261}
262
263#[derive(Debug)]
264struct SignalTerminationError {
265    signal: i32,
266}
267
268impl std::fmt::Display for SignalTerminationError {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(f, "terminated by signal {}", self.signal)
271    }
272}
273
274impl std::error::Error for SignalTerminationError {}
275
276fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
277    err.get_ref()
278        .and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
279        .map(|inner| inner.signal)
280}
281
282/// Sink for frame timing events.
283pub trait FrameTimingSink: Send + Sync {
284    fn record_frame(&self, timing: &FrameTiming);
285}
286
287/// Configuration for frame timing capture.
288#[derive(Clone)]
289pub struct FrameTimingConfig {
290    pub sink: Arc<dyn FrameTimingSink>,
291}
292
293impl FrameTimingConfig {
294    #[must_use]
295    pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
296        Self { sink }
297    }
298}
299
300impl std::fmt::Debug for FrameTimingConfig {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.debug_struct("FrameTimingConfig")
303            .field("sink", &"<dyn FrameTimingSink>")
304            .finish()
305    }
306}
307
308/// Commands represent side effects to be executed by the runtime.
309///
310/// Commands are returned from `init()` and `update()` to trigger
311/// actions like quitting, sending messages, or scheduling ticks.
312#[derive(Default)]
313pub enum Cmd<M> {
314    /// No operation.
315    #[default]
316    None,
317    /// Quit the application.
318    Quit,
319    /// Execute multiple commands as a batch (currently sequential).
320    Batch(Vec<Cmd<M>>),
321    /// Execute commands sequentially.
322    Sequence(Vec<Cmd<M>>),
323    /// Send a message to the model.
324    Msg(M),
325    /// Schedule a tick after a duration.
326    Tick(Duration),
327    /// Write a log message to the terminal output.
328    ///
329    /// This writes to the scrollback region in inline mode, or is ignored/handled
330    /// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
331    Log(String),
332    /// Execute a blocking operation on a background thread.
333    ///
334    /// When effect queue scheduling is enabled, tasks are enqueued and executed
335    /// in Smith-rule order on a dedicated worker thread. Otherwise the closure
336    /// runs on a spawned thread immediately. The return value is sent back
337    /// as a message to the model.
338    Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
339    /// Save widget state to the persistence registry.
340    ///
341    /// Triggers a flush of the state registry to the storage backend.
342    /// No-op if persistence is not configured.
343    SaveState,
344    /// Restore widget state from the persistence registry.
345    ///
346    /// Triggers a load from the storage backend and updates the cache.
347    /// No-op if persistence is not configured. Returns a message via
348    /// callback if state was successfully restored.
349    RestoreState,
350    /// Toggle mouse capture at runtime.
351    ///
352    /// Instructs the terminal session to enable or disable mouse event capture.
353    /// No-op in test simulators.
354    SetMouseCapture(bool),
355    /// Replace the tick strategy at runtime.
356    ///
357    /// Takes ownership of a boxed strategy. Use when switching from one
358    /// strategy to another (e.g., `Uniform` → `Predictive` after loading
359    /// persisted transition data).
360    SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
361}
362
363impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365        match self {
366            Self::None => write!(f, "None"),
367            Self::Quit => write!(f, "Quit"),
368            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
369            Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
370            Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
371            Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
372            Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
373            Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
374            Self::SaveState => write!(f, "SaveState"),
375            Self::RestoreState => write!(f, "RestoreState"),
376            Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
377            Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
378        }
379    }
380}
381
382impl<M> Cmd<M> {
383    /// Create a no-op command.
384    #[inline]
385    pub fn none() -> Self {
386        Self::None
387    }
388
389    /// Create a quit command.
390    #[inline]
391    pub fn quit() -> Self {
392        Self::Quit
393    }
394
395    /// Create a message command.
396    #[inline]
397    pub fn msg(m: M) -> Self {
398        Self::Msg(m)
399    }
400
401    /// Create a log command.
402    ///
403    /// The message will be sanitized and written to the terminal log (scrollback).
404    /// A newline is appended if not present.
405    #[inline]
406    pub fn log(msg: impl Into<String>) -> Self {
407        Self::Log(msg.into())
408    }
409
410    /// Create a batch of commands.
411    pub fn batch(cmds: Vec<Self>) -> Self {
412        if cmds.is_empty() {
413            Self::None
414        } else if cmds.len() == 1 {
415            cmds.into_iter().next().unwrap_or(Self::None)
416        } else {
417            Self::Batch(cmds)
418        }
419    }
420
421    /// Create a sequence of commands.
422    pub fn sequence(cmds: Vec<Self>) -> Self {
423        if cmds.is_empty() {
424            Self::None
425        } else if cmds.len() == 1 {
426            cmds.into_iter().next().unwrap_or(Self::None)
427        } else {
428            Self::Sequence(cmds)
429        }
430    }
431
432    /// Return a stable name for telemetry and tracing.
433    #[inline]
434    pub fn type_name(&self) -> &'static str {
435        match self {
436            Self::None => "None",
437            Self::Quit => "Quit",
438            Self::Batch(_) => "Batch",
439            Self::Sequence(_) => "Sequence",
440            Self::Msg(_) => "Msg",
441            Self::Tick(_) => "Tick",
442            Self::Log(_) => "Log",
443            Self::Task(..) => "Task",
444            Self::SaveState => "SaveState",
445            Self::RestoreState => "RestoreState",
446            Self::SetMouseCapture(_) => "SetMouseCapture",
447            Self::SetTickStrategy(_) => "SetTickStrategy",
448        }
449    }
450
451    /// Create a tick command.
452    #[inline]
453    pub fn tick(duration: Duration) -> Self {
454        Self::Tick(duration)
455    }
456
457    /// Create a background task command.
458    ///
459    /// The closure runs on a spawned thread (or the effect queue worker when
460    /// scheduling is enabled). When it completes, the returned message is
461    /// sent back to the model's `update()`.
462    pub fn task<F>(f: F) -> Self
463    where
464        F: FnOnce() -> M + Send + 'static,
465    {
466        Self::Task(TaskSpec::default(), Box::new(f))
467    }
468
469    /// Create a background task command with explicit scheduling metadata.
470    pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
471    where
472        F: FnOnce() -> M + Send + 'static,
473    {
474        Self::Task(spec, Box::new(f))
475    }
476
477    /// Create a background task command with explicit weight and estimate.
478    pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
479    where
480        F: FnOnce() -> M + Send + 'static,
481    {
482        Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
483    }
484
485    /// Create a named background task command.
486    pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
487    where
488        F: FnOnce() -> M + Send + 'static,
489    {
490        Self::Task(TaskSpec::default().with_name(name), Box::new(f))
491    }
492
493    /// Replace the active tick strategy at runtime.
494    ///
495    /// Use when switching strategies (e.g., `Uniform` → `Predictive` after
496    /// loading persisted transition data).
497    pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
498        Self::SetTickStrategy(Box::new(strategy))
499    }
500
501    /// Create a save state command.
502    ///
503    /// Triggers a flush of the state registry to the storage backend.
504    /// No-op if persistence is not configured.
505    #[inline]
506    pub fn save_state() -> Self {
507        Self::SaveState
508    }
509
510    /// Create a restore state command.
511    ///
512    /// Triggers a load from the storage backend.
513    /// No-op if persistence is not configured.
514    #[inline]
515    pub fn restore_state() -> Self {
516        Self::RestoreState
517    }
518
519    /// Create a mouse capture toggle command.
520    ///
521    /// Instructs the runtime to enable or disable mouse event capture on the
522    /// underlying terminal session.
523    #[inline]
524    pub fn set_mouse_capture(enabled: bool) -> Self {
525        Self::SetMouseCapture(enabled)
526    }
527
528    /// Count the number of atomic commands in this command.
529    ///
530    /// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
531    pub fn count(&self) -> usize {
532        match self {
533            Self::None => 0,
534            Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
535            _ => 1,
536        }
537    }
538}
539
540/// Resize handling behavior for the runtime.
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum ResizeBehavior {
543    /// Apply resize immediately (no debounce, no placeholder).
544    Immediate,
545    /// Coalesce resize events for continuous reflow.
546    Throttled,
547}
548
549impl ResizeBehavior {
550    const fn uses_coalescer(self) -> bool {
551        matches!(self, ResizeBehavior::Throttled)
552    }
553}
554
555/// Policy controlling when terminal mouse capture is enabled.
556///
557/// Mouse capture can steal normal scrollback interaction in inline mode.
558/// `Auto` keeps inline mode scrollback-safe while still enabling mouse in
559/// alt-screen mode.
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
561pub enum MouseCapturePolicy {
562    /// Enable in alt-screen mode, disable in inline modes.
563    #[default]
564    Auto,
565    /// Always enable mouse capture.
566    On,
567    /// Always disable mouse capture.
568    Off,
569}
570
571impl MouseCapturePolicy {
572    /// Resolve the policy to a concrete mouse-capture toggle.
573    #[must_use]
574    pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
575        match self {
576            Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
577            Self::On => true,
578            Self::Off => false,
579        }
580    }
581}
582
583const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
584const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
585
586/// One splitter handle region in terminal cell-space.
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
588pub struct PaneTerminalSplitterHandle {
589    /// Semantic resize target represented by this handle.
590    pub target: PaneResizeTarget,
591    /// Cell-space hit rectangle for this handle.
592    pub rect: Rect,
593    /// Split boundary coordinate used for deterministic nearest-target ranking.
594    pub boundary: i32,
595}
596
597/// Build deterministic splitter handle regions for terminal hit-testing.
598///
599/// Handles are emitted in split-id order and are clamped to the split rect.
600#[must_use]
601pub fn pane_terminal_splitter_handles(
602    tree: &PaneTree,
603    layout: &PaneLayout,
604    hit_thickness: u16,
605) -> Vec<PaneTerminalSplitterHandle> {
606    let thickness = if hit_thickness == 0 {
607        PANE_TERMINAL_DEFAULT_HIT_THICKNESS
608    } else {
609        hit_thickness
610    };
611    let mut handles = Vec::new();
612    for node in tree.nodes() {
613        let PaneNodeKind::Split(split) = &node.kind else {
614            continue;
615        };
616        let Some(split_rect) = layout.rect(node.id) else {
617            continue;
618        };
619        if split_rect.is_empty() {
620            continue;
621        }
622        let Some(first_rect) = layout.rect(split.first) else {
623            continue;
624        };
625        let Some(second_rect) = layout.rect(split.second) else {
626            continue;
627        };
628
629        let boundary_u16 = match split.axis {
630            SplitAxis::Horizontal => {
631                // Horizontal split => left/right panes => vertical splitter line.
632                if second_rect.x == split_rect.x {
633                    first_rect.right()
634                } else {
635                    second_rect.x
636                }
637            }
638            SplitAxis::Vertical => {
639                // Vertical split => top/bottom panes => horizontal splitter line.
640                if second_rect.y == split_rect.y {
641                    first_rect.bottom()
642                } else {
643                    second_rect.y
644                }
645            }
646        };
647        let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
648            continue;
649        };
650        handles.push(PaneTerminalSplitterHandle {
651            target: PaneResizeTarget {
652                split_id: node.id,
653                axis: split.axis,
654            },
655            rect,
656            boundary: i32::from(boundary_u16),
657        });
658    }
659    handles
660}
661
662/// Resolve a semantic splitter target from a terminal cell position.
663///
664/// If multiple handles overlap, chooses deterministically by:
665/// 1) smallest distance to the splitter boundary, then
666/// 2) smaller split_id, then
667/// 3) horizontal axis before vertical axis.
668#[must_use]
669pub fn pane_terminal_resolve_splitter_target(
670    handles: &[PaneTerminalSplitterHandle],
671    x: u16,
672    y: u16,
673) -> Option<PaneResizeTarget> {
674    let px = i32::from(x);
675    let py = i32::from(y);
676    let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
677
678    for handle in handles {
679        if !rect_contains_cell(handle.rect, x, y) {
680            continue;
681        }
682        let distance = match handle.target.axis {
683            SplitAxis::Horizontal => px.abs_diff(handle.boundary),
684            SplitAxis::Vertical => py.abs_diff(handle.boundary),
685        };
686        let axis_rank = match handle.target.axis {
687            SplitAxis::Horizontal => 0,
688            SplitAxis::Vertical => 1,
689        };
690        let key = (distance, handle.target.split_id.get(), axis_rank);
691        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
692            best = Some((key, handle.target));
693        }
694    }
695
696    best.map(|(_, target)| target)
697}
698
699/// Register pane splitter handles into the frame hit-grid.
700///
701/// Each handle is registered as `HitRegion::Handle` with encoded target data.
702/// Returns number of successfully-registered regions.
703pub fn register_pane_terminal_splitter_hits(
704    frame: &mut Frame,
705    handles: &[PaneTerminalSplitterHandle],
706    hit_id_base: u32,
707) -> usize {
708    let mut registered = 0usize;
709    for (idx, handle) in handles.iter().enumerate() {
710        let Ok(offset) = u32::try_from(idx) else {
711            break;
712        };
713        let hit_id = HitId::new(hit_id_base.saturating_add(offset));
714        if frame.register_hit(
715            handle.rect,
716            hit_id,
717            HitRegion::Handle,
718            encode_pane_resize_target(handle.target),
719        ) {
720            registered = registered.saturating_add(1);
721        }
722    }
723    registered
724}
725
726/// Decode pane resize target from a hit-grid tuple produced by pane handle registration.
727#[must_use]
728pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
729    let (_, region, data) = hit;
730    if region != HitRegion::Handle {
731        return None;
732    }
733    decode_pane_resize_target(data)
734}
735
736fn splitter_hit_rect(
737    axis: SplitAxis,
738    split_rect: Rect,
739    boundary: u16,
740    thickness: u16,
741) -> Option<Rect> {
742    let half = thickness.saturating_sub(1) / 2;
743    match axis {
744        SplitAxis::Horizontal => {
745            let start = boundary.saturating_sub(half).max(split_rect.x);
746            let end = boundary
747                .saturating_add(thickness.saturating_sub(half))
748                .min(split_rect.right());
749            let width = end.saturating_sub(start);
750            (width > 0 && split_rect.height > 0).then_some(Rect::new(
751                start,
752                split_rect.y,
753                width,
754                split_rect.height,
755            ))
756        }
757        SplitAxis::Vertical => {
758            let start = boundary.saturating_sub(half).max(split_rect.y);
759            let end = boundary
760                .saturating_add(thickness.saturating_sub(half))
761                .min(split_rect.bottom());
762            let height = end.saturating_sub(start);
763            (height > 0 && split_rect.width > 0).then_some(Rect::new(
764                split_rect.x,
765                start,
766                split_rect.width,
767                height,
768            ))
769        }
770    }
771}
772
773fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
774    x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
775}
776
777fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
778    let axis = match target.axis {
779        SplitAxis::Horizontal => 0_u64,
780        SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
781    };
782    (target.split_id.get() << 1) | axis
783}
784
785fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
786    let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
787        SplitAxis::Horizontal
788    } else {
789        SplitAxis::Vertical
790    };
791    let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
792    Some(PaneResizeTarget { split_id, axis })
793}
794
795// ============================================================================
796// Pane capability matrix for multiplexer / terminal compat (bd-6u66i)
797// ============================================================================
798
799/// Which multiplexer environment the terminal is running inside.
800#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
801pub enum PaneMuxEnvironment {
802    /// No multiplexer detected — direct terminal access.
803    None,
804    /// tmux (TMUX env var set, or DA2 terminal type 84).
805    Tmux,
806    /// GNU Screen (STY env var set, or DA2 terminal type 83).
807    Screen,
808    /// Zellij (ZELLIJ env var set).
809    Zellij,
810    /// WezTerm mux-served pane/session.
811    WeztermMux,
812}
813
814/// Resolved capability matrix describing which pane interaction features
815/// are available in the current terminal + multiplexer environment.
816///
817/// Derived from [`TerminalCapabilities`] via [`PaneCapabilityMatrix::from_capabilities`].
818/// The adapter uses this to decide which code-paths are safe and which
819/// need deterministic fallbacks.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub struct PaneCapabilityMatrix {
822    /// Detected multiplexer environment.
823    pub mux: PaneMuxEnvironment,
824
825    // --- Mouse input capabilities ---
826    /// SGR (1006) extended mouse protocol available.
827    /// Without this, mouse coordinates are limited to 223 columns/rows.
828    pub mouse_sgr: bool,
829    /// Mouse drag events are reliably delivered.
830    /// False in some screen versions where drag tracking is incomplete.
831    pub mouse_drag_reliable: bool,
832    /// Mouse button events include correct button identity on release.
833    /// X10/normal mode sends button 3 for all releases; SGR preserves it.
834    pub mouse_button_discrimination: bool,
835
836    // --- Focus / lifecycle ---
837    /// Terminal delivers CSI I / CSI O focus events.
838    pub focus_events: bool,
839    /// Bracketed paste mode available (affects interaction cancel heuristics).
840    pub bracketed_paste: bool,
841
842    // --- Rendering affordances ---
843    /// Unicode box-drawing glyphs available for splitter rendering.
844    pub unicode_box_drawing: bool,
845    /// True-color support for splitter highlight/drag feedback.
846    pub true_color: bool,
847
848    // --- Fallback summary ---
849    /// One or more pane features are degraded due to environment constraints.
850    pub degraded: bool,
851}
852
853/// Human-readable description of a known limitation and its fallback.
854#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct PaneCapabilityLimitation {
856    /// Short identifier (e.g. `"mouse_drag_unreliable"`).
857    pub id: &'static str,
858    /// What the limitation is.
859    pub description: &'static str,
860    /// What the adapter does instead.
861    pub fallback: &'static str,
862}
863
864impl PaneCapabilityMatrix {
865    /// Derive the pane capability matrix from terminal capabilities.
866    ///
867    /// This is the single source of truth for which pane features are
868    /// available. All fallback decisions flow from this matrix.
869    #[must_use]
870    pub fn from_capabilities(
871        caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
872    ) -> Self {
873        let mux = if caps.in_tmux {
874            PaneMuxEnvironment::Tmux
875        } else if caps.in_screen {
876            PaneMuxEnvironment::Screen
877        } else if caps.in_zellij {
878            PaneMuxEnvironment::Zellij
879        } else if caps.in_wezterm_mux {
880            PaneMuxEnvironment::WeztermMux
881        } else {
882            PaneMuxEnvironment::None
883        };
884
885        let mouse_sgr = caps.mouse_sgr;
886
887        // GNU Screen has historically unreliable drag event delivery.
888        // tmux and zellij forward drags correctly in modern versions.
889        let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
890
891        // Button discrimination requires SGR mouse protocol.
892        // Without it, X10/normal mode reports button 3 for all releases.
893        let mouse_button_discrimination = mouse_sgr;
894
895        // Focus events are conservatively disabled in any mux context.
896        let focus_events = caps.focus_events && !caps.in_any_mux();
897
898        let bracketed_paste = caps.bracketed_paste;
899        let unicode_box_drawing = caps.unicode_box_drawing;
900        let true_color = caps.true_color;
901
902        let degraded =
903            !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
904
905        Self {
906            mux,
907            mouse_sgr,
908            mouse_drag_reliable,
909            mouse_button_discrimination,
910            focus_events,
911            bracketed_paste,
912            unicode_box_drawing,
913            true_color,
914            degraded,
915        }
916    }
917
918    /// Whether pane drag interactions should be enabled at all.
919    ///
920    /// Drag requires at minimum mouse event support. If drag events
921    /// are unreliable (e.g. GNU Screen), drag is disabled and the
922    /// adapter falls back to keyboard-only resize.
923    #[must_use]
924    pub const fn drag_enabled(&self) -> bool {
925        self.mouse_drag_reliable
926    }
927
928    /// Whether focus-loss auto-cancel is effective.
929    ///
930    /// When focus events are unavailable, the adapter cannot detect
931    /// window blur — interactions must rely on timeout or explicit
932    /// keyboard cancel instead.
933    #[must_use]
934    pub const fn focus_cancel_effective(&self) -> bool {
935        self.focus_events
936    }
937
938    /// Collect all active limitations with their fallback descriptions.
939    #[must_use]
940    pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
941        let mut out = Vec::new();
942
943        if !self.mouse_sgr {
944            out.push(PaneCapabilityLimitation {
945                id: "no_sgr_mouse",
946                description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
947                fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
948            });
949        }
950
951        if !self.mouse_drag_reliable {
952            out.push(PaneCapabilityLimitation {
953                id: "mouse_drag_unreliable",
954                description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
955                fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
956            });
957        }
958
959        if !self.mouse_button_discrimination {
960            out.push(PaneCapabilityLimitation {
961                id: "no_button_discrimination",
962                description: "Mouse release events do not identify which button was released",
963                fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
964            });
965        }
966
967        if !self.focus_events {
968            out.push(PaneCapabilityLimitation {
969                id: "no_focus_events",
970                description: "Terminal does not deliver focus-in/focus-out events",
971                fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
972            });
973        }
974
975        out
976    }
977}
978
979/// Configuration for terminal-to-pane semantic input translation.
980///
981/// This adapter normalizes terminal `Event` streams into
982/// `PaneSemanticInputEvent` values accepted by `PaneDragResizeMachine`.
983#[derive(Debug, Clone, Copy, PartialEq, Eq)]
984pub struct PaneTerminalAdapterConfig {
985    /// Drag start threshold in pane-local units.
986    pub drag_threshold: u16,
987    /// Drag update hysteresis threshold in pane-local units.
988    pub update_hysteresis: u16,
989    /// Mouse button required to begin a drag sequence.
990    pub activation_button: PanePointerButton,
991    /// Minimum drag delta (Manhattan distance, cells) before forwarding
992    /// updates while already in the dragging state.
993    pub drag_update_coalesce_distance: u16,
994    /// Cancel active interactions on focus loss.
995    pub cancel_on_focus_lost: bool,
996    /// Cancel active interactions on terminal resize.
997    pub cancel_on_resize: bool,
998}
999
1000impl Default for PaneTerminalAdapterConfig {
1001    fn default() -> Self {
1002        Self {
1003            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1004            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1005            activation_button: PanePointerButton::Primary,
1006            drag_update_coalesce_distance: 2,
1007            cancel_on_focus_lost: true,
1008            cancel_on_resize: true,
1009        }
1010    }
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1014struct PaneTerminalActivePointer {
1015    pointer_id: u32,
1016    target: PaneResizeTarget,
1017    button: PanePointerButton,
1018    last_position: PanePointerPosition,
1019    cumulative_delta_x: i32,
1020    cumulative_delta_y: i32,
1021    direction_changes: u16,
1022    sample_count: u32,
1023    previous_step_delta_x: i32,
1024    previous_step_delta_y: i32,
1025    start_time: Instant,
1026}
1027
1028/// Lifecycle phase observed while translating a terminal event.
1029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub enum PaneTerminalLifecyclePhase {
1031    MouseDown,
1032    MouseDrag,
1033    MouseMove,
1034    MouseUp,
1035    MouseScroll,
1036    KeyResize,
1037    KeyCancel,
1038    FocusLoss,
1039    ResizeInterrupt,
1040    Other,
1041}
1042
1043/// Deterministic reason a terminal event did not map to pane semantics.
1044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum PaneTerminalIgnoredReason {
1046    MissingTarget,
1047    NoActivePointer,
1048    PointerButtonMismatch,
1049    ActivationButtonRequired,
1050    WindowNotFocused,
1051    UnsupportedKey,
1052    FocusGainNoop,
1053    ResizeNoop,
1054    DragCoalesced,
1055    NonSemanticEvent,
1056    MachineRejectedEvent,
1057}
1058
1059/// Translation outcome for one raw terminal event.
1060#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1061pub enum PaneTerminalLogOutcome {
1062    SemanticForwarded,
1063    SemanticForwardedAfterRecovery,
1064    Ignored(PaneTerminalIgnoredReason),
1065}
1066
1067/// Structured translation log entry for one raw terminal event.
1068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1069pub struct PaneTerminalLogEntry {
1070    pub phase: PaneTerminalLifecyclePhase,
1071    pub sequence: Option<u64>,
1072    pub pointer_id: Option<u32>,
1073    pub target: Option<PaneResizeTarget>,
1074    pub recovery_cancel_sequence: Option<u64>,
1075    pub outcome: PaneTerminalLogOutcome,
1076}
1077
1078/// Output of one terminal event translation step.
1079///
1080/// `recovery_*` fields are populated when the adapter first emits an internal
1081/// cancel (for stale/missing mouse-up recovery) and then forwards the incoming
1082/// event as a fresh semantic event.
1083#[derive(Debug, Clone, PartialEq)]
1084pub struct PaneTerminalDispatch {
1085    pub primary_event: Option<PaneSemanticInputEvent>,
1086    pub primary_transition: Option<PaneDragResizeTransition>,
1087    pub motion: Option<PaneMotionVector>,
1088    pub inertial_throw: Option<PaneInertialThrow>,
1089    pub projected_position: Option<PanePointerPosition>,
1090    pub recovery_event: Option<PaneSemanticInputEvent>,
1091    pub recovery_transition: Option<PaneDragResizeTransition>,
1092    pub log: PaneTerminalLogEntry,
1093}
1094
1095impl PaneTerminalDispatch {
1096    fn ignored(
1097        phase: PaneTerminalLifecyclePhase,
1098        reason: PaneTerminalIgnoredReason,
1099        pointer_id: Option<u32>,
1100        target: Option<PaneResizeTarget>,
1101    ) -> Self {
1102        Self {
1103            primary_event: None,
1104            primary_transition: None,
1105            motion: None,
1106            inertial_throw: None,
1107            projected_position: None,
1108            recovery_event: None,
1109            recovery_transition: None,
1110            log: PaneTerminalLogEntry {
1111                phase,
1112                sequence: None,
1113                pointer_id,
1114                target,
1115                recovery_cancel_sequence: None,
1116                outcome: PaneTerminalLogOutcome::Ignored(reason),
1117            },
1118        }
1119    }
1120
1121    fn forwarded(
1122        phase: PaneTerminalLifecyclePhase,
1123        pointer_id: Option<u32>,
1124        target: Option<PaneResizeTarget>,
1125        event: PaneSemanticInputEvent,
1126        transition: PaneDragResizeTransition,
1127    ) -> Self {
1128        let sequence = Some(event.sequence);
1129        Self {
1130            primary_event: Some(event),
1131            primary_transition: Some(transition),
1132            motion: None,
1133            inertial_throw: None,
1134            projected_position: None,
1135            recovery_event: None,
1136            recovery_transition: None,
1137            log: PaneTerminalLogEntry {
1138                phase,
1139                sequence,
1140                pointer_id,
1141                target,
1142                recovery_cancel_sequence: None,
1143                outcome: PaneTerminalLogOutcome::SemanticForwarded,
1144            },
1145        }
1146    }
1147
1148    /// Derive dynamic snap profile from translated pointer motion.
1149    #[must_use]
1150    pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1151        self.motion.map(PanePressureSnapProfile::from_motion)
1152    }
1153}
1154
1155/// Deterministic terminal adapter mapping raw `Event` values into
1156/// schema-validated pane semantic interaction events.
1157#[derive(Debug, Clone)]
1158pub struct PaneTerminalAdapter {
1159    machine: PaneDragResizeMachine,
1160    config: PaneTerminalAdapterConfig,
1161    active: Option<PaneTerminalActivePointer>,
1162    window_focused: bool,
1163    next_sequence: u64,
1164}
1165
1166impl PaneTerminalAdapter {
1167    /// Construct a new adapter with validated drag thresholds.
1168    pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1169        let config = PaneTerminalAdapterConfig {
1170            drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1171            ..config
1172        };
1173        let machine = PaneDragResizeMachine::new_with_hysteresis(
1174            config.drag_threshold,
1175            config.update_hysteresis,
1176        )?;
1177        Ok(Self {
1178            machine,
1179            config,
1180            active: None,
1181            window_focused: true,
1182            next_sequence: 1,
1183        })
1184    }
1185
1186    /// Adapter configuration.
1187    #[must_use]
1188    pub const fn config(&self) -> PaneTerminalAdapterConfig {
1189        self.config
1190    }
1191
1192    /// Active pointer id currently tracked by the adapter, if any.
1193    #[must_use]
1194    pub fn active_pointer_id(&self) -> Option<u32> {
1195        self.active.map(|active| active.pointer_id)
1196    }
1197
1198    /// Whether the host window is currently focused.
1199    #[must_use]
1200    pub const fn window_focused(&self) -> bool {
1201        self.window_focused
1202    }
1203
1204    /// Current pane drag/resize machine state.
1205    #[must_use]
1206    pub const fn machine_state(&self) -> PaneDragResizeState {
1207        self.machine.state()
1208    }
1209
1210    /// Translate one raw terminal event into pane semantic event(s).
1211    ///
1212    /// `target_hint` is provided by host hit-testing (upcoming pane-terminal
1213    /// tasks). Pointer drag/move/up reuse active target continuity once armed.
1214    pub fn translate(
1215        &mut self,
1216        event: &Event,
1217        target_hint: Option<PaneResizeTarget>,
1218    ) -> PaneTerminalDispatch {
1219        match event {
1220            Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1221            Event::Key(key) => self.translate_key(*key, target_hint),
1222            Event::Focus(focused) => self.translate_focus(*focused),
1223            Event::Resize { .. } => self.translate_resize(),
1224            _ => PaneTerminalDispatch::ignored(
1225                PaneTerminalLifecyclePhase::Other,
1226                PaneTerminalIgnoredReason::NonSemanticEvent,
1227                None,
1228                target_hint,
1229            ),
1230        }
1231    }
1232
1233    /// Translate one raw terminal event while resolving splitter targets from
1234    /// terminal hit regions.
1235    ///
1236    /// This is a convenience wrapper for host code that already has splitter
1237    /// handle regions from [`pane_terminal_splitter_handles`].
1238    pub fn translate_with_handles(
1239        &mut self,
1240        event: &Event,
1241        handles: &[PaneTerminalSplitterHandle],
1242    ) -> PaneTerminalDispatch {
1243        let active_target = self.active.map(|active| active.target);
1244        let target_hint = match event {
1245            Event::Mouse(mouse) => {
1246                let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1247                match mouse.kind {
1248                    MouseEventKind::Down(_)
1249                    | MouseEventKind::ScrollUp
1250                    | MouseEventKind::ScrollDown
1251                    | MouseEventKind::ScrollLeft
1252                    | MouseEventKind::ScrollRight => resolved,
1253                    MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1254                        resolved.or(active_target)
1255                    }
1256                }
1257            }
1258            Event::Key(_) => active_target,
1259            _ => None,
1260        };
1261        self.translate(event, target_hint)
1262    }
1263
1264    fn translate_mouse(
1265        &mut self,
1266        mouse: MouseEvent,
1267        target_hint: Option<PaneResizeTarget>,
1268    ) -> PaneTerminalDispatch {
1269        let position = mouse_position(mouse);
1270        let modifiers = pane_modifiers(mouse.modifiers);
1271        match mouse.kind {
1272            MouseEventKind::Down(button) => {
1273                let pane_button = pane_button(button);
1274                if pane_button != self.config.activation_button {
1275                    return PaneTerminalDispatch::ignored(
1276                        PaneTerminalLifecyclePhase::MouseDown,
1277                        PaneTerminalIgnoredReason::ActivationButtonRequired,
1278                        Some(pointer_id_for_button(pane_button)),
1279                        target_hint,
1280                    );
1281                }
1282                let Some(target) = target_hint else {
1283                    return PaneTerminalDispatch::ignored(
1284                        PaneTerminalLifecyclePhase::MouseDown,
1285                        PaneTerminalIgnoredReason::MissingTarget,
1286                        Some(pointer_id_for_button(pane_button)),
1287                        None,
1288                    );
1289                };
1290
1291                let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1292                let pointer_id = pointer_id_for_button(pane_button);
1293                let kind = PaneSemanticInputEventKind::PointerDown {
1294                    target,
1295                    pointer_id,
1296                    button: pane_button,
1297                    position,
1298                };
1299                let mut dispatch = self.forward_semantic(
1300                    PaneTerminalLifecyclePhase::MouseDown,
1301                    Some(pointer_id),
1302                    Some(target),
1303                    kind,
1304                    modifiers,
1305                );
1306                if dispatch.primary_transition.is_some() {
1307                    self.active = Some(PaneTerminalActivePointer {
1308                        pointer_id,
1309                        target,
1310                        button: pane_button,
1311                        last_position: position,
1312                        cumulative_delta_x: 0,
1313                        cumulative_delta_y: 0,
1314                        direction_changes: 0,
1315                        sample_count: 0,
1316                        previous_step_delta_x: 0,
1317                        previous_step_delta_y: 0,
1318                        start_time: Instant::now(),
1319                    });
1320                }
1321                if let Some((cancel_event, cancel_transition)) = recovery {
1322                    dispatch.recovery_event = Some(cancel_event);
1323                    dispatch.recovery_transition = Some(cancel_transition);
1324                    dispatch.log.recovery_cancel_sequence =
1325                        dispatch.recovery_event.as_ref().map(|event| event.sequence);
1326                    if matches!(
1327                        dispatch.log.outcome,
1328                        PaneTerminalLogOutcome::SemanticForwarded
1329                    ) {
1330                        dispatch.log.outcome =
1331                            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1332                    }
1333                }
1334                dispatch
1335            }
1336            MouseEventKind::Drag(button) => {
1337                let pane_button = pane_button(button);
1338                let Some(mut active) = self.active else {
1339                    return PaneTerminalDispatch::ignored(
1340                        PaneTerminalLifecyclePhase::MouseDrag,
1341                        PaneTerminalIgnoredReason::NoActivePointer,
1342                        Some(pointer_id_for_button(pane_button)),
1343                        target_hint,
1344                    );
1345                };
1346                if active.button != pane_button {
1347                    return PaneTerminalDispatch::ignored(
1348                        PaneTerminalLifecyclePhase::MouseDrag,
1349                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1350                        Some(pointer_id_for_button(pane_button)),
1351                        Some(active.target),
1352                    );
1353                }
1354                let delta_x = position.x.saturating_sub(active.last_position.x);
1355                let delta_y = position.y.saturating_sub(active.last_position.y);
1356                if self.should_coalesce_drag(delta_x, delta_y) {
1357                    return PaneTerminalDispatch::ignored(
1358                        PaneTerminalLifecyclePhase::MouseDrag,
1359                        PaneTerminalIgnoredReason::DragCoalesced,
1360                        Some(active.pointer_id),
1361                        Some(active.target),
1362                    );
1363                }
1364                if active.sample_count > 0 {
1365                    let flipped_x = delta_x.signum() != 0
1366                        && active.previous_step_delta_x.signum() != 0
1367                        && delta_x.signum() != active.previous_step_delta_x.signum();
1368                    let flipped_y = delta_y.signum() != 0
1369                        && active.previous_step_delta_y.signum() != 0
1370                        && delta_y.signum() != active.previous_step_delta_y.signum();
1371                    if flipped_x || flipped_y {
1372                        active.direction_changes = active.direction_changes.saturating_add(1);
1373                    }
1374                }
1375                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1376                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1377                active.sample_count = active.sample_count.saturating_add(1);
1378                active.previous_step_delta_x = delta_x;
1379                active.previous_step_delta_y = delta_y;
1380                let kind = PaneSemanticInputEventKind::PointerMove {
1381                    target: active.target,
1382                    pointer_id: active.pointer_id,
1383                    position,
1384                    delta_x,
1385                    delta_y,
1386                };
1387                let mut dispatch = self.forward_semantic(
1388                    PaneTerminalLifecyclePhase::MouseDrag,
1389                    Some(active.pointer_id),
1390                    Some(active.target),
1391                    kind,
1392                    modifiers,
1393                );
1394                if dispatch.primary_transition.is_some() {
1395                    active.last_position = position;
1396                    self.active = Some(active);
1397                    let duration = active.start_time.elapsed().as_millis() as u32;
1398                    dispatch.motion = Some(PaneMotionVector::from_delta(
1399                        active.cumulative_delta_x,
1400                        active.cumulative_delta_y,
1401                        duration,
1402                        active.direction_changes,
1403                    ));
1404                }
1405                dispatch
1406            }
1407            MouseEventKind::Moved => {
1408                let Some(mut active) = self.active else {
1409                    return PaneTerminalDispatch::ignored(
1410                        PaneTerminalLifecyclePhase::MouseMove,
1411                        PaneTerminalIgnoredReason::NoActivePointer,
1412                        None,
1413                        target_hint,
1414                    );
1415                };
1416                let delta_x = position.x.saturating_sub(active.last_position.x);
1417                let delta_y = position.y.saturating_sub(active.last_position.y);
1418                if self.should_coalesce_drag(delta_x, delta_y) {
1419                    return PaneTerminalDispatch::ignored(
1420                        PaneTerminalLifecyclePhase::MouseMove,
1421                        PaneTerminalIgnoredReason::DragCoalesced,
1422                        Some(active.pointer_id),
1423                        Some(active.target),
1424                    );
1425                }
1426                if active.sample_count > 0 {
1427                    let flipped_x = delta_x.signum() != 0
1428                        && active.previous_step_delta_x.signum() != 0
1429                        && delta_x.signum() != active.previous_step_delta_x.signum();
1430                    let flipped_y = delta_y.signum() != 0
1431                        && active.previous_step_delta_y.signum() != 0
1432                        && delta_y.signum() != active.previous_step_delta_y.signum();
1433                    if flipped_x || flipped_y {
1434                        active.direction_changes = active.direction_changes.saturating_add(1);
1435                    }
1436                }
1437                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1438                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1439                active.sample_count = active.sample_count.saturating_add(1);
1440                active.previous_step_delta_x = delta_x;
1441                active.previous_step_delta_y = delta_y;
1442                let kind = PaneSemanticInputEventKind::PointerMove {
1443                    target: active.target,
1444                    pointer_id: active.pointer_id,
1445                    position,
1446                    delta_x,
1447                    delta_y,
1448                };
1449                let mut dispatch = self.forward_semantic(
1450                    PaneTerminalLifecyclePhase::MouseMove,
1451                    Some(active.pointer_id),
1452                    Some(active.target),
1453                    kind,
1454                    modifiers,
1455                );
1456                if dispatch.primary_transition.is_some() {
1457                    active.last_position = position;
1458                    self.active = Some(active);
1459                    let duration = active.start_time.elapsed().as_millis() as u32;
1460                    dispatch.motion = Some(PaneMotionVector::from_delta(
1461                        active.cumulative_delta_x,
1462                        active.cumulative_delta_y,
1463                        duration,
1464                        active.direction_changes,
1465                    ));
1466                }
1467                dispatch
1468            }
1469            MouseEventKind::Up(button) => {
1470                let pane_button = pane_button(button);
1471                let Some(active) = self.active else {
1472                    return PaneTerminalDispatch::ignored(
1473                        PaneTerminalLifecyclePhase::MouseUp,
1474                        PaneTerminalIgnoredReason::NoActivePointer,
1475                        Some(pointer_id_for_button(pane_button)),
1476                        target_hint,
1477                    );
1478                };
1479                if active.button != pane_button {
1480                    return PaneTerminalDispatch::ignored(
1481                        PaneTerminalLifecyclePhase::MouseUp,
1482                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1483                        Some(pointer_id_for_button(pane_button)),
1484                        Some(active.target),
1485                    );
1486                }
1487                let kind = PaneSemanticInputEventKind::PointerUp {
1488                    target: active.target,
1489                    pointer_id: active.pointer_id,
1490                    button: active.button,
1491                    position,
1492                };
1493                let mut dispatch = self.forward_semantic(
1494                    PaneTerminalLifecyclePhase::MouseUp,
1495                    Some(active.pointer_id),
1496                    Some(active.target),
1497                    kind,
1498                    modifiers,
1499                );
1500                if dispatch.primary_transition.is_some() {
1501                    let duration = active.start_time.elapsed().as_millis() as u32;
1502                    let motion = PaneMotionVector::from_delta(
1503                        active.cumulative_delta_x,
1504                        active.cumulative_delta_y,
1505                        duration,
1506                        active.direction_changes,
1507                    );
1508                    let inertial_throw = PaneInertialThrow::from_motion(motion);
1509                    dispatch.motion = Some(motion);
1510                    dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1511                    dispatch.inertial_throw = Some(inertial_throw);
1512                    self.active = None;
1513                }
1514                dispatch
1515            }
1516            MouseEventKind::ScrollUp
1517            | MouseEventKind::ScrollDown
1518            | MouseEventKind::ScrollLeft
1519            | MouseEventKind::ScrollRight => {
1520                let target = target_hint.or(self.active.map(|active| active.target));
1521                let Some(target) = target else {
1522                    return PaneTerminalDispatch::ignored(
1523                        PaneTerminalLifecyclePhase::MouseScroll,
1524                        PaneTerminalIgnoredReason::MissingTarget,
1525                        None,
1526                        None,
1527                    );
1528                };
1529                let lines = match mouse.kind {
1530                    MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1531                    MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1532                    _ => unreachable!("handled by outer match"),
1533                };
1534                let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1535                self.forward_semantic(
1536                    PaneTerminalLifecyclePhase::MouseScroll,
1537                    None,
1538                    Some(target),
1539                    kind,
1540                    modifiers,
1541                )
1542            }
1543        }
1544    }
1545
1546    fn translate_key(
1547        &mut self,
1548        key: KeyEvent,
1549        target_hint: Option<PaneResizeTarget>,
1550    ) -> PaneTerminalDispatch {
1551        if !self.window_focused {
1552            return PaneTerminalDispatch::ignored(
1553                PaneTerminalLifecyclePhase::KeyResize,
1554                PaneTerminalIgnoredReason::WindowNotFocused,
1555                self.active_pointer_id(),
1556                target_hint.or(self.active.map(|active| active.target)),
1557            );
1558        }
1559        if key.kind == KeyEventKind::Release {
1560            return PaneTerminalDispatch::ignored(
1561                PaneTerminalLifecyclePhase::Other,
1562                PaneTerminalIgnoredReason::UnsupportedKey,
1563                None,
1564                target_hint,
1565            );
1566        }
1567        if matches!(key.code, KeyCode::Escape) {
1568            return self.cancel_active_dispatch(
1569                PaneTerminalLifecyclePhase::KeyCancel,
1570                PaneCancelReason::EscapeKey,
1571                PaneTerminalIgnoredReason::NoActivePointer,
1572            );
1573        }
1574        let target = target_hint.or(self.active.map(|active| active.target));
1575        let Some(target) = target else {
1576            return PaneTerminalDispatch::ignored(
1577                PaneTerminalLifecyclePhase::KeyResize,
1578                PaneTerminalIgnoredReason::MissingTarget,
1579                None,
1580                None,
1581            );
1582        };
1583        let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1584            return PaneTerminalDispatch::ignored(
1585                PaneTerminalLifecyclePhase::KeyResize,
1586                PaneTerminalIgnoredReason::UnsupportedKey,
1587                None,
1588                Some(target),
1589            );
1590        };
1591        let units = keyboard_resize_units(key.modifiers);
1592        let kind = PaneSemanticInputEventKind::KeyboardResize {
1593            target,
1594            direction,
1595            units,
1596        };
1597        self.forward_semantic(
1598            PaneTerminalLifecyclePhase::KeyResize,
1599            self.active_pointer_id(),
1600            Some(target),
1601            kind,
1602            pane_modifiers(key.modifiers),
1603        )
1604    }
1605
1606    fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1607        if focused {
1608            self.window_focused = true;
1609            return PaneTerminalDispatch::ignored(
1610                PaneTerminalLifecyclePhase::Other,
1611                PaneTerminalIgnoredReason::FocusGainNoop,
1612                self.active_pointer_id(),
1613                self.active.map(|active| active.target),
1614            );
1615        }
1616        self.window_focused = false;
1617        if !self.config.cancel_on_focus_lost {
1618            return PaneTerminalDispatch::ignored(
1619                PaneTerminalLifecyclePhase::FocusLoss,
1620                PaneTerminalIgnoredReason::ResizeNoop,
1621                self.active_pointer_id(),
1622                self.active.map(|active| active.target),
1623            );
1624        }
1625        self.cancel_active_dispatch(
1626            PaneTerminalLifecyclePhase::FocusLoss,
1627            PaneCancelReason::FocusLost,
1628            PaneTerminalIgnoredReason::NoActivePointer,
1629        )
1630    }
1631
1632    fn translate_resize(&mut self) -> PaneTerminalDispatch {
1633        if !self.config.cancel_on_resize {
1634            return PaneTerminalDispatch::ignored(
1635                PaneTerminalLifecyclePhase::ResizeInterrupt,
1636                PaneTerminalIgnoredReason::ResizeNoop,
1637                self.active_pointer_id(),
1638                self.active.map(|active| active.target),
1639            );
1640        }
1641        self.cancel_active_dispatch(
1642            PaneTerminalLifecyclePhase::ResizeInterrupt,
1643            PaneCancelReason::Programmatic,
1644            PaneTerminalIgnoredReason::ResizeNoop,
1645        )
1646    }
1647
1648    fn cancel_active_dispatch(
1649        &mut self,
1650        phase: PaneTerminalLifecyclePhase,
1651        reason: PaneCancelReason,
1652        no_active_reason: PaneTerminalIgnoredReason,
1653    ) -> PaneTerminalDispatch {
1654        let Some(active) = self.active else {
1655            return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1656        };
1657        let kind = PaneSemanticInputEventKind::Cancel {
1658            target: Some(active.target),
1659            reason,
1660        };
1661        let dispatch = self.forward_semantic(
1662            phase,
1663            Some(active.pointer_id),
1664            Some(active.target),
1665            kind,
1666            PaneModifierSnapshot::default(),
1667        );
1668        if dispatch.primary_transition.is_some() {
1669            self.active = None;
1670        }
1671        dispatch
1672    }
1673
1674    fn cancel_active_internal(
1675        &mut self,
1676        reason: PaneCancelReason,
1677    ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1678        let active = self.active?;
1679        let kind = PaneSemanticInputEventKind::Cancel {
1680            target: Some(active.target),
1681            reason,
1682        };
1683        let result = self
1684            .apply_semantic(kind, PaneModifierSnapshot::default())
1685            .ok();
1686        if result.is_some() {
1687            self.active = None;
1688        }
1689        result
1690    }
1691
1692    fn forward_semantic(
1693        &mut self,
1694        phase: PaneTerminalLifecyclePhase,
1695        pointer_id: Option<u32>,
1696        target: Option<PaneResizeTarget>,
1697        kind: PaneSemanticInputEventKind,
1698        modifiers: PaneModifierSnapshot,
1699    ) -> PaneTerminalDispatch {
1700        match self.apply_semantic(kind, modifiers) {
1701            Ok((event, transition)) => {
1702                PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1703            }
1704            Err(_) => PaneTerminalDispatch::ignored(
1705                phase,
1706                PaneTerminalIgnoredReason::MachineRejectedEvent,
1707                pointer_id,
1708                target,
1709            ),
1710        }
1711    }
1712
1713    fn apply_semantic(
1714        &mut self,
1715        kind: PaneSemanticInputEventKind,
1716        modifiers: PaneModifierSnapshot,
1717    ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1718    {
1719        let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1720        event.modifiers = modifiers;
1721        let transition = self.machine.apply_event(&event)?;
1722        Ok((event, transition))
1723    }
1724
1725    fn next_sequence(&mut self) -> u64 {
1726        let sequence = self.next_sequence;
1727        self.next_sequence = self.next_sequence.saturating_add(1);
1728        sequence
1729    }
1730
1731    fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1732        if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1733            return false;
1734        }
1735        let movement = delta_x
1736            .unsigned_abs()
1737            .saturating_add(delta_y.unsigned_abs());
1738        movement < u32::from(self.config.drag_update_coalesce_distance)
1739    }
1740
1741    /// Force-cancel any active pane interaction and return diagnostic info.
1742    ///
1743    /// This is the safety-valve for cleanup paths (RAII guard drops, signal
1744    /// handlers, panic hooks) where constructing a proper semantic event is
1745    /// not feasible. It resets both the underlying drag/resize state machine
1746    /// and the adapter's active-pointer tracking.
1747    ///
1748    /// Returns `None` if no interaction was active.
1749    pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1750        let was_active = self.active.is_some();
1751        let machine_state_before = self.machine.state();
1752        let machine_transition = self.machine.force_cancel();
1753        let active_pointer = self.active.take();
1754        if !was_active && machine_transition.is_none() {
1755            return None;
1756        }
1757        Some(PaneCleanupDiagnostics {
1758            had_active_pointer: was_active,
1759            active_pointer_id: active_pointer.map(|a| a.pointer_id),
1760            machine_state_before,
1761            machine_transition,
1762        })
1763    }
1764}
1765
1766/// Structured diagnostics emitted when pane interaction state is force-cleaned.
1767///
1768/// Fields mirror the pane layout types which are already `Serialize`/`Deserialize`,
1769/// so callers can convert this struct to JSON for evidence logging.
1770#[derive(Debug, Clone, PartialEq, Eq)]
1771pub struct PaneCleanupDiagnostics {
1772    /// Whether the adapter had an active pointer tracker when cleanup ran.
1773    pub had_active_pointer: bool,
1774    /// The pointer ID that was active (if any).
1775    pub active_pointer_id: Option<u32>,
1776    /// The machine state before force-cancel was applied.
1777    pub machine_state_before: PaneDragResizeState,
1778    /// The transition produced by force-cancel, or `None` if the machine
1779    /// was already idle.
1780    pub machine_transition: Option<PaneDragResizeTransition>,
1781}
1782
1783/// RAII guard that ensures pane interaction state is cleanly canceled on drop.
1784///
1785/// When a pane interaction session is active and the guard drops (due to
1786/// panic, scope exit, or any other unwind), it force-cancels any in-progress
1787/// drag/resize and collects cleanup diagnostics.
1788///
1789/// # Usage
1790///
1791/// ```ignore
1792/// let guard = PaneInteractionGuard::new(&mut adapter);
1793/// // ... pane interaction event loop ...
1794/// // If this scope panics, guard's Drop will force-cancel the drag machine
1795/// let diagnostics = guard.finish(); // explicit clean finish
1796/// ```
1797pub struct PaneInteractionGuard<'a> {
1798    adapter: &'a mut PaneTerminalAdapter,
1799    finished: bool,
1800    diagnostics: Option<PaneCleanupDiagnostics>,
1801}
1802
1803impl<'a> PaneInteractionGuard<'a> {
1804    /// Create a new guard wrapping the given adapter.
1805    pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1806        Self {
1807            adapter,
1808            finished: false,
1809            diagnostics: None,
1810        }
1811    }
1812
1813    /// Access the wrapped adapter for normal event translation.
1814    pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1815        self.adapter
1816    }
1817
1818    /// Explicitly finish the guard, returning any cleanup diagnostics.
1819    ///
1820    /// Calling `finish()` is optional — the guard will also clean up on drop.
1821    /// However, `finish()` gives the caller access to the diagnostics.
1822    pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1823        self.finished = true;
1824        let diagnostics = self.adapter.force_cancel_all();
1825        self.diagnostics = diagnostics.clone();
1826        diagnostics
1827    }
1828}
1829
1830impl Drop for PaneInteractionGuard<'_> {
1831    fn drop(&mut self) {
1832        if !self.finished {
1833            self.diagnostics = self.adapter.force_cancel_all();
1834        }
1835    }
1836}
1837
1838fn pane_button(button: MouseButton) -> PanePointerButton {
1839    match button {
1840        MouseButton::Left => PanePointerButton::Primary,
1841        MouseButton::Right => PanePointerButton::Secondary,
1842        MouseButton::Middle => PanePointerButton::Middle,
1843    }
1844}
1845
1846fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1847    match button {
1848        PanePointerButton::Primary => 1,
1849        PanePointerButton::Secondary => 2,
1850        PanePointerButton::Middle => 3,
1851    }
1852}
1853
1854fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1855    PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1856}
1857
1858fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1859    PaneModifierSnapshot {
1860        shift: modifiers.contains(Modifiers::SHIFT),
1861        alt: modifiers.contains(Modifiers::ALT),
1862        ctrl: modifiers.contains(Modifiers::CTRL),
1863        meta: modifiers.contains(Modifiers::SUPER),
1864    }
1865}
1866
1867fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1868    match (axis, code) {
1869        (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1870        (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1871        (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1872        (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1873        (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1874        (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1875        _ => None,
1876    }
1877}
1878
1879fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1880    if modifiers.contains(Modifiers::SHIFT) {
1881        5
1882    } else {
1883        1
1884    }
1885}
1886
1887/// Configuration for state persistence in the program runtime.
1888///
1889/// Controls when and how widget state is saved/restored.
1890#[derive(Clone)]
1891pub struct PersistenceConfig {
1892    /// State registry for persistence. If None, persistence is disabled.
1893    pub registry: Option<std::sync::Arc<StateRegistry>>,
1894    /// Interval for periodic checkpoint saves. None disables checkpoints.
1895    pub checkpoint_interval: Option<Duration>,
1896    /// Automatically load state on program start.
1897    pub auto_load: bool,
1898    /// Automatically save state on program exit.
1899    pub auto_save: bool,
1900}
1901
1902impl Default for PersistenceConfig {
1903    fn default() -> Self {
1904        Self {
1905            registry: None,
1906            checkpoint_interval: None,
1907            auto_load: true,
1908            auto_save: true,
1909        }
1910    }
1911}
1912
1913impl std::fmt::Debug for PersistenceConfig {
1914    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1915        f.debug_struct("PersistenceConfig")
1916            .field(
1917                "registry",
1918                &self.registry.as_ref().map(|r| r.backend_name()),
1919            )
1920            .field("checkpoint_interval", &self.checkpoint_interval)
1921            .field("auto_load", &self.auto_load)
1922            .field("auto_save", &self.auto_save)
1923            .finish()
1924    }
1925}
1926
1927impl PersistenceConfig {
1928    /// Create a disabled persistence config.
1929    #[must_use]
1930    pub fn disabled() -> Self {
1931        Self::default()
1932    }
1933
1934    /// Create a persistence config with the given registry.
1935    #[must_use]
1936    pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1937        Self {
1938            registry: Some(registry),
1939            ..Default::default()
1940        }
1941    }
1942
1943    /// Set the checkpoint interval.
1944    #[must_use]
1945    pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1946        self.checkpoint_interval = Some(interval);
1947        self
1948    }
1949
1950    /// Enable or disable auto-load on start.
1951    #[must_use]
1952    pub fn auto_load(mut self, enabled: bool) -> Self {
1953        self.auto_load = enabled;
1954        self
1955    }
1956
1957    /// Enable or disable auto-save on exit.
1958    #[must_use]
1959    pub fn auto_save(mut self, enabled: bool) -> Self {
1960        self.auto_save = enabled;
1961        self
1962    }
1963}
1964
1965/// Configuration for widget refresh selection under render budget.
1966///
1967/// Defaults are conservative and deterministic:
1968/// - enabled: true
1969/// - staleness_window_ms: 1_000
1970/// - starve_ms: 3_000
1971/// - max_starved_per_frame: 2
1972/// - max_drop_fraction: 1.0 (disabled)
1973/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
1974/// - starve_boost: 1.5
1975/// - min_cost_us: 1.0
1976#[derive(Debug, Clone)]
1977pub struct WidgetRefreshConfig {
1978    /// Enable budgeted widget refresh selection.
1979    pub enabled: bool,
1980    /// Staleness decay window (ms) used to normalize staleness scores.
1981    pub staleness_window_ms: u64,
1982    /// Staleness threshold that triggers starvation guard (ms).
1983    pub starve_ms: u64,
1984    /// Maximum number of starved widgets to force in per frame.
1985    pub max_starved_per_frame: usize,
1986    /// Maximum fraction of non-essential widgets that may be dropped.
1987    /// Set to 1.0 to disable the guardrail.
1988    pub max_drop_fraction: f32,
1989    /// Weight for base priority signal.
1990    pub weight_priority: f32,
1991    /// Weight for staleness signal.
1992    pub weight_staleness: f32,
1993    /// Weight for focus boost.
1994    pub weight_focus: f32,
1995    /// Weight for interaction boost.
1996    pub weight_interaction: f32,
1997    /// Additive boost to value for starved widgets.
1998    pub starve_boost: f32,
1999    /// Minimum cost (us) to avoid divide-by-zero.
2000    pub min_cost_us: f32,
2001}
2002
2003impl Default for WidgetRefreshConfig {
2004    fn default() -> Self {
2005        Self {
2006            enabled: true,
2007            staleness_window_ms: 1_000,
2008            starve_ms: 3_000,
2009            max_starved_per_frame: 2,
2010            max_drop_fraction: 1.0,
2011            weight_priority: 1.0,
2012            weight_staleness: 0.5,
2013            weight_focus: 0.75,
2014            weight_interaction: 0.5,
2015            starve_boost: 1.5,
2016            min_cost_us: 1.0,
2017        }
2018    }
2019}
2020
2021/// Configuration for effect queue scheduling.
2022#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2023pub enum TaskExecutorBackend {
2024    /// Spawn one native thread per task and reap finished handles on the main loop.
2025    #[default]
2026    Spawned,
2027    /// Route tasks through the runtime's queueing scheduler.
2028    EffectQueue,
2029    /// Route blocking task closures through an Asupersync blocking pool.
2030    #[cfg(feature = "asupersync-executor")]
2031    Asupersync,
2032}
2033
2034#[derive(Debug, Clone)]
2035pub struct EffectQueueConfig {
2036    /// Whether effect queue scheduling is enabled.
2037    ///
2038    /// This legacy convenience flag is kept in sync with `backend`. New code
2039    /// should prefer `backend` for executor selection.
2040    pub enabled: bool,
2041    /// Which task executor backend to use for `Cmd::Task`.
2042    pub backend: TaskExecutorBackend,
2043    /// Scheduler configuration (Smith's rule by default).
2044    pub scheduler: SchedulerConfig,
2045    /// Maximum queue depth before backpressure kicks in (bd-2zd0a).
2046    ///
2047    /// When the queue depth exceeds this limit, new tasks are dropped with
2048    /// a `tracing::warn!` and the `effects_queue_dropped` counter increments.
2049    /// A value of `0` means unbounded (no backpressure).
2050    pub max_queue_depth: usize,
2051    /// Whether the backend selection was set explicitly by the caller.
2052    explicit_backend: bool,
2053}
2054
2055impl Default for EffectQueueConfig {
2056    fn default() -> Self {
2057        let scheduler = SchedulerConfig {
2058            smith_enabled: true,
2059            force_fifo: false,
2060            preemptive: false,
2061            aging_factor: 0.0,
2062            wait_starve_ms: 0.0,
2063            enable_logging: false,
2064            ..Default::default()
2065        };
2066        Self {
2067            enabled: false,
2068            backend: TaskExecutorBackend::Spawned,
2069            scheduler,
2070            max_queue_depth: 0,
2071            explicit_backend: false,
2072        }
2073    }
2074}
2075
2076impl EffectQueueConfig {
2077    /// Enable effect queue scheduling with the provided scheduler config.
2078    #[must_use]
2079    pub fn with_enabled(mut self, enabled: bool) -> Self {
2080        self.enabled = enabled;
2081        self.backend = if enabled {
2082            TaskExecutorBackend::EffectQueue
2083        } else {
2084            TaskExecutorBackend::Spawned
2085        };
2086        self.explicit_backend = true;
2087        self
2088    }
2089
2090    /// Select the task executor backend for `Cmd::Task`.
2091    #[must_use]
2092    pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2093        self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2094        self.backend = backend;
2095        self.explicit_backend = true;
2096        self
2097    }
2098
2099    /// Override the scheduler configuration.
2100    #[must_use]
2101    pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2102        self.scheduler = scheduler;
2103        self
2104    }
2105
2106    /// Set the maximum queue depth for backpressure (bd-2zd0a).
2107    ///
2108    /// When the queue depth exceeds this limit, new tasks are dropped.
2109    /// A value of `0` means unbounded (no backpressure, the default).
2110    #[must_use]
2111    pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2112        self.max_queue_depth = depth;
2113        self
2114    }
2115
2116    #[must_use]
2117    fn uses_legacy_default_backend(&self) -> bool {
2118        !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2119    }
2120}
2121
2122/// Immediate event-drain policy for the runtime main loop.
2123///
2124/// When a poll reports readiness, the runtime drains events by repeatedly
2125/// checking `poll_event(Duration::ZERO)` to avoid latency between buffered
2126/// inputs. This policy bounds that immediate-drain path so bursty workloads do
2127/// not devolve into zero-timeout spin storms.
2128#[derive(Debug, Clone)]
2129pub struct ImmediateDrainConfig {
2130    /// Maximum consecutive zero-timeout polls allowed in a single burst window.
2131    pub max_zero_timeout_polls_per_burst: usize,
2132    /// Maximum wall-clock time spent in a single immediate-drain burst window.
2133    pub max_burst_duration: Duration,
2134    /// Non-zero poll timeout used when the burst window budget is exhausted.
2135    pub backoff_timeout: Duration,
2136}
2137
2138impl Default for ImmediateDrainConfig {
2139    fn default() -> Self {
2140        Self {
2141            max_zero_timeout_polls_per_burst: 64,
2142            max_burst_duration: Duration::from_millis(2),
2143            backoff_timeout: Duration::from_millis(1),
2144        }
2145    }
2146}
2147
2148/// Runtime counters for immediate-drain behavior.
2149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2150pub struct ImmediateDrainStats {
2151    /// Number of event-drain bursts observed.
2152    pub bursts: u64,
2153    /// Total zero-timeout polls executed (`poll_event(Duration::ZERO)`).
2154    pub zero_timeout_polls: u64,
2155    /// Total non-zero backoff polls executed after exhausting burst budget.
2156    pub backoff_polls: u64,
2157    /// Number of bursts that hit the configured immediate-drain cap.
2158    pub capped_bursts: u64,
2159    /// Max number of zero-timeout polls seen in a single burst window.
2160    pub max_zero_timeout_polls_in_burst: u64,
2161}
2162
2163/// User-visible runtime mode reported by the conservative load governor.
2164///
2165/// These modes mirror the `bd-8vstx` runtime contract:
2166///
2167/// - `Healthy`: admit all work normally; no explicit fallback is active.
2168/// - `Stressed`: strict work is preserved while visible/coalescible or
2169///   background work may be slowed before user-visible ambiguity appears.
2170/// - `Degraded`: bounded fallback is active; strict interactive semantics
2171///   still hold while background/best-effort work is deferred or dropped.
2172/// - `Recovered`: a degraded interval has closed after the configured
2173///   hysteresis window; the next steady interval returns to `Healthy`.
2174/// - `Unsafe`: a strict semantic guarantee cannot be preserved, so optimistic
2175///   degradation must stop and the caller must surface explicit failure.
2176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2177pub enum RuntimeLoadMode {
2178    /// No sustained pressure and no active fallback.
2179    Healthy,
2180    /// Elevated pressure while strict guarantees still hold.
2181    Stressed,
2182    /// Explicit bounded fallback is active.
2183    Degraded,
2184    /// Recovery interval has been observed and reported.
2185    Recovered,
2186    /// A strict semantic guarantee was violated.
2187    Unsafe,
2188}
2189
2190impl RuntimeLoadMode {
2191    /// Stable string for evidence logs.
2192    #[inline]
2193    #[must_use]
2194    pub const fn as_str(self) -> &'static str {
2195        match self {
2196            Self::Healthy => "healthy",
2197            Self::Stressed => "stressed",
2198            Self::Degraded => "degraded",
2199            Self::Recovered => "recovered",
2200            Self::Unsafe => "unsafe",
2201        }
2202    }
2203}
2204
2205/// Pressure envelope inferred from measured runtime signals.
2206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2207pub enum RuntimePressureClass {
2208    /// Within steady-state envelope.
2209    SteadyState,
2210    /// Early overload band; bounded coalescing/deferment may begin.
2211    SoftOverload,
2212    /// Degraded band; explicit fallback is active.
2213    HardOverload,
2214    /// Terminal strict-semantics failure.
2215    Unsafe,
2216}
2217
2218impl RuntimePressureClass {
2219    /// Stable string for evidence logs.
2220    #[inline]
2221    #[must_use]
2222    pub const fn as_str(self) -> &'static str {
2223        match self {
2224            Self::SteadyState => "steady_state",
2225            Self::SoftOverload => "soft_overload",
2226            Self::HardOverload => "hard_overload",
2227            Self::Unsafe => "unsafe",
2228        }
2229    }
2230}
2231
2232/// Work disposition allowed by the current runtime mode.
2233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2234pub enum RuntimeWorkDisposition {
2235    /// Admit all work normally.
2236    AdmitAll,
2237    /// Preserve strict work; coalesce visible work and slow background work.
2238    CoalesceVisibleDeferBackground,
2239    /// Preserve strict work; defer background work and drop best-effort work.
2240    DeferBackgroundDropBestEffort,
2241    /// Re-admit deferred work after the hysteresis window closed.
2242    ReadmitAfterHysteresis,
2243    /// Stop optimistic degradation because a strict guarantee failed.
2244    FailFastStrictGuarantee,
2245}
2246
2247impl RuntimeWorkDisposition {
2248    /// Stable string for evidence logs.
2249    #[inline]
2250    #[must_use]
2251    pub const fn as_str(self) -> &'static str {
2252        match self {
2253            Self::AdmitAll => "admit_all",
2254            Self::CoalesceVisibleDeferBackground => "coalesce_visible_defer_background",
2255            Self::DeferBackgroundDropBestEffort => "defer_background_drop_best_effort",
2256            Self::ReadmitAfterHysteresis => "readmit_after_hysteresis",
2257            Self::FailFastStrictGuarantee => "fail_fast_strict_guarantee",
2258        }
2259    }
2260}
2261
2262/// Conservative policy for classifying runtime load.
2263///
2264/// The first runtime governor intentionally uses one primary control family:
2265/// render-budget degradation and explicit coalescing/admission evidence. Queue
2266/// watermarks are advisory inputs when a queue cap exists; when the queue is
2267/// uncapped, frame-budget and coalescer signals drive fallback classification.
2268#[derive(Debug, Clone, Copy, PartialEq)]
2269pub struct LoadGovernorPolicy {
2270    /// Queue occupancy ratio that enters `stressed`.
2271    pub stressed_queue_watermark: f64,
2272    /// Queue occupancy ratio that enters `degraded`.
2273    pub degraded_queue_watermark: f64,
2274    /// Queue occupancy ratio required before closing a degraded interval.
2275    pub recovery_queue_watermark: f64,
2276    /// Consecutive steady intervals required before reporting recovery.
2277    pub recovery_intervals: u8,
2278    /// Frame-time ratio over budget that enters `stressed` when uncapped.
2279    pub budget_overrun_soft_ratio: f64,
2280}
2281
2282impl Default for LoadGovernorPolicy {
2283    fn default() -> Self {
2284        Self {
2285            stressed_queue_watermark: 0.5,
2286            degraded_queue_watermark: 0.8,
2287            recovery_queue_watermark: 0.25,
2288            recovery_intervals: 3,
2289            budget_overrun_soft_ratio: 1.0,
2290        }
2291    }
2292}
2293
2294impl LoadGovernorPolicy {
2295    #[must_use]
2296    fn normalized(self) -> Self {
2297        let recovery = normalize_ratio(self.recovery_queue_watermark, 0.25);
2298        let stressed = normalize_ratio(self.stressed_queue_watermark, 0.5).max(recovery);
2299        let degraded = normalize_ratio(self.degraded_queue_watermark, 0.8).max(stressed);
2300        Self {
2301            recovery_queue_watermark: recovery,
2302            stressed_queue_watermark: stressed,
2303            degraded_queue_watermark: degraded,
2304            recovery_intervals: self.recovery_intervals.max(1),
2305            budget_overrun_soft_ratio: normalize_positive_ratio(
2306                self.budget_overrun_soft_ratio,
2307                1.0,
2308            ),
2309        }
2310    }
2311}
2312
2313#[inline]
2314fn normalize_ratio(value: f64, fallback: f64) -> f64 {
2315    if value.is_finite() {
2316        value.clamp(0.0, 1.0)
2317    } else {
2318        fallback
2319    }
2320}
2321
2322#[inline]
2323fn normalize_positive_ratio(value: f64, fallback: f64) -> f64 {
2324    if value.is_finite() && value > 0.0 {
2325        value
2326    } else {
2327        fallback
2328    }
2329}
2330
2331/// Conservative runtime load-governor configuration.
2332///
2333/// The first runtime governor keeps one primary control objective:
2334/// responsiveness under render pressure. It drives adaptive render degradation
2335/// through `RenderBudget`, classifies runtime mode with queue/coalescer inputs,
2336/// and emits replayable evidence for the allowed work disposition. Disabling
2337/// this config falls back to the legacy threshold path.
2338#[derive(Debug, Clone, PartialEq)]
2339pub struct LoadGovernorConfig {
2340    /// Whether the adaptive governor is active.
2341    pub enabled: bool,
2342    /// Controller used to decide degrade/upgrade transitions from frame timing.
2343    pub budget_controller: BudgetControllerConfig,
2344    /// Runtime-mode and work-disposition classification policy.
2345    pub policy: LoadGovernorPolicy,
2346}
2347
2348impl Default for LoadGovernorConfig {
2349    fn default() -> Self {
2350        Self {
2351            enabled: true,
2352            budget_controller: BudgetControllerConfig::default(),
2353            policy: LoadGovernorPolicy::default(),
2354        }
2355    }
2356}
2357
2358impl LoadGovernorConfig {
2359    /// Create an enabled governor with conservative defaults.
2360    #[must_use]
2361    pub fn enabled() -> Self {
2362        Self::default()
2363    }
2364
2365    /// Disable the governor and use the legacy render-budget threshold path.
2366    #[must_use]
2367    pub fn disabled() -> Self {
2368        Self {
2369            enabled: false,
2370            budget_controller: BudgetControllerConfig::default(),
2371            policy: LoadGovernorPolicy::default(),
2372        }
2373    }
2374
2375    /// Toggle governor activation.
2376    #[must_use]
2377    pub fn with_enabled(mut self, enabled: bool) -> Self {
2378        self.enabled = enabled;
2379        self
2380    }
2381
2382    /// Replace the adaptive budget controller configuration.
2383    #[must_use]
2384    pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
2385        self.budget_controller = config;
2386        self
2387    }
2388
2389    /// Replace the runtime-mode classification policy.
2390    #[must_use]
2391    pub fn with_policy(mut self, policy: LoadGovernorPolicy) -> Self {
2392        self.policy = policy.normalized();
2393        self
2394    }
2395}
2396
2397#[derive(Debug, Clone, Copy)]
2398struct LoadGovernorObservation {
2399    frame_time_us: f64,
2400    budget_us: f64,
2401    degradation: DegradationLevel,
2402    queue: crate::effect_system::QueueTelemetry,
2403    resize_coalescing_active: bool,
2404    strict_semantics_violation: bool,
2405}
2406
2407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2408struct LoadGovernorSnapshot {
2409    mode: RuntimeLoadMode,
2410    mode_before: RuntimeLoadMode,
2411    pressure_class: RuntimePressureClass,
2412    disposition: RuntimeWorkDisposition,
2413    reason_code: &'static str,
2414    transition: bool,
2415    strict_semantics_preserved: bool,
2416    queue_in_flight: u64,
2417    queue_max_depth: Option<usize>,
2418    queue_dropped_delta: u64,
2419    resize_coalescing_active: bool,
2420    recovery_intervals_observed: u8,
2421    recovery_intervals_required: u8,
2422    deferred_work_total: u64,
2423    coalesced_work_total: u64,
2424    dropped_work_total: u64,
2425}
2426
2427#[derive(Debug, Clone)]
2428struct LoadGovernorState {
2429    enabled: bool,
2430    policy: LoadGovernorPolicy,
2431    max_queue_depth: usize,
2432    mode: RuntimeLoadMode,
2433    recovery_intervals_observed: u8,
2434    last_queue_dropped: u64,
2435    queue_baseline_initialized: bool,
2436    deferred_work_total: u64,
2437    coalesced_work_total: u64,
2438    dropped_work_total: u64,
2439}
2440
2441impl LoadGovernorState {
2442    fn new(config: LoadGovernorConfig, max_queue_depth: usize) -> Self {
2443        Self {
2444            enabled: config.enabled,
2445            policy: config.policy.normalized(),
2446            max_queue_depth,
2447            mode: RuntimeLoadMode::Healthy,
2448            recovery_intervals_observed: 0,
2449            last_queue_dropped: 0,
2450            queue_baseline_initialized: false,
2451            deferred_work_total: 0,
2452            coalesced_work_total: 0,
2453            dropped_work_total: 0,
2454        }
2455    }
2456
2457    fn observe(&mut self, observation: LoadGovernorObservation) -> LoadGovernorSnapshot {
2458        if !self.enabled {
2459            return self.snapshot(
2460                RuntimeLoadMode::Healthy,
2461                RuntimeLoadMode::Healthy,
2462                RuntimePressureClass::SteadyState,
2463                RuntimeWorkDisposition::AdmitAll,
2464                "governor_disabled",
2465                false,
2466                true,
2467                observation,
2468                0,
2469            );
2470        }
2471
2472        let dropped_delta = self.queue_dropped_delta(observation.queue.dropped);
2473        let pressure = self.classify_pressure(observation, dropped_delta);
2474        let mode_before = self.mode;
2475        let reason_code = self.reason_code(observation, pressure, dropped_delta);
2476
2477        match pressure {
2478            RuntimePressureClass::Unsafe => {
2479                self.mode = RuntimeLoadMode::Unsafe;
2480                self.recovery_intervals_observed = 0;
2481            }
2482            // Strict-semantics failure is terminal: once Unsafe is entered the
2483            // governor latches there regardless of later pressure (this mirrors
2484            // the `Unsafe => {}` arm in `observe_steady_interval`, which already
2485            // refuses to recover Unsafe under steady load). Without this guard,
2486            // a subsequent overload interval would downgrade Unsafe to
2487            // Degraded/Stressed and then recover to Healthy, silently escaping
2488            // the fail-fast guarantee. Only an explicit reset clears Unsafe.
2489            _ if self.mode == RuntimeLoadMode::Unsafe => {
2490                self.recovery_intervals_observed = 0;
2491            }
2492            RuntimePressureClass::HardOverload => {
2493                self.mode = RuntimeLoadMode::Degraded;
2494                self.recovery_intervals_observed = 0;
2495            }
2496            RuntimePressureClass::SoftOverload => {
2497                if self.mode != RuntimeLoadMode::Degraded {
2498                    self.mode = RuntimeLoadMode::Stressed;
2499                }
2500                self.recovery_intervals_observed = 0;
2501            }
2502            RuntimePressureClass::SteadyState => self.observe_steady_interval(),
2503        }
2504
2505        self.record_work_disposition(observation, dropped_delta);
2506        let disposition = Self::disposition_for_mode(self.mode);
2507        self.snapshot(
2508            self.mode,
2509            mode_before,
2510            pressure,
2511            disposition,
2512            if self.mode == RuntimeLoadMode::Unsafe {
2513                // Whether freshly violated this interval or latched from a prior
2514                // one, the terminal cause is always the strict-semantics failure.
2515                "strict_semantics_violation"
2516            } else if self.mode == RuntimeLoadMode::Recovered {
2517                "recovery_hysteresis_satisfied"
2518            } else if mode_before == RuntimeLoadMode::Recovered
2519                && self.mode == RuntimeLoadMode::Healthy
2520            {
2521                "recovered_interval_closed"
2522            } else {
2523                reason_code
2524            },
2525            mode_before != self.mode,
2526            // Reflect the latched mode, not the instantaneous pressure: once the
2527            // governor is Unsafe, strict semantics stay unpreserved even on an
2528            // interval whose raw pressure classified lower.
2529            self.mode != RuntimeLoadMode::Unsafe,
2530            observation,
2531            dropped_delta,
2532        )
2533    }
2534
2535    fn queue_dropped_delta(&mut self, dropped_total: u64) -> u64 {
2536        if !self.queue_baseline_initialized {
2537            self.queue_baseline_initialized = true;
2538            self.last_queue_dropped = dropped_total;
2539            return 0;
2540        }
2541        let delta = dropped_total.saturating_sub(self.last_queue_dropped);
2542        self.last_queue_dropped = dropped_total;
2543        delta
2544    }
2545
2546    fn classify_pressure(
2547        &self,
2548        observation: LoadGovernorObservation,
2549        dropped_delta: u64,
2550    ) -> RuntimePressureClass {
2551        if observation.strict_semantics_violation {
2552            return RuntimePressureClass::Unsafe;
2553        }
2554        if dropped_delta > 0
2555            || self
2556                .queue_ratio(observation.queue.in_flight)
2557                .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark)
2558            || observation.degradation > DegradationLevel::Full
2559        {
2560            return RuntimePressureClass::HardOverload;
2561        }
2562        if self
2563            .queue_ratio(observation.queue.in_flight)
2564            .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark)
2565            || observation.resize_coalescing_active
2566            || observation.frame_time_us
2567                > observation.budget_us * self.policy.budget_overrun_soft_ratio
2568        {
2569            return RuntimePressureClass::SoftOverload;
2570        }
2571        RuntimePressureClass::SteadyState
2572    }
2573
2574    fn observe_steady_interval(&mut self) {
2575        match self.mode {
2576            RuntimeLoadMode::Degraded => {
2577                self.recovery_intervals_observed = self
2578                    .recovery_intervals_observed
2579                    .saturating_add(1)
2580                    .min(self.policy.recovery_intervals);
2581                if self.recovery_intervals_observed >= self.policy.recovery_intervals {
2582                    self.mode = RuntimeLoadMode::Recovered;
2583                }
2584            }
2585            RuntimeLoadMode::Recovered | RuntimeLoadMode::Stressed => {
2586                self.mode = RuntimeLoadMode::Healthy;
2587                self.recovery_intervals_observed = 0;
2588            }
2589            RuntimeLoadMode::Healthy => {
2590                self.recovery_intervals_observed = 0;
2591            }
2592            RuntimeLoadMode::Unsafe => {}
2593        }
2594    }
2595
2596    fn record_work_disposition(
2597        &mut self,
2598        observation: LoadGovernorObservation,
2599        dropped_delta: u64,
2600    ) {
2601        if dropped_delta > 0 {
2602            self.dropped_work_total = self.dropped_work_total.saturating_add(dropped_delta);
2603        }
2604        match self.mode {
2605            RuntimeLoadMode::Stressed => {
2606                if observation.resize_coalescing_active {
2607                    self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2608                }
2609            }
2610            RuntimeLoadMode::Degraded => {
2611                self.deferred_work_total = self.deferred_work_total.saturating_add(1);
2612                if observation.resize_coalescing_active {
2613                    self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2614                }
2615            }
2616            RuntimeLoadMode::Healthy | RuntimeLoadMode::Recovered | RuntimeLoadMode::Unsafe => {}
2617        }
2618    }
2619
2620    fn reason_code(
2621        &self,
2622        observation: LoadGovernorObservation,
2623        pressure: RuntimePressureClass,
2624        dropped_delta: u64,
2625    ) -> &'static str {
2626        match pressure {
2627            RuntimePressureClass::Unsafe => "strict_semantics_violation",
2628            RuntimePressureClass::HardOverload if dropped_delta > 0 => "effect_queue_drop",
2629            RuntimePressureClass::HardOverload
2630                if self
2631                    .queue_ratio(observation.queue.in_flight)
2632                    .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark) =>
2633            {
2634                "queue_degraded_watermark"
2635            }
2636            RuntimePressureClass::HardOverload => "budget_degradation_active",
2637            RuntimePressureClass::SoftOverload
2638                if self
2639                    .queue_ratio(observation.queue.in_flight)
2640                    .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark) =>
2641            {
2642                "queue_stressed_watermark"
2643            }
2644            RuntimePressureClass::SoftOverload if observation.resize_coalescing_active => {
2645                "resize_coalescing_active"
2646            }
2647            RuntimePressureClass::SoftOverload => "frame_budget_overrun",
2648            RuntimePressureClass::SteadyState if self.mode == RuntimeLoadMode::Degraded => {
2649                "recovery_hysteresis_pending"
2650            }
2651            RuntimePressureClass::SteadyState => "steady_state",
2652        }
2653    }
2654
2655    fn queue_ratio(&self, in_flight: u64) -> Option<f64> {
2656        (self.max_queue_depth > 0).then(|| in_flight as f64 / self.max_queue_depth as f64)
2657    }
2658
2659    const fn disposition_for_mode(mode: RuntimeLoadMode) -> RuntimeWorkDisposition {
2660        match mode {
2661            RuntimeLoadMode::Healthy => RuntimeWorkDisposition::AdmitAll,
2662            RuntimeLoadMode::Stressed => RuntimeWorkDisposition::CoalesceVisibleDeferBackground,
2663            RuntimeLoadMode::Degraded => RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
2664            RuntimeLoadMode::Recovered => RuntimeWorkDisposition::ReadmitAfterHysteresis,
2665            RuntimeLoadMode::Unsafe => RuntimeWorkDisposition::FailFastStrictGuarantee,
2666        }
2667    }
2668
2669    // Internal constructor that gathers all snapshot fields in one place; the
2670    // wide signature is intentional (a builder would add indirection for a
2671    // private helper). Pre-existing lint, unrelated to the #78 fix.
2672    #[allow(clippy::too_many_arguments)]
2673    fn snapshot(
2674        &self,
2675        mode: RuntimeLoadMode,
2676        mode_before: RuntimeLoadMode,
2677        pressure_class: RuntimePressureClass,
2678        disposition: RuntimeWorkDisposition,
2679        reason_code: &'static str,
2680        transition: bool,
2681        strict_semantics_preserved: bool,
2682        observation: LoadGovernorObservation,
2683        dropped_delta: u64,
2684    ) -> LoadGovernorSnapshot {
2685        LoadGovernorSnapshot {
2686            mode,
2687            mode_before,
2688            pressure_class,
2689            disposition,
2690            reason_code,
2691            transition,
2692            strict_semantics_preserved,
2693            queue_in_flight: observation.queue.in_flight,
2694            queue_max_depth: (self.max_queue_depth > 0).then_some(self.max_queue_depth),
2695            queue_dropped_delta: dropped_delta,
2696            resize_coalescing_active: observation.resize_coalescing_active,
2697            recovery_intervals_observed: self.recovery_intervals_observed,
2698            recovery_intervals_required: self.policy.recovery_intervals,
2699            deferred_work_total: self.deferred_work_total,
2700            coalesced_work_total: self.coalesced_work_total,
2701            dropped_work_total: self.dropped_work_total,
2702        }
2703    }
2704}
2705
2706/// Runtime lane for the Asupersync migration rollout.
2707///
2708/// Controls which subscription/effect execution backend is active.
2709/// The default is `Structured`, reflecting the completed CancellationToken migration (bd-3tmu4).
2710///
2711/// # Migration rollout
2712///
2713/// 1. `Legacy` — pre-migration thread-based subscriptions with manual stop coordination
2714/// 2. `Structured` — CancellationToken-backed subscriptions (current default after bd-3tmu4)
2715/// 3. `Asupersync` — full Asupersync-native execution (future)
2716///
2717/// Selection is logged at startup so operators can tell which lane is active.
2718/// Fallback from `Asupersync` → `Structured` → `Legacy` is automatic on error.
2719#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2720pub enum RuntimeLane {
2721    /// Pre-migration behavior: thread-based subscriptions with manual stop coordination.
2722    /// This is the safe default that preserves all existing semantics.
2723    Legacy,
2724    /// Structured cancellation: subscriptions use CancellationToken internally.
2725    /// Externally observable behavior is identical to Legacy.
2726    #[default]
2727    Structured,
2728    /// Full Asupersync-native execution (reserved for future use).
2729    /// Falls back to Structured if Asupersync primitives are unavailable.
2730    Asupersync,
2731}
2732
2733impl RuntimeLane {
2734    /// Resolve the effective lane, applying fallback rules.
2735    ///
2736    /// If the requested lane is not yet implemented, falls back to the
2737    /// highest available lane. Currently: Asupersync → Structured.
2738    #[must_use]
2739    pub fn resolve(self) -> Self {
2740        match self {
2741            Self::Asupersync => {
2742                tracing::info!(
2743                    target: "ftui.runtime",
2744                    requested = "asupersync",
2745                    resolved = "structured",
2746                    "Asupersync lane not yet available; falling back to structured cancellation"
2747                );
2748                Self::Structured
2749            }
2750            other => other,
2751        }
2752    }
2753
2754    /// Returns a human-readable label for logging.
2755    #[must_use]
2756    pub fn label(self) -> &'static str {
2757        match self {
2758            Self::Legacy => "legacy",
2759            Self::Structured => "structured",
2760            Self::Asupersync => "asupersync",
2761        }
2762    }
2763
2764    /// Check if this lane uses structured cancellation (CancellationToken).
2765    #[must_use]
2766    pub fn uses_structured_cancellation(self) -> bool {
2767        matches!(self, Self::Structured | Self::Asupersync)
2768    }
2769
2770    /// Resolve the default task executor backend for this lane.
2771    ///
2772    /// # Input-lag regression fix (#78)
2773    ///
2774    /// The `Structured` lane changes *subscription cancellation* semantics
2775    /// (CancellationToken-backed stop signals in `subscription.rs`); it must
2776    /// NOT also collapse `Cmd::Task` concurrency. Earlier this returned
2777    /// `EffectQueue`, which routed every `Cmd::Task` through a single
2778    /// `effect_queue_loop` worker thread with a Smith's-rule (SPT) scheduler,
2779    /// serializing all tasks. Apps that forward PTY output via per-pane
2780    /// `Cmd::task` polling loops (e.g. 10ms drains) then contended for one
2781    /// serialized worker, adding per-keystroke head-of-line latency versus
2782    /// v0.2.1, where each `Cmd::task` got its own `std::thread::spawn`.
2783    ///
2784    /// Structured cancellation does not depend on the effect queue (stop
2785    /// signals are token-backed regardless of executor backend), so the two
2786    /// concerns are decoupled here: `Structured` keeps the structured
2787    /// cancellation semantics but restores per-task-thread (`Spawned`)
2788    /// execution. Apps that explicitly opt into `EffectQueue` still get it
2789    /// (the lane default only applies when the app uses the legacy default
2790    /// backend — see `EffectQueueConfig::uses_legacy_default_backend`).
2791    #[must_use]
2792    fn task_executor_backend(self) -> TaskExecutorBackend {
2793        match self {
2794            // Legacy and Structured both use per-task `Spawned` execution.
2795            // (Structured only changes cancellation semantics, not concurrency
2796            // — see the regression note above.) Kept as a combined arm so the
2797            // `match_same_arms` lint stays happy under `-D warnings`.
2798            Self::Legacy | Self::Structured => TaskExecutorBackend::Spawned,
2799            Self::Asupersync => {
2800                #[cfg(feature = "asupersync-executor")]
2801                {
2802                    TaskExecutorBackend::Asupersync
2803                }
2804                #[cfg(not(feature = "asupersync-executor"))]
2805                {
2806                    TaskExecutorBackend::EffectQueue
2807                }
2808            }
2809        }
2810    }
2811
2812    /// Read the lane from the `FTUI_RUNTIME_LANE` environment variable.
2813    ///
2814    /// Accepted values (case-insensitive): `legacy`, `structured`, `asupersync`.
2815    /// Returns `None` if the variable is unset or contains an unrecognized value.
2816    #[must_use]
2817    pub fn from_env() -> Option<Self> {
2818        let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2819        Self::parse(&val)
2820    }
2821
2822    /// Parse a lane name (case-insensitive).
2823    ///
2824    /// Returns `None` for unrecognized values.
2825    #[must_use]
2826    pub fn parse(s: &str) -> Option<Self> {
2827        match s.to_ascii_lowercase().as_str() {
2828            "legacy" => Some(Self::Legacy),
2829            "structured" => Some(Self::Structured),
2830            "asupersync" => Some(Self::Asupersync),
2831            _ => {
2832                tracing::warn!(
2833                    target: "ftui.runtime",
2834                    value = s,
2835                    "RuntimeLane::parse: unrecognized value"
2836                );
2837                None
2838            }
2839        }
2840    }
2841}
2842
2843/// Rollout policy for the Asupersync migration (bd-2crbt).
2844///
2845/// Controls how the runtime lane transition is managed:
2846///
2847/// - `Off` — use only the configured lane, no shadow comparison.
2848/// - `Shadow` — run both baseline and candidate lanes, compare outputs,
2849///   but use only the baseline lane for actual rendering. Evidence is emitted
2850///   to the configured JSONL sink for operator review.
2851/// - `Enabled` — use the candidate lane for rendering (requires prior shadow
2852///   evidence showing deterministic match).
2853///
2854/// The policy is logged at startup and can be overridden via the
2855/// `FTUI_ROLLOUT_POLICY` environment variable (`off`, `shadow`, `enabled`).
2856#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2857pub enum RolloutPolicy {
2858    /// No rollout activity — use the configured lane directly.
2859    #[default]
2860    Off,
2861    /// Shadow-run comparison mode: run both lanes, emit evidence, use baseline.
2862    Shadow,
2863    /// Candidate lane is live — requires prior shadow evidence.
2864    Enabled,
2865}
2866
2867impl RolloutPolicy {
2868    /// Read the policy from the `FTUI_ROLLOUT_POLICY` environment variable.
2869    ///
2870    /// Accepted values (case-insensitive): `off`, `shadow`, `enabled`.
2871    /// Returns `None` if unset or unrecognized.
2872    #[must_use]
2873    pub fn from_env() -> Option<Self> {
2874        let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2875        Self::parse(&val)
2876    }
2877
2878    /// Parse a rollout policy name (case-insensitive).
2879    ///
2880    /// Returns `None` for unrecognized values.
2881    #[must_use]
2882    pub fn parse(s: &str) -> Option<Self> {
2883        match s.to_ascii_lowercase().as_str() {
2884            "off" => Some(Self::Off),
2885            "shadow" => Some(Self::Shadow),
2886            "enabled" => Some(Self::Enabled),
2887            _ => {
2888                tracing::warn!(
2889                    target: "ftui.runtime",
2890                    value = s,
2891                    "RolloutPolicy::parse: unrecognized value"
2892                );
2893                None
2894            }
2895        }
2896    }
2897
2898    /// Returns a human-readable label for logging.
2899    #[must_use]
2900    pub fn label(self) -> &'static str {
2901        match self {
2902            Self::Off => "off",
2903            Self::Shadow => "shadow",
2904            Self::Enabled => "enabled",
2905        }
2906    }
2907
2908    /// Whether this policy involves shadow comparison.
2909    #[must_use]
2910    pub fn is_shadow(self) -> bool {
2911        matches!(self, Self::Shadow)
2912    }
2913}
2914
2915impl std::fmt::Display for RolloutPolicy {
2916    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2917        f.write_str(self.label())
2918    }
2919}
2920
2921impl std::fmt::Display for RuntimeLane {
2922    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2923        f.write_str(self.label())
2924    }
2925}
2926
2927/// Configuration for the program runtime.
2928#[derive(Debug, Clone)]
2929pub struct ProgramConfig {
2930    /// Screen mode (inline or alternate screen).
2931    pub screen_mode: ScreenMode,
2932    /// UI anchor for inline mode.
2933    pub ui_anchor: UiAnchor,
2934    /// Frame budget configuration.
2935    pub budget: FrameBudgetConfig,
2936    /// Runtime load-governor configuration.
2937    pub load_governor: LoadGovernorConfig,
2938    /// Diff strategy configuration for the terminal writer.
2939    pub diff_config: RuntimeDiffConfig,
2940    /// Evidence JSONL sink configuration.
2941    pub evidence_sink: EvidenceSinkConfig,
2942    /// Render-trace recorder configuration.
2943    pub render_trace: RenderTraceConfig,
2944    /// Optional frame timing sink.
2945    pub frame_timing: Option<FrameTimingConfig>,
2946    /// Conformal predictor configuration for frame-time risk gating.
2947    pub conformal_config: Option<ConformalConfig>,
2948    /// Locale context used for rendering.
2949    pub locale_context: LocaleContext,
2950    /// Input poll timeout.
2951    pub poll_timeout: Duration,
2952    /// Immediate event-drain policy for burst handling.
2953    pub immediate_drain: ImmediateDrainConfig,
2954    /// Resize coalescer configuration.
2955    pub resize_coalescer: CoalescerConfig,
2956    /// Resize handling behavior (immediate/throttled).
2957    pub resize_behavior: ResizeBehavior,
2958    /// Forced terminal size override (when set, resize events are ignored).
2959    pub forced_size: Option<(u16, u16)>,
2960    /// Mouse capture policy (`Auto`, `On`, `Off`).
2961    ///
2962    /// `Auto` is inline-safe: off in inline modes, on in alt-screen mode.
2963    pub mouse_capture_policy: MouseCapturePolicy,
2964    /// Enable bracketed paste.
2965    pub bracketed_paste: bool,
2966    /// Enable focus reporting.
2967    pub focus_reporting: bool,
2968    /// Enable Kitty keyboard protocol (repeat/release events).
2969    pub kitty_keyboard: bool,
2970    /// State persistence configuration.
2971    pub persistence: PersistenceConfig,
2972    /// Inline auto UI height remeasurement policy.
2973    pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2974    /// Widget refresh selection configuration.
2975    pub widget_refresh: WidgetRefreshConfig,
2976    /// Effect queue scheduling configuration.
2977    pub effect_queue: EffectQueueConfig,
2978    /// Frame guardrails configuration (memory + queue safety limits).
2979    pub guardrails: GuardrailsConfig,
2980    /// Install signal handlers for cleanup on SIGINT/SIGTERM/SIGHUP.
2981    ///
2982    /// Defaults to `true` for application safety. Set to `false` in tests or
2983    /// when the embedding application manages signals.
2984    pub intercept_signals: bool,
2985    /// Optional tick strategy for selective background screen ticking.
2986    ///
2987    /// When `None` (default), all screens tick every frame (current behavior).
2988    /// When set, the runtime consults the strategy for each inactive screen.
2989    pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2990    /// Runtime execution lane for the Asupersync migration rollout.
2991    ///
2992    /// Controls which subscription/effect backend is active.
2993    /// Defaults to `Structured` (CancellationToken-backed, current migration state).
2994    /// Logged at startup so operators can identify the active lane.
2995    pub runtime_lane: RuntimeLane,
2996    /// Rollout policy for the Asupersync migration (bd-2crbt).
2997    ///
2998    /// Controls whether shadow-run comparison is active during this session.
2999    /// When `Shadow`, both the baseline and candidate lanes run in parallel
3000    /// and evidence is emitted; rendering uses the baseline lane only.
3001    pub rollout_policy: RolloutPolicy,
3002}
3003
3004impl Default for ProgramConfig {
3005    fn default() -> Self {
3006        Self {
3007            screen_mode: ScreenMode::Inline { ui_height: 4 },
3008            ui_anchor: UiAnchor::Bottom,
3009            budget: FrameBudgetConfig::default(),
3010            load_governor: LoadGovernorConfig::default(),
3011            diff_config: RuntimeDiffConfig::default(),
3012            evidence_sink: EvidenceSinkConfig::default(),
3013            render_trace: RenderTraceConfig::default(),
3014            frame_timing: None,
3015            conformal_config: None,
3016            locale_context: LocaleContext::global(),
3017            poll_timeout: Duration::from_millis(100),
3018            immediate_drain: ImmediateDrainConfig::default(),
3019            resize_coalescer: CoalescerConfig::default(),
3020            resize_behavior: ResizeBehavior::Throttled,
3021            forced_size: None,
3022            mouse_capture_policy: MouseCapturePolicy::Auto,
3023            bracketed_paste: true,
3024            focus_reporting: false,
3025            kitty_keyboard: false,
3026            persistence: PersistenceConfig::default(),
3027            inline_auto_remeasure: None,
3028            widget_refresh: WidgetRefreshConfig::default(),
3029            effect_queue: EffectQueueConfig::default(),
3030            guardrails: GuardrailsConfig::default(),
3031            intercept_signals: true,
3032            tick_strategy: None,
3033            runtime_lane: RuntimeLane::default(),
3034            rollout_policy: RolloutPolicy::default(),
3035        }
3036    }
3037}
3038
3039impl ProgramConfig {
3040    /// Create config for fullscreen applications.
3041    pub fn fullscreen() -> Self {
3042        Self {
3043            screen_mode: ScreenMode::AltScreen,
3044            ..Default::default()
3045        }
3046    }
3047
3048    /// Create config for inline mode with specified height.
3049    pub fn inline(height: u16) -> Self {
3050        Self {
3051            screen_mode: ScreenMode::Inline { ui_height: height },
3052            ..Default::default()
3053        }
3054    }
3055
3056    /// Create config for inline mode with automatic UI height.
3057    pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
3058        Self {
3059            screen_mode: ScreenMode::InlineAuto {
3060                min_height,
3061                max_height,
3062            },
3063            inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
3064            ..Default::default()
3065        }
3066    }
3067
3068    /// Enable mouse support.
3069    #[must_use]
3070    pub fn with_mouse(mut self) -> Self {
3071        self.mouse_capture_policy = MouseCapturePolicy::On;
3072        self
3073    }
3074
3075    /// Set mouse capture policy.
3076    #[must_use]
3077    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
3078        self.mouse_capture_policy = policy;
3079        self
3080    }
3081
3082    /// Force mouse capture enabled/disabled regardless of screen mode.
3083    #[must_use]
3084    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
3085        self.mouse_capture_policy = if enabled {
3086            MouseCapturePolicy::On
3087        } else {
3088            MouseCapturePolicy::Off
3089        };
3090        self
3091    }
3092
3093    /// Resolve mouse capture using the configured policy and screen mode.
3094    #[must_use]
3095    pub const fn resolved_mouse_capture(&self) -> bool {
3096        self.mouse_capture_policy.resolve(self.screen_mode)
3097    }
3098
3099    /// Set the budget configuration.
3100    #[must_use]
3101    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
3102        self.budget = budget;
3103        self
3104    }
3105
3106    /// Set the runtime load-governor configuration.
3107    #[must_use]
3108    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
3109        self.load_governor = config;
3110        self
3111    }
3112
3113    /// Disable the adaptive load governor and use legacy render-budget behavior.
3114    #[must_use]
3115    pub fn without_load_governor(mut self) -> Self {
3116        self.load_governor = LoadGovernorConfig::disabled();
3117        self
3118    }
3119
3120    /// Set the diff strategy configuration for the terminal writer.
3121    #[must_use]
3122    pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
3123        self.diff_config = diff_config;
3124        self
3125    }
3126
3127    /// Set the evidence JSONL sink configuration.
3128    #[must_use]
3129    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
3130        self.evidence_sink = config;
3131        self
3132    }
3133
3134    /// Set the render-trace recorder configuration.
3135    #[must_use]
3136    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
3137        self.render_trace = config;
3138        self
3139    }
3140
3141    /// Set a frame timing sink for per-frame profiling.
3142    #[must_use]
3143    pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
3144        self.frame_timing = Some(config);
3145        self
3146    }
3147
3148    /// Enable conformal frame-time risk gating with the given config.
3149    #[must_use]
3150    pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
3151        self.conformal_config = Some(config);
3152        self
3153    }
3154
3155    /// Disable conformal frame-time risk gating.
3156    #[must_use]
3157    pub fn without_conformal(mut self) -> Self {
3158        self.conformal_config = None;
3159        self
3160    }
3161
3162    /// Set the locale context used for rendering.
3163    #[must_use]
3164    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
3165        self.locale_context = locale_context;
3166        self
3167    }
3168
3169    /// Set the base locale used for rendering.
3170    #[must_use]
3171    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
3172        self.locale_context = LocaleContext::new(locale);
3173        self
3174    }
3175
3176    /// Set the widget refresh selection configuration.
3177    #[must_use]
3178    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
3179        self.widget_refresh = config;
3180        self
3181    }
3182
3183    /// Set the effect queue scheduling configuration.
3184    #[must_use]
3185    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
3186        self.effect_queue = config;
3187        self
3188    }
3189
3190    /// Set the resize coalescer configuration.
3191    #[must_use]
3192    pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
3193        self.resize_coalescer = config;
3194        self
3195    }
3196
3197    /// Set the resize handling behavior.
3198    #[must_use]
3199    pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
3200        self.resize_behavior = behavior;
3201        self
3202    }
3203
3204    /// Force a fixed terminal size (cols, rows). Resize events are ignored.
3205    #[must_use]
3206    pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
3207        let width = width.max(1);
3208        let height = height.max(1);
3209        self.forced_size = Some((width, height));
3210        self
3211    }
3212
3213    /// Clear any forced terminal size override.
3214    #[must_use]
3215    pub fn without_forced_size(mut self) -> Self {
3216        self.forced_size = None;
3217        self
3218    }
3219
3220    /// Toggle legacy immediate-resize behavior for migration.
3221    #[must_use]
3222    pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
3223        if enabled {
3224            self.resize_behavior = ResizeBehavior::Immediate;
3225        }
3226        self
3227    }
3228
3229    /// Set the persistence configuration.
3230    #[must_use]
3231    pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
3232        self.persistence = persistence;
3233        self
3234    }
3235
3236    /// Enable persistence with the given registry.
3237    #[must_use]
3238    pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
3239        self.persistence = PersistenceConfig::with_registry(registry);
3240        self
3241    }
3242
3243    /// Enable inline auto UI height remeasurement with the given policy.
3244    #[must_use]
3245    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
3246        self.inline_auto_remeasure = Some(config);
3247        self
3248    }
3249
3250    /// Disable inline auto UI height remeasurement.
3251    #[must_use]
3252    pub fn without_inline_auto_remeasure(mut self) -> Self {
3253        self.inline_auto_remeasure = None;
3254        self
3255    }
3256
3257    /// Enable or disable signal interception (SIGHUP/SIGTERM/SIGINT) for cleanup.
3258    #[must_use]
3259    pub fn with_signal_interception(mut self, enabled: bool) -> Self {
3260        self.intercept_signals = enabled;
3261        self
3262    }
3263
3264    /// Set frame guardrails configuration.
3265    #[must_use]
3266    pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
3267        self.guardrails = config;
3268        self
3269    }
3270
3271    /// Set the immediate event-drain policy for burst handling.
3272    #[must_use]
3273    pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
3274        self.immediate_drain = config;
3275        self
3276    }
3277
3278    /// Set the tick strategy for selective background screen ticking.
3279    ///
3280    /// When set, the runtime consults the strategy to decide which inactive
3281    /// screens should tick on each frame. Without a strategy, all screens
3282    /// tick every frame (backwards-compatible default).
3283    ///
3284    /// ```ignore
3285    /// ProgramConfig::default()
3286    ///     .with_tick_strategy(TickStrategyKind::Uniform { divisor: 5 })
3287    /// ```
3288    #[must_use]
3289    pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
3290        self.tick_strategy = Some(strategy);
3291        self
3292    }
3293
3294    /// Set the runtime execution lane.
3295    #[must_use]
3296    pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
3297        self.runtime_lane = lane;
3298        self
3299    }
3300
3301    /// Set the rollout policy for the Asupersync migration.
3302    #[must_use]
3303    pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
3304        self.rollout_policy = policy;
3305        self
3306    }
3307
3308    /// Apply environment-variable overrides for lane and rollout policy.
3309    ///
3310    /// Reads `FTUI_RUNTIME_LANE` and `FTUI_ROLLOUT_POLICY`. Unset variables
3311    /// are ignored. Unrecognized values emit a `tracing::warn` and are
3312    /// ignored (the programmatic default or prior builder value is retained).
3313    #[must_use]
3314    pub fn with_env_overrides(mut self) -> Self {
3315        if let Some(lane) = RuntimeLane::from_env() {
3316            self.runtime_lane = lane;
3317        }
3318        if let Some(policy) = RolloutPolicy::from_env() {
3319            self.rollout_policy = policy;
3320        }
3321        self
3322    }
3323
3324    #[must_use]
3325    fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
3326        if !self.effect_queue.uses_legacy_default_backend() {
3327            return self.effect_queue.clone();
3328        }
3329
3330        self.effect_queue
3331            .clone()
3332            .with_backend(self.runtime_lane.resolve().task_executor_backend())
3333    }
3334}
3335
3336fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
3337    let budget = RenderBudget::from_config(&config.budget);
3338    if config.load_governor.enabled {
3339        let mut controller = config.load_governor.budget_controller.clone();
3340        controller.target = config.budget.total;
3341        budget.with_controller(controller)
3342    } else {
3343        budget
3344    }
3345}
3346
3347enum EffectCommand<M> {
3348    Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
3349    Shutdown,
3350}
3351
3352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3353enum EffectLoopControl {
3354    Continue,
3355    ShutdownRequested,
3356}
3357
3358struct EffectQueue<M: Send + 'static> {
3359    sender: mpsc::Sender<EffectCommand<M>>,
3360    handle: Option<JoinHandle<()>>,
3361    closed: bool,
3362}
3363
3364impl<M: Send + 'static> EffectQueue<M> {
3365    fn start(
3366        config: EffectQueueConfig,
3367        result_sender: mpsc::Sender<M>,
3368        evidence_sink: Option<EvidenceSink>,
3369    ) -> io::Result<Self> {
3370        let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
3371        let handle = thread::Builder::new()
3372            .name("ftui-effects".into())
3373            .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
3374
3375        Ok(Self {
3376            sender: tx,
3377            handle: Some(handle),
3378            closed: false,
3379        })
3380    }
3381
3382    fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3383        if self.closed {
3384            crate::effect_system::record_queue_drop("post_shutdown");
3385            tracing::debug!("rejecting task enqueue after effect queue shutdown");
3386            return;
3387        }
3388        if self
3389            .sender
3390            .send(EffectCommand::Enqueue(spec, task))
3391            .is_err()
3392        {
3393            crate::effect_system::record_queue_drop("channel_closed");
3394        }
3395    }
3396
3397    /// Timeout for the effect-queue thread to finish after sending Shutdown.
3398    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3399    /// Poll interval when waiting for the effect-queue thread (bd-170o5).
3400    ///
3401    /// This sleep-poll pattern is the idiomatic Rust approach for bounded
3402    /// thread joins — `JoinHandle` has no `join_timeout` in stable Rust.
3403    /// 1ms is chosen to minimize shutdown latency while avoiding spin.
3404    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3405
3406    fn shutdown(&mut self) {
3407        self.closed = true;
3408        let _ = self.sender.send(EffectCommand::Shutdown);
3409        if let Some(handle) = self.handle.take() {
3410            let start = Instant::now();
3411            // Fast path: most shutdowns complete nearly instantly after the
3412            // Shutdown command is drained. Check once before entering poll loop.
3413            if handle.is_finished() {
3414                let _ = handle.join();
3415                let elapsed_us = start.elapsed().as_micros() as u64;
3416                tracing::debug!(
3417                    target: "ftui.runtime",
3418                    elapsed_us,
3419                    "effect-queue shutdown (fast path)"
3420                );
3421                return;
3422            }
3423            // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
3424            while !handle.is_finished() {
3425                if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3426                    tracing::warn!(
3427                        target: "ftui.runtime",
3428                        timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3429                        "effect-queue thread did not stop within timeout; detaching"
3430                    );
3431                    return;
3432                }
3433                thread::sleep(Self::SHUTDOWN_POLL);
3434            }
3435            let _ = handle.join();
3436            let elapsed_us = start.elapsed().as_micros() as u64;
3437            tracing::debug!(
3438                target: "ftui.runtime",
3439                elapsed_us,
3440                "effect-queue shutdown (slow path)"
3441            );
3442        }
3443    }
3444}
3445
3446impl<M: Send + 'static> Drop for EffectQueue<M> {
3447    fn drop(&mut self) {
3448        self.shutdown();
3449    }
3450}
3451
3452struct SpawnTaskExecutor<M: Send + 'static> {
3453    result_sender: mpsc::Sender<M>,
3454    evidence_sink: Option<EvidenceSink>,
3455    handles: Vec<JoinHandle<()>>,
3456    closed: bool,
3457}
3458
3459impl<M: Send + 'static> SpawnTaskExecutor<M> {
3460    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3461    /// Poll interval for bounded thread joins (bd-170o5).
3462    ///
3463    /// Same rationale as `EffectQueue::SHUTDOWN_POLL` — `JoinHandle` has no
3464    /// `join_timeout` in stable Rust, so we poll `is_finished()` with a
3465    /// 1ms sleep to minimize shutdown latency while avoiding spin.
3466    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3467
3468    fn new(result_sender: mpsc::Sender<M>, evidence_sink: Option<EvidenceSink>) -> Self {
3469        Self {
3470            result_sender,
3471            evidence_sink,
3472            handles: Vec::new(),
3473            closed: false,
3474        }
3475    }
3476
3477    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3478        if self.closed {
3479            tracing::debug!("rejecting spawned task submit after shutdown");
3480            return;
3481        }
3482        let sender = self.result_sender.clone();
3483        let evidence_sink = self.evidence_sink.clone();
3484        let handle = thread::spawn(move || {
3485            let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
3486        });
3487        self.handles.push(handle);
3488    }
3489
3490    fn reap_finished(&mut self) {
3491        if self.handles.is_empty() {
3492            return;
3493        }
3494
3495        let mut i = 0;
3496        while i < self.handles.len() {
3497            if self.handles[i].is_finished() {
3498                let handle = self.handles.swap_remove(i);
3499                let _ = handle.join();
3500            } else {
3501                i += 1;
3502            }
3503        }
3504    }
3505
3506    fn shutdown(&mut self) {
3507        self.closed = true;
3508        let start = Instant::now();
3509        // Fast path: reap any already-finished handles first.
3510        self.reap_finished();
3511        if self.handles.is_empty() {
3512            let elapsed_us = start.elapsed().as_micros() as u64;
3513            tracing::debug!(
3514                target: "ftui.runtime",
3515                elapsed_us,
3516                "spawn-executor shutdown (fast path, all tasks already finished)"
3517            );
3518            return;
3519        }
3520        // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
3521        let pending_at_start = self.handles.len();
3522        while self.handles.iter().any(|handle| !handle.is_finished()) {
3523            if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3524                let still_pending = self
3525                    .handles
3526                    .iter()
3527                    .filter(|handle| !handle.is_finished())
3528                    .count();
3529                tracing::warn!(
3530                    target: "ftui.runtime",
3531                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3532                    pending_handles = still_pending,
3533                    "background task threads did not stop within timeout; detaching"
3534                );
3535                self.handles.clear();
3536                return;
3537            }
3538            thread::sleep(Self::SHUTDOWN_POLL);
3539        }
3540        self.reap_finished();
3541        let elapsed_us = start.elapsed().as_micros() as u64;
3542        tracing::debug!(
3543            target: "ftui.runtime",
3544            elapsed_us,
3545            pending_at_start,
3546            "spawn-executor shutdown (slow path)"
3547        );
3548    }
3549}
3550
3551#[cfg(feature = "asupersync-executor")]
3552struct AsupersyncTaskExecutor<M: Send + 'static> {
3553    result_sender: mpsc::Sender<M>,
3554    evidence_sink: Option<EvidenceSink>,
3555    runtime: AsupersyncRuntime,
3556    handles: Vec<BlockingTaskHandle>,
3557    closed: bool,
3558}
3559
3560#[cfg(feature = "asupersync-executor")]
3561impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
3562    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3563
3564    fn new(
3565        result_sender: mpsc::Sender<M>,
3566        evidence_sink: Option<EvidenceSink>,
3567    ) -> io::Result<Self> {
3568        let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
3569        let runtime = RuntimeBuilder::new()
3570            .blocking_threads(1, max_threads)
3571            .thread_name_prefix("ftui-asupersync-task")
3572            .build()
3573            .map_err(|error| {
3574                io::Error::other(format!("asupersync runtime init failed: {error}"))
3575            })?;
3576
3577        Ok(Self {
3578            result_sender,
3579            evidence_sink,
3580            runtime,
3581            handles: Vec::new(),
3582            closed: false,
3583        })
3584    }
3585
3586    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3587        if self.closed {
3588            tracing::debug!("rejecting asupersync task submit after shutdown");
3589            return;
3590        }
3591        let sender = self.result_sender.clone();
3592        let evidence_sink = self.evidence_sink.clone();
3593        let handle = self
3594            .runtime
3595            .spawn_blocking(move || {
3596                let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3597            })
3598            .expect("asupersync blocking pool must be configured");
3599        self.handles.push(handle);
3600    }
3601
3602    fn reap_finished(&mut self) {
3603        self.handles.retain(|handle| !handle.is_done());
3604    }
3605
3606    fn shutdown(&mut self) {
3607        self.closed = true;
3608        let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3609        for handle in &self.handles {
3610            let remaining = deadline.saturating_duration_since(Instant::now());
3611            if remaining.is_zero() || !handle.wait_timeout(remaining) {
3612                tracing::warn!(
3613                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3614                    pending_handles = self
3615                        .handles
3616                        .iter()
3617                        .filter(|pending| !pending.is_done())
3618                        .count(),
3619                    "Asupersync blocking tasks did not stop within timeout; detaching"
3620                );
3621                self.handles.clear();
3622                return;
3623            }
3624        }
3625        self.handles.clear();
3626    }
3627}
3628
3629enum TaskExecutor<M: Send + 'static> {
3630    Spawned(SpawnTaskExecutor<M>),
3631    Queued(EffectQueue<M>),
3632    #[cfg(feature = "asupersync-executor")]
3633    Asupersync(AsupersyncTaskExecutor<M>),
3634}
3635
3636impl<M: Send + 'static> TaskExecutor<M> {
3637    fn new(
3638        config: &EffectQueueConfig,
3639        result_sender: mpsc::Sender<M>,
3640        evidence_sink: Option<EvidenceSink>,
3641    ) -> io::Result<Self> {
3642        let executor = match config.backend {
3643            TaskExecutorBackend::Spawned => {
3644                Self::Spawned(SpawnTaskExecutor::new(result_sender, evidence_sink.clone()))
3645            }
3646            TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3647                config.clone(),
3648                result_sender,
3649                evidence_sink.clone(),
3650            )?),
3651            #[cfg(feature = "asupersync-executor")]
3652            TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3653                result_sender,
3654                evidence_sink.clone(),
3655            )?),
3656        };
3657
3658        emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3659        Ok(executor)
3660    }
3661
3662    fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3663        match self {
3664            Self::Spawned(executor) => executor.submit(task),
3665            Self::Queued(queue) => queue.enqueue(spec, task),
3666            #[cfg(feature = "asupersync-executor")]
3667            Self::Asupersync(executor) => executor.submit(task),
3668        }
3669    }
3670
3671    fn reap_finished(&mut self) {
3672        match self {
3673            Self::Spawned(executor) => executor.reap_finished(),
3674            #[cfg(feature = "asupersync-executor")]
3675            Self::Asupersync(executor) => executor.reap_finished(),
3676            Self::Queued(_) => {}
3677        }
3678    }
3679
3680    fn shutdown(&mut self) {
3681        match self {
3682            Self::Spawned(executor) => executor.shutdown(),
3683            Self::Queued(queue) => queue.shutdown(),
3684            #[cfg(feature = "asupersync-executor")]
3685            Self::Asupersync(executor) => executor.shutdown(),
3686        }
3687    }
3688
3689    #[cfg(test)]
3690    fn kind_name(&self) -> &'static str {
3691        self.kind_name_for_logs()
3692    }
3693
3694    fn kind_name_for_logs(&self) -> &'static str {
3695        match self {
3696            Self::Spawned(_) => "spawned",
3697            Self::Queued(_) => "queued",
3698            #[cfg(feature = "asupersync-executor")]
3699            Self::Asupersync(_) => "asupersync",
3700        }
3701    }
3702}
3703
3704fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3705    let Some(sink) = sink else {
3706        return;
3707    };
3708    let _ = sink.write_jsonl(&format!(
3709        r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3710    ));
3711}
3712
3713fn emit_task_executor_completion_evidence(
3714    sink: Option<&EvidenceSink>,
3715    backend: &str,
3716    duration_us: u64,
3717) {
3718    let Some(sink) = sink else {
3719        return;
3720    };
3721    let _ = sink.write_jsonl(&format!(
3722        r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3723    ));
3724}
3725
3726fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3727    let Some(sink) = sink else {
3728        return;
3729    };
3730    let escaped = panic_msg
3731        .replace('\\', "\\\\")
3732        .replace('"', "\\\"")
3733        .replace('\n', "\\n")
3734        .replace('\r', "\\r")
3735        .replace('\t', "\\t");
3736    let _ = sink.write_jsonl(&format!(
3737        r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3738    ));
3739}
3740
3741fn emit_task_executor_backpressure_evidence(
3742    sink: Option<&EvidenceSink>,
3743    backend: &str,
3744    action: &str,
3745    queue_length: usize,
3746    max_queue_size: usize,
3747    total_rejected: u64,
3748) {
3749    let Some(sink) = sink else {
3750        return;
3751    };
3752    let _ = sink.write_jsonl(&format!(
3753        r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3754    ));
3755}
3756
3757fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3758    if let Some(s) = payload.downcast_ref::<&str>() {
3759        (*s).to_owned()
3760    } else if let Some(s) = payload.downcast_ref::<String>() {
3761        s.clone()
3762    } else {
3763        "unknown panic payload".to_owned()
3764    }
3765}
3766
3767fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3768    #[cfg(feature = "tracing")]
3769    tracing::error!(
3770        executor_backend = backend,
3771        panic_msg,
3772        "task executor task panicked"
3773    );
3774    #[cfg(not(feature = "tracing"))]
3775    eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3776}
3777
3778fn run_task_closure<M: Send + 'static>(
3779    task: Box<dyn FnOnce() -> M + Send>,
3780    backend: &str,
3781    evidence_sink: Option<&EvidenceSink>,
3782    result_sender: &mpsc::Sender<M>,
3783) -> bool {
3784    let start = Instant::now();
3785    match panic::catch_unwind(AssertUnwindSafe(task)) {
3786        Ok(msg) => {
3787            let duration_us = start.elapsed().as_micros() as u64;
3788            tracing::debug!(
3789                target: "ftui.effect",
3790                command_type = "task",
3791                executor_backend = backend,
3792                duration_us = duration_us,
3793                effect_duration_us = duration_us,
3794                "task effect completed"
3795            );
3796            emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3797            let _ = result_sender.send(msg);
3798            true
3799        }
3800        Err(payload) => {
3801            let panic_msg = panic_payload_message(payload);
3802            log_task_executor_panic(backend, &panic_msg);
3803            emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3804            false
3805        }
3806    }
3807}
3808
3809fn effect_queue_loop<M: Send + 'static>(
3810    config: EffectQueueConfig,
3811    rx: mpsc::Receiver<EffectCommand<M>>,
3812    result_sender: mpsc::Sender<M>,
3813    evidence_sink: Option<EvidenceSink>,
3814) {
3815    let mut scheduler = QueueingScheduler::new(config.scheduler);
3816    let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3817    let mut shutdown_requested = false;
3818    let max_depth = config.max_queue_depth;
3819
3820    loop {
3821        if tasks.is_empty() {
3822            if shutdown_requested {
3823                return;
3824            }
3825            match rx.recv() {
3826                Ok(cmd) => {
3827                    if matches!(
3828                        handle_effect_command(
3829                            cmd,
3830                            &mut scheduler,
3831                            &mut tasks,
3832                            &result_sender,
3833                            evidence_sink.as_ref(),
3834                            max_depth,
3835                        ),
3836                        EffectLoopControl::ShutdownRequested
3837                    ) {
3838                        shutdown_requested = true;
3839                    }
3840                }
3841                Err(_) => return,
3842            }
3843        }
3844
3845        while let Ok(cmd) = rx.try_recv() {
3846            if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3847                crate::effect_system::record_queue_drop("post_shutdown");
3848                continue;
3849            }
3850            if matches!(
3851                handle_effect_command(
3852                    cmd,
3853                    &mut scheduler,
3854                    &mut tasks,
3855                    &result_sender,
3856                    evidence_sink.as_ref(),
3857                    max_depth,
3858                ),
3859                EffectLoopControl::ShutdownRequested
3860            ) {
3861                shutdown_requested = true;
3862            }
3863        }
3864
3865        if tasks.is_empty() {
3866            if shutdown_requested {
3867                return;
3868            }
3869            continue;
3870        }
3871
3872        let Some(job) = scheduler.peek_next().cloned() else {
3873            continue;
3874        };
3875
3876        if let Some(ref sink) = evidence_sink {
3877            let evidence = scheduler.evidence();
3878            let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3879        }
3880
3881        let completed = scheduler.tick(job.remaining_time);
3882        for job_id in completed {
3883            if let Some(task) = tasks.remove(&job_id) {
3884                let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3885                crate::effect_system::record_queue_processed();
3886            }
3887        }
3888    }
3889}
3890
3891fn handle_effect_command<M: Send + 'static>(
3892    cmd: EffectCommand<M>,
3893    scheduler: &mut QueueingScheduler,
3894    tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3895    result_sender: &mpsc::Sender<M>,
3896    evidence_sink: Option<&EvidenceSink>,
3897    max_depth: usize,
3898) -> EffectLoopControl {
3899    match cmd {
3900        EffectCommand::Enqueue(spec, task) => {
3901            // Backpressure: drop task if queue depth exceeds limit (bd-2zd0a)
3902            if max_depth > 0 && tasks.len() >= max_depth {
3903                crate::effect_system::record_queue_drop("backpressure");
3904                return EffectLoopControl::Continue;
3905            }
3906            let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3907                WeightSource::Default
3908            } else {
3909                WeightSource::Explicit
3910            };
3911            let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3912                EstimateSource::Default
3913            } else {
3914                EstimateSource::Explicit
3915            };
3916            let id = scheduler.submit_with_sources(
3917                spec.weight,
3918                spec.estimate_ms,
3919                weight_source,
3920                estimate_source,
3921                spec.name,
3922            );
3923            if let Some(id) = id {
3924                tasks.insert(id, task);
3925                crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3926            } else {
3927                let stats = scheduler.stats();
3928                emit_task_executor_backpressure_evidence(
3929                    evidence_sink,
3930                    "queued",
3931                    "inline_fallback",
3932                    stats.queue_length,
3933                    scheduler.max_queue_size(),
3934                    stats.total_rejected,
3935                );
3936                let _ =
3937                    run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
3938            }
3939            EffectLoopControl::Continue
3940        }
3941        EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
3942    }
3943}
3944
3945// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
3946
3947/// Policy for remeasuring inline auto UI height.
3948///
3949/// Uses VOI (value-of-information) sampling to decide when to perform
3950/// a costly full-height measurement, with any-time valid guarantees via
3951/// the embedded e-process in `VoiSampler`.
3952#[derive(Debug, Clone)]
3953pub struct InlineAutoRemeasureConfig {
3954    /// VOI sampling configuration.
3955    pub voi: VoiConfig,
3956    /// Minimum row delta to count as a "violation".
3957    pub change_threshold_rows: u16,
3958}
3959
3960impl Default for InlineAutoRemeasureConfig {
3961    fn default() -> Self {
3962        Self {
3963            voi: VoiConfig {
3964                // Height changes are expected to be rare; bias toward fewer samples.
3965                prior_alpha: 1.0,
3966                prior_beta: 9.0,
3967                // Allow ~1s max latency to adapt to growth/shrink.
3968                max_interval_ms: 1000,
3969                // Avoid over-sampling in high-FPS loops.
3970                min_interval_ms: 100,
3971                // Disable event forcing; use time-based gating.
3972                max_interval_events: 0,
3973                min_interval_events: 0,
3974                // Treat sampling as moderately expensive.
3975                sample_cost: 0.08,
3976                ..VoiConfig::default()
3977            },
3978            change_threshold_rows: 1,
3979        }
3980    }
3981}
3982
3983#[derive(Debug)]
3984struct InlineAutoRemeasureState {
3985    config: InlineAutoRemeasureConfig,
3986    sampler: VoiSampler,
3987}
3988
3989impl InlineAutoRemeasureState {
3990    fn new(config: InlineAutoRemeasureConfig) -> Self {
3991        let sampler = VoiSampler::new(config.voi.clone());
3992        Self { config, sampler }
3993    }
3994
3995    fn reset(&mut self) {
3996        self.sampler = VoiSampler::new(self.config.voi.clone());
3997    }
3998}
3999
4000#[derive(Debug, Clone)]
4001struct ConformalEvidence {
4002    bucket_key: String,
4003    n_b: usize,
4004    alpha: f64,
4005    q_b: f64,
4006    y_hat: f64,
4007    upper_us: f64,
4008    risk: bool,
4009    fallback_level: u8,
4010    window_size: usize,
4011    reset_count: u64,
4012}
4013
4014impl ConformalEvidence {
4015    fn from_prediction(prediction: &ConformalPrediction) -> Self {
4016        let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
4017        Self {
4018            bucket_key: prediction.bucket.to_string(),
4019            n_b: prediction.sample_count,
4020            alpha,
4021            q_b: prediction.quantile,
4022            y_hat: prediction.y_hat,
4023            upper_us: prediction.upper_us,
4024            risk: prediction.risk,
4025            fallback_level: prediction.fallback_level,
4026            window_size: prediction.window_size,
4027            reset_count: prediction.reset_count,
4028        }
4029    }
4030}
4031
4032#[derive(Debug, Clone)]
4033struct BudgetDecisionEvidence {
4034    frame_idx: u64,
4035    decision: BudgetDecision,
4036    controller_decision: BudgetDecision,
4037    degradation_before: DegradationLevel,
4038    degradation_after: DegradationLevel,
4039    frame_time_us: f64,
4040    budget_us: f64,
4041    pid_output: f64,
4042    pid_p: f64,
4043    pid_i: f64,
4044    pid_d: f64,
4045    e_value: f64,
4046    frames_observed: u32,
4047    frames_since_change: u32,
4048    in_warmup: bool,
4049    controller_reason: BudgetDecisionReason,
4050    load_governor: LoadGovernorSnapshot,
4051    conformal: Option<ConformalEvidence>,
4052}
4053
4054impl BudgetDecisionEvidence {
4055    fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
4056        if after > before {
4057            BudgetDecision::Degrade
4058        } else if after < before {
4059            BudgetDecision::Upgrade
4060        } else {
4061            BudgetDecision::Hold
4062        }
4063    }
4064
4065    #[must_use]
4066    fn to_jsonl(&self) -> String {
4067        let conformal = self.conformal.as_ref();
4068        let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
4069        let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
4070        let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
4071        let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
4072        let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
4073        let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
4074        let risk = Self::opt_bool(conformal.map(|c| c.risk));
4075        let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
4076        let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
4077        let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
4078        let queue_max_depth = Self::opt_usize(self.load_governor.queue_max_depth);
4079
4080        format!(
4081            r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","decision_controller_reason":"{}","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":{},"runtime_mode":"{}","runtime_mode_before":"{}","pressure_class":"{}","work_disposition":"{}","governor_reason":"{}","governor_transition":{},"strict_semantics_preserved":{},"queue_in_flight":{},"queue_max_depth":{},"queue_dropped_delta":{},"resize_coalescing_active":{},"recovery_intervals_observed":{},"recovery_intervals_required":{},"deferred_work_total":{},"coalesced_work_total":{},"dropped_work_total":{},"bucket_key":{},"n_b":{},"alpha":{},"q_b":{},"y_hat":{},"upper_us":{},"risk":{},"fallback_level":{},"window_size":{},"reset_count":{}}}"#,
4082            self.frame_idx,
4083            self.decision.as_str(),
4084            self.controller_decision.as_str(),
4085            self.controller_reason.as_str(),
4086            self.degradation_before.as_str(),
4087            self.degradation_after.as_str(),
4088            self.frame_time_us,
4089            self.budget_us,
4090            self.pid_output,
4091            self.pid_p,
4092            self.pid_i,
4093            self.pid_d,
4094            self.e_value,
4095            self.frames_observed,
4096            self.frames_since_change,
4097            self.in_warmup,
4098            self.load_governor.mode.as_str(),
4099            self.load_governor.mode_before.as_str(),
4100            self.load_governor.pressure_class.as_str(),
4101            self.load_governor.disposition.as_str(),
4102            self.load_governor.reason_code,
4103            self.load_governor.transition,
4104            self.load_governor.strict_semantics_preserved,
4105            self.load_governor.queue_in_flight,
4106            queue_max_depth,
4107            self.load_governor.queue_dropped_delta,
4108            self.load_governor.resize_coalescing_active,
4109            self.load_governor.recovery_intervals_observed,
4110            self.load_governor.recovery_intervals_required,
4111            self.load_governor.deferred_work_total,
4112            self.load_governor.coalesced_work_total,
4113            self.load_governor.dropped_work_total,
4114            bucket_key,
4115            n_b,
4116            alpha,
4117            q_b,
4118            y_hat,
4119            upper_us,
4120            risk,
4121            fallback_level,
4122            window_size,
4123            reset_count
4124        )
4125    }
4126
4127    fn opt_f64(value: Option<f64>) -> String {
4128        value
4129            .map(|v| format!("{v:.6}"))
4130            .unwrap_or_else(|| "null".to_string())
4131    }
4132
4133    fn opt_u64(value: Option<u64>) -> String {
4134        value
4135            .map(|v| v.to_string())
4136            .unwrap_or_else(|| "null".to_string())
4137    }
4138
4139    fn opt_u8(value: Option<u8>) -> String {
4140        value
4141            .map(|v| v.to_string())
4142            .unwrap_or_else(|| "null".to_string())
4143    }
4144
4145    fn opt_usize(value: Option<usize>) -> String {
4146        value
4147            .map(|v| v.to_string())
4148            .unwrap_or_else(|| "null".to_string())
4149    }
4150
4151    fn opt_bool(value: Option<bool>) -> String {
4152        value
4153            .map(|v| v.to_string())
4154            .unwrap_or_else(|| "null".to_string())
4155    }
4156
4157    fn opt_str(value: Option<&str>) -> String {
4158        value
4159            .map(|v| {
4160                format!(
4161                    "\"{}\"",
4162                    v.replace('\\', "\\\\")
4163                        .replace('"', "\\\"")
4164                        .replace('\n', "\\n")
4165                        .replace('\r', "\\r")
4166                        .replace('\t', "\\t")
4167                )
4168            })
4169            .unwrap_or_else(|| "null".to_string())
4170    }
4171}
4172
4173#[derive(Debug, Clone)]
4174struct FairnessConfigEvidence {
4175    enabled: bool,
4176    input_priority_threshold_ms: u64,
4177    dominance_threshold: u32,
4178    fairness_threshold: f64,
4179}
4180
4181impl FairnessConfigEvidence {
4182    #[must_use]
4183    fn to_jsonl(&self) -> String {
4184        format!(
4185            r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
4186            self.enabled,
4187            self.input_priority_threshold_ms,
4188            self.dominance_threshold,
4189            self.fairness_threshold
4190        )
4191    }
4192}
4193
4194#[derive(Debug, Clone)]
4195struct FairnessDecisionEvidence {
4196    frame_idx: u64,
4197    decision: &'static str,
4198    reason: &'static str,
4199    pending_input_latency_ms: Option<u64>,
4200    jain_index: f64,
4201    resize_dominance_count: u32,
4202    dominance_threshold: u32,
4203    fairness_threshold: f64,
4204    input_priority_threshold_ms: u64,
4205}
4206
4207impl FairnessDecisionEvidence {
4208    #[must_use]
4209    fn to_jsonl(&self) -> String {
4210        let pending_latency = self
4211            .pending_input_latency_ms
4212            .map(|v| v.to_string())
4213            .unwrap_or_else(|| "null".to_string());
4214        format!(
4215            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":{}}}"#,
4216            self.frame_idx,
4217            self.decision,
4218            self.reason,
4219            pending_latency,
4220            self.jain_index,
4221            self.resize_dominance_count,
4222            self.dominance_threshold,
4223            self.fairness_threshold,
4224            self.input_priority_threshold_ms
4225        )
4226    }
4227}
4228
4229#[derive(Debug, Clone)]
4230struct WidgetRefreshEntry {
4231    widget_id: u64,
4232    essential: bool,
4233    starved: bool,
4234    value: f32,
4235    cost_us: f32,
4236    score: f32,
4237    staleness_ms: u64,
4238}
4239
4240impl WidgetRefreshEntry {
4241    fn to_json(&self) -> String {
4242        format!(
4243            r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
4244            self.widget_id,
4245            self.cost_us,
4246            self.value,
4247            self.score,
4248            self.essential,
4249            self.starved,
4250            self.staleness_ms
4251        )
4252    }
4253}
4254
4255#[derive(Debug, Clone)]
4256struct WidgetRefreshPlan {
4257    frame_idx: u64,
4258    budget_us: f64,
4259    degradation: DegradationLevel,
4260    essentials_cost_us: f64,
4261    selected_cost_us: f64,
4262    selected_value: f64,
4263    signal_count: usize,
4264    selected: Vec<WidgetRefreshEntry>,
4265    skipped_count: usize,
4266    skipped_starved: usize,
4267    starved_selected: usize,
4268    over_budget: bool,
4269}
4270
4271impl WidgetRefreshPlan {
4272    fn new() -> Self {
4273        Self {
4274            frame_idx: 0,
4275            budget_us: 0.0,
4276            degradation: DegradationLevel::Full,
4277            essentials_cost_us: 0.0,
4278            selected_cost_us: 0.0,
4279            selected_value: 0.0,
4280            signal_count: 0,
4281            selected: Vec::new(),
4282            skipped_count: 0,
4283            skipped_starved: 0,
4284            starved_selected: 0,
4285            over_budget: false,
4286        }
4287    }
4288
4289    fn clear(&mut self) {
4290        self.frame_idx = 0;
4291        self.budget_us = 0.0;
4292        self.degradation = DegradationLevel::Full;
4293        self.essentials_cost_us = 0.0;
4294        self.selected_cost_us = 0.0;
4295        self.selected_value = 0.0;
4296        self.signal_count = 0;
4297        self.selected.clear();
4298        self.skipped_count = 0;
4299        self.skipped_starved = 0;
4300        self.starved_selected = 0;
4301        self.over_budget = false;
4302    }
4303
4304    fn as_budget(&self) -> WidgetBudget {
4305        if self.signal_count == 0 {
4306            return WidgetBudget::allow_all();
4307        }
4308        let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
4309        WidgetBudget::allow_only(ids)
4310    }
4311
4312    fn recompute(
4313        &mut self,
4314        frame_idx: u64,
4315        budget_us: f64,
4316        degradation: DegradationLevel,
4317        signals: &[WidgetSignal],
4318        config: &WidgetRefreshConfig,
4319    ) {
4320        self.clear();
4321        self.frame_idx = frame_idx;
4322        self.budget_us = budget_us;
4323        self.degradation = degradation;
4324
4325        if !config.enabled || signals.is_empty() {
4326            return;
4327        }
4328
4329        self.signal_count = signals.len();
4330        let mut essentials_cost = 0.0f64;
4331        let mut selected_cost = 0.0f64;
4332        let mut selected_value = 0.0f64;
4333
4334        let staleness_window = config.staleness_window_ms.max(1) as f32;
4335        let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
4336
4337        for signal in signals {
4338            let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
4339            let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
4340            let mut value = config.weight_priority * signal.priority
4341                + config.weight_staleness * staleness_score
4342                + config.weight_focus * signal.focus_boost
4343                + config.weight_interaction * signal.interaction_boost;
4344            if starved {
4345                value += config.starve_boost;
4346            }
4347            let raw_cost = if signal.recent_cost_us > 0.0 {
4348                signal.recent_cost_us
4349            } else {
4350                signal.cost_estimate_us
4351            };
4352            let cost_us = raw_cost.max(config.min_cost_us);
4353            let score = if cost_us > 0.0 {
4354                value / cost_us
4355            } else {
4356                value
4357            };
4358
4359            let entry = WidgetRefreshEntry {
4360                widget_id: signal.widget_id,
4361                essential: signal.essential,
4362                starved,
4363                value,
4364                cost_us,
4365                score,
4366                staleness_ms: signal.staleness_ms,
4367            };
4368
4369            if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
4370                self.skipped_count += 1;
4371                if starved {
4372                    self.skipped_starved = self.skipped_starved.saturating_add(1);
4373                }
4374                continue;
4375            }
4376
4377            if signal.essential {
4378                essentials_cost += cost_us as f64;
4379                selected_cost += cost_us as f64;
4380                selected_value += value as f64;
4381                if starved {
4382                    self.starved_selected = self.starved_selected.saturating_add(1);
4383                }
4384                self.selected.push(entry);
4385            } else {
4386                candidates.push(entry);
4387            }
4388        }
4389
4390        let mut remaining = budget_us - selected_cost;
4391
4392        if degradation < DegradationLevel::EssentialOnly {
4393            let nonessential_total = candidates.len();
4394            let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
4395            let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
4396            let min_nonessential_selected = if enforce_drop_rate {
4397                let min_fraction = (1.0 - max_drop_fraction).max(0.0);
4398                ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
4399            } else {
4400                0
4401            };
4402
4403            candidates.sort_by(|a, b| {
4404                b.starved
4405                    .cmp(&a.starved)
4406                    .then_with(|| b.score.total_cmp(&a.score))
4407                    .then_with(|| b.value.total_cmp(&a.value))
4408                    .then_with(|| a.cost_us.total_cmp(&b.cost_us))
4409                    .then_with(|| a.widget_id.cmp(&b.widget_id))
4410            });
4411
4412            let mut forced_starved = 0usize;
4413            let mut nonessential_selected = 0usize;
4414            let mut skipped_candidates = if enforce_drop_rate {
4415                Vec::with_capacity(candidates.len())
4416            } else {
4417                Vec::new()
4418            };
4419
4420            for entry in candidates.into_iter() {
4421                if entry.starved && forced_starved >= config.max_starved_per_frame {
4422                    self.skipped_count += 1;
4423                    self.skipped_starved = self.skipped_starved.saturating_add(1);
4424                    if enforce_drop_rate {
4425                        skipped_candidates.push(entry);
4426                    }
4427                    continue;
4428                }
4429
4430                if remaining >= entry.cost_us as f64 {
4431                    remaining -= entry.cost_us as f64;
4432                    selected_cost += entry.cost_us as f64;
4433                    selected_value += entry.value as f64;
4434                    if entry.starved {
4435                        self.starved_selected = self.starved_selected.saturating_add(1);
4436                        forced_starved += 1;
4437                    }
4438                    nonessential_selected += 1;
4439                    self.selected.push(entry);
4440                } else if entry.starved
4441                    && forced_starved < config.max_starved_per_frame
4442                    && nonessential_selected == 0
4443                {
4444                    // Starvation guard: ensure at least one starved widget can refresh.
4445                    selected_cost += entry.cost_us as f64;
4446                    selected_value += entry.value as f64;
4447                    self.starved_selected = self.starved_selected.saturating_add(1);
4448                    forced_starved += 1;
4449                    nonessential_selected += 1;
4450                    self.selected.push(entry);
4451                } else {
4452                    self.skipped_count += 1;
4453                    if entry.starved {
4454                        self.skipped_starved = self.skipped_starved.saturating_add(1);
4455                    }
4456                    if enforce_drop_rate {
4457                        skipped_candidates.push(entry);
4458                    }
4459                }
4460            }
4461
4462            if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
4463                for entry in skipped_candidates.into_iter() {
4464                    if nonessential_selected >= min_nonessential_selected {
4465                        break;
4466                    }
4467                    if entry.starved && forced_starved >= config.max_starved_per_frame {
4468                        continue;
4469                    }
4470                    selected_cost += entry.cost_us as f64;
4471                    selected_value += entry.value as f64;
4472                    if entry.starved {
4473                        self.starved_selected = self.starved_selected.saturating_add(1);
4474                        forced_starved += 1;
4475                        self.skipped_starved = self.skipped_starved.saturating_sub(1);
4476                    }
4477                    self.skipped_count = self.skipped_count.saturating_sub(1);
4478                    nonessential_selected += 1;
4479                    self.selected.push(entry);
4480                }
4481            }
4482        }
4483
4484        self.essentials_cost_us = essentials_cost;
4485        self.selected_cost_us = selected_cost;
4486        self.selected_value = selected_value;
4487        self.over_budget = selected_cost > budget_us;
4488    }
4489
4490    #[must_use]
4491    fn to_jsonl(&self) -> String {
4492        let mut out = String::with_capacity(256 + self.selected.len() * 96);
4493        out.push_str(r#"{"event":"widget_refresh""#);
4494        out.push_str(&format!(
4495            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":{}"#,
4496            self.frame_idx,
4497            self.budget_us,
4498            self.degradation.as_str(),
4499            self.essentials_cost_us,
4500            self.selected_cost_us,
4501            self.selected_value,
4502            self.selected.len(),
4503            self.skipped_count,
4504            self.starved_selected,
4505            self.skipped_starved,
4506            self.over_budget
4507        ));
4508        out.push_str(r#","selected":["#);
4509        for (i, entry) in self.selected.iter().enumerate() {
4510            if i > 0 {
4511                out.push(',');
4512            }
4513            out.push_str(&entry.to_json());
4514        }
4515        out.push_str("]}");
4516        out
4517    }
4518}
4519
4520// =============================================================================
4521// CrosstermEventSource: BackendEventSource adapter for TerminalSession
4522// =============================================================================
4523
4524#[cfg(feature = "crossterm-compat")]
4525/// Adapter that wraps [`TerminalSession`] to implement [`BackendEventSource`].
4526///
4527/// This provides the bridge between the legacy crossterm-based terminal session
4528/// and the new backend abstraction. Once the native `ftui-tty` backend fully
4529/// replaces crossterm, this adapter will be removed.
4530pub struct CrosstermEventSource {
4531    session: TerminalSession,
4532    features: BackendFeatures,
4533}
4534
4535#[cfg(feature = "crossterm-compat")]
4536impl CrosstermEventSource {
4537    /// Create a new crossterm event source from a terminal session.
4538    pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
4539        Self {
4540            session,
4541            features: initial_features,
4542        }
4543    }
4544}
4545
4546#[cfg(feature = "crossterm-compat")]
4547impl BackendEventSource for CrosstermEventSource {
4548    type Error = io::Error;
4549
4550    fn size(&self) -> Result<(u16, u16), io::Error> {
4551        self.session.size()
4552    }
4553
4554    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4555        if features.mouse_capture != self.features.mouse_capture {
4556            self.session.set_mouse_capture(features.mouse_capture)?;
4557        }
4558        // bracketed_paste, focus_events, and kitty_keyboard are set at session
4559        // construction and cleaned up in TerminalSession::Drop. Runtime toggling
4560        // is not supported by the crossterm backend.
4561        self.features = features;
4562        Ok(())
4563    }
4564
4565    fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
4566        self.session.poll_event(timeout)
4567    }
4568
4569    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4570        self.session.read_event()
4571    }
4572}
4573
4574// =============================================================================
4575// HeadlessEventSource: no-op event source for headless/test programs
4576// =============================================================================
4577
4578/// A no-op event source for headless and test programs.
4579///
4580/// Returns a fixed terminal size, accepts feature changes silently, and never
4581/// produces events. This allows the test helper to construct a `Program`
4582/// without depending on crossterm or a real terminal.
4583pub struct HeadlessEventSource {
4584    width: u16,
4585    height: u16,
4586    features: BackendFeatures,
4587}
4588
4589impl HeadlessEventSource {
4590    /// Create a headless event source with the given terminal size.
4591    pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
4592        Self {
4593            width,
4594            height,
4595            features,
4596        }
4597    }
4598}
4599
4600impl BackendEventSource for HeadlessEventSource {
4601    type Error = io::Error;
4602
4603    fn size(&self) -> Result<(u16, u16), io::Error> {
4604        Ok((self.width, self.height))
4605    }
4606
4607    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4608        self.features = features;
4609        Ok(())
4610    }
4611
4612    fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
4613        Ok(false)
4614    }
4615
4616    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4617        Ok(None)
4618    }
4619}
4620
4621// =============================================================================
4622// Program
4623// =============================================================================
4624
4625/// The program runtime that manages the update/view loop.
4626pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4627    /// The application model.
4628    model: M,
4629    /// Terminal output coordinator.
4630    writer: TerminalWriter<W>,
4631    /// Event source (terminal input, size queries, feature toggles).
4632    events: E,
4633    /// Currently active backend feature toggles.
4634    backend_features: BackendFeatures,
4635    /// Whether the program is running.
4636    running: bool,
4637    /// Current tick rate (if any).
4638    tick_rate: Option<Duration>,
4639    /// Total commands actually executed by the runtime.
4640    executed_cmd_count: usize,
4641    /// Last tick time.
4642    last_tick: Instant,
4643    /// Whether the UI needs to be redrawn.
4644    dirty: bool,
4645    /// Monotonic frame index for evidence logging.
4646    frame_idx: u64,
4647    /// Monotonic tick index for tick-strategy scheduling.
4648    tick_count: u64,
4649    /// Widget scheduling signals captured during the last render.
4650    widget_signals: Vec<WidgetSignal>,
4651    /// Widget refresh selection configuration.
4652    widget_refresh_config: WidgetRefreshConfig,
4653    /// Last computed widget refresh plan.
4654    widget_refresh_plan: WidgetRefreshPlan,
4655    /// Current terminal width.
4656    width: u16,
4657    /// Current terminal height.
4658    height: u16,
4659    /// Forced terminal size override (when set, resize events are ignored).
4660    forced_size: Option<(u16, u16)>,
4661    /// Poll timeout when no tick is scheduled.
4662    poll_timeout: Duration,
4663    /// Whether the runtime should observe process-level termination signals.
4664    intercept_signals: bool,
4665    /// Immediate drain policy for bursty input handling.
4666    immediate_drain_config: ImmediateDrainConfig,
4667    /// Runtime counters for immediate-drain behavior.
4668    immediate_drain_stats: ImmediateDrainStats,
4669    /// Frame budget configuration.
4670    budget: RenderBudget,
4671    /// Runtime load-governor state for mode and fallback evidence.
4672    load_governor: LoadGovernorState,
4673    /// Conformal predictor for frame-time risk gating.
4674    conformal_predictor: Option<ConformalPredictor>,
4675    /// Last observed frame time (microseconds), used as a baseline predictor.
4676    last_frame_time_us: Option<f64>,
4677    /// Last observed update duration (microseconds).
4678    last_update_us: Option<u64>,
4679    /// Optional frame timing sink for profiling.
4680    frame_timing: Option<FrameTimingConfig>,
4681    /// Locale context used for rendering.
4682    locale_context: LocaleContext,
4683    /// Last observed locale version.
4684    locale_version: u64,
4685    /// Resize coalescer for rapid resize events.
4686    resize_coalescer: ResizeCoalescer,
4687    /// Shared evidence sink for decision logs (optional).
4688    evidence_sink: Option<EvidenceSink>,
4689    /// Whether fairness config has been logged to evidence sink.
4690    fairness_config_logged: bool,
4691    /// Resize handling behavior.
4692    resize_behavior: ResizeBehavior,
4693    /// Input fairness guard for scheduler integration.
4694    fairness_guard: InputFairnessGuard,
4695    /// Optional event recorder for macro capture.
4696    event_recorder: Option<EventRecorder>,
4697    /// Subscription lifecycle manager.
4698    subscriptions: SubscriptionManager<M::Message>,
4699    /// Channel for receiving messages from background tasks.
4700    #[cfg(test)]
4701    task_sender: std::sync::mpsc::Sender<M::Message>,
4702    /// Channel for receiving messages from background tasks.
4703    task_receiver: std::sync::mpsc::Receiver<M::Message>,
4704    /// Internal task execution substrate behind `Cmd::Task`.
4705    task_executor: TaskExecutor<M::Message>,
4706    /// Optional state registry for widget persistence.
4707    state_registry: Option<std::sync::Arc<StateRegistry>>,
4708    /// Persistence configuration.
4709    persistence_config: PersistenceConfig,
4710    /// Last checkpoint save time.
4711    last_checkpoint: Instant,
4712    /// Inline auto UI height remeasurement state.
4713    inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4714    /// Per-frame bump arena for temporary render-path allocations.
4715    frame_arena: FrameArena,
4716    /// Unified frame guardrails (memory/queue limits).
4717    guardrails: FrameGuardrails,
4718    /// Optional tick strategy for selective background screen ticking.
4719    tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4720    /// Last active screen observed by the tick strategy dispatch path.
4721    last_active_screen_for_strategy: Option<String>,
4722}
4723
4724#[cfg(feature = "crossterm-compat")]
4725impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4726    /// Create a new program with default configuration.
4727    pub fn new(model: M) -> io::Result<Self>
4728    where
4729        M::Message: Send + 'static,
4730    {
4731        Self::with_config(model, ProgramConfig::default())
4732    }
4733
4734    /// Create a new program with the specified configuration.
4735    pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4736    where
4737        M::Message: Send + 'static,
4738    {
4739        let resolved_lane = config.runtime_lane.resolve();
4740        let effect_queue_config = config.resolved_effect_queue_config();
4741        let capabilities = TerminalCapabilities::with_overrides();
4742        let mouse_capture = config.resolved_mouse_capture();
4743        let requested_features = BackendFeatures {
4744            mouse_capture,
4745            bracketed_paste: config.bracketed_paste,
4746            focus_events: config.focus_reporting,
4747            kitty_keyboard: config.kitty_keyboard,
4748        };
4749        let initial_features =
4750            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4751        let session = TerminalSession::new(SessionOptions {
4752            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4753            mouse_capture: initial_features.mouse_capture,
4754            bracketed_paste: initial_features.bracketed_paste,
4755            focus_events: initial_features.focus_events,
4756            kitty_keyboard: initial_features.kitty_keyboard,
4757            intercept_signals: config.intercept_signals,
4758        })?;
4759        let events = CrosstermEventSource::new(session, initial_features);
4760
4761        let mut writer = TerminalWriter::with_diff_config(
4762            io::stdout(),
4763            config.screen_mode,
4764            config.ui_anchor,
4765            capabilities,
4766            config.diff_config.clone(),
4767        );
4768
4769        let frame_timing = config.frame_timing.clone();
4770        writer.set_timing_enabled(frame_timing.is_some());
4771
4772        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4773        if let Some(ref sink) = evidence_sink {
4774            writer = writer.with_evidence_sink(sink.clone());
4775        }
4776
4777        let render_trace = crate::RenderTraceRecorder::from_config(
4778            &config.render_trace,
4779            crate::RenderTraceContext {
4780                capabilities: writer.capabilities(),
4781                diff_config: config.diff_config.clone(),
4782                resize_config: config.resize_coalescer.clone(),
4783                conformal_config: config.conformal_config.clone(),
4784            },
4785        )?;
4786        if let Some(recorder) = render_trace {
4787            writer = writer.with_render_trace(recorder);
4788        }
4789
4790        // Get terminal size for initial frame (or forced size override).
4791        let (w, h) = config
4792            .forced_size
4793            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4794        let width = w.max(1);
4795        let height = h.max(1);
4796        writer.set_size(width, height);
4797
4798        let budget = render_budget_from_program_config(&config);
4799        let load_governor = LoadGovernorState::new(
4800            config.load_governor.clone(),
4801            effect_queue_config.max_queue_depth,
4802        );
4803        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4804        let locale_context = config.locale_context.clone();
4805        let locale_version = locale_context.version();
4806        let mut resize_coalescer =
4807            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4808                .with_screen_mode(config.screen_mode);
4809        if let Some(ref sink) = evidence_sink {
4810            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4811        }
4812        let subscriptions = SubscriptionManager::new();
4813        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4814        let inline_auto_remeasure = config
4815            .inline_auto_remeasure
4816            .clone()
4817            .map(InlineAutoRemeasureState::new);
4818        let task_executor = TaskExecutor::new(
4819            &effect_queue_config,
4820            task_sender.clone(),
4821            evidence_sink.clone(),
4822        )?;
4823        let guardrails = FrameGuardrails::new(config.guardrails);
4824
4825        // Log runtime lane and rollout policy at startup (bd-2crbt)
4826        tracing::info!(
4827            target: "ftui.runtime",
4828            requested_lane = config.runtime_lane.label(),
4829            resolved_lane = resolved_lane.label(),
4830            rollout_policy = config.rollout_policy.label(),
4831            "runtime startup: lane={}, rollout={}",
4832            resolved_lane.label(),
4833            config.rollout_policy.label(),
4834        );
4835
4836        Ok(Self {
4837            model,
4838            writer,
4839            events,
4840            backend_features: initial_features,
4841            running: true,
4842            tick_rate: None,
4843            executed_cmd_count: 0,
4844            last_tick: Instant::now(),
4845            dirty: true,
4846            frame_idx: 0,
4847            tick_count: 0,
4848            widget_signals: Vec::new(),
4849            widget_refresh_config: config.widget_refresh,
4850            widget_refresh_plan: WidgetRefreshPlan::new(),
4851            width,
4852            height,
4853            forced_size: config.forced_size,
4854            poll_timeout: config.poll_timeout,
4855            intercept_signals: config.intercept_signals,
4856            immediate_drain_config: config.immediate_drain,
4857            immediate_drain_stats: ImmediateDrainStats::default(),
4858            budget,
4859            load_governor,
4860            conformal_predictor,
4861            last_frame_time_us: None,
4862            last_update_us: None,
4863            frame_timing,
4864            locale_context,
4865            locale_version,
4866            resize_coalescer,
4867            evidence_sink,
4868            fairness_config_logged: false,
4869            resize_behavior: config.resize_behavior,
4870            fairness_guard: InputFairnessGuard::new(),
4871            event_recorder: None,
4872            subscriptions,
4873            #[cfg(test)]
4874            task_sender,
4875            task_receiver,
4876            task_executor,
4877            state_registry: config.persistence.registry.clone(),
4878            persistence_config: config.persistence,
4879            last_checkpoint: Instant::now(),
4880            inline_auto_remeasure,
4881            frame_arena: FrameArena::default(),
4882            guardrails,
4883            tick_strategy: config
4884                .tick_strategy
4885                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4886            last_active_screen_for_strategy: None,
4887        })
4888    }
4889}
4890
4891impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4892    /// Create a program with an externally-constructed event source and writer.
4893    ///
4894    /// This is the generic entry point for alternative backends (native tty,
4895    /// WASM, headless testing). The caller is responsible for terminal
4896    /// lifecycle (raw mode, cleanup) — the event source should handle that
4897    /// via its `Drop` impl or an external RAII guard.
4898    pub fn with_event_source(
4899        model: M,
4900        events: E,
4901        backend_features: BackendFeatures,
4902        writer: TerminalWriter<W>,
4903        config: ProgramConfig,
4904    ) -> io::Result<Self>
4905    where
4906        M::Message: Send + 'static,
4907    {
4908        let effect_queue_config = config.resolved_effect_queue_config();
4909        let (width, height) = config
4910            .forced_size
4911            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4912        let width = width.max(1);
4913        let height = height.max(1);
4914
4915        let mut writer = writer;
4916        writer.set_size(width, height);
4917
4918        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4919        if let Some(ref sink) = evidence_sink {
4920            writer = writer.with_evidence_sink(sink.clone());
4921        }
4922
4923        let render_trace = crate::RenderTraceRecorder::from_config(
4924            &config.render_trace,
4925            crate::RenderTraceContext {
4926                capabilities: writer.capabilities(),
4927                diff_config: config.diff_config.clone(),
4928                resize_config: config.resize_coalescer.clone(),
4929                conformal_config: config.conformal_config.clone(),
4930            },
4931        )?;
4932        if let Some(recorder) = render_trace {
4933            writer = writer.with_render_trace(recorder);
4934        }
4935
4936        let frame_timing = config.frame_timing.clone();
4937        writer.set_timing_enabled(frame_timing.is_some());
4938
4939        let budget = render_budget_from_program_config(&config);
4940        let load_governor = LoadGovernorState::new(
4941            config.load_governor.clone(),
4942            effect_queue_config.max_queue_depth,
4943        );
4944        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4945        let locale_context = config.locale_context.clone();
4946        let locale_version = locale_context.version();
4947        let mut resize_coalescer =
4948            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4949                .with_screen_mode(config.screen_mode);
4950        if let Some(ref sink) = evidence_sink {
4951            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4952        }
4953        let subscriptions = SubscriptionManager::new();
4954        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4955        let inline_auto_remeasure = config
4956            .inline_auto_remeasure
4957            .clone()
4958            .map(InlineAutoRemeasureState::new);
4959        let task_executor = TaskExecutor::new(
4960            &effect_queue_config,
4961            task_sender.clone(),
4962            evidence_sink.clone(),
4963        )?;
4964
4965        let guardrails = FrameGuardrails::new(config.guardrails);
4966
4967        Ok(Self {
4968            model,
4969            writer,
4970            events,
4971            backend_features,
4972            running: true,
4973            tick_rate: None,
4974            executed_cmd_count: 0,
4975            last_tick: Instant::now(),
4976            dirty: true,
4977            frame_idx: 0,
4978            tick_count: 0,
4979            widget_signals: Vec::new(),
4980            widget_refresh_config: config.widget_refresh,
4981            widget_refresh_plan: WidgetRefreshPlan::new(),
4982            width,
4983            height,
4984            forced_size: config.forced_size,
4985            poll_timeout: config.poll_timeout,
4986            intercept_signals: config.intercept_signals,
4987            immediate_drain_config: config.immediate_drain,
4988            immediate_drain_stats: ImmediateDrainStats::default(),
4989            budget,
4990            load_governor,
4991            conformal_predictor,
4992            last_frame_time_us: None,
4993            last_update_us: None,
4994            frame_timing,
4995            locale_context,
4996            locale_version,
4997            resize_coalescer,
4998            evidence_sink,
4999            fairness_config_logged: false,
5000            resize_behavior: config.resize_behavior,
5001            fairness_guard: InputFairnessGuard::new(),
5002            event_recorder: None,
5003            subscriptions,
5004            #[cfg(test)]
5005            task_sender,
5006            task_receiver,
5007            task_executor,
5008            state_registry: config.persistence.registry.clone(),
5009            persistence_config: config.persistence,
5010            last_checkpoint: Instant::now(),
5011            inline_auto_remeasure,
5012            frame_arena: FrameArena::default(),
5013            guardrails,
5014            tick_strategy: config
5015                .tick_strategy
5016                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
5017            last_active_screen_for_strategy: None,
5018        })
5019    }
5020}
5021
5022// =============================================================================
5023// Native TTY backend constructor (feature-gated)
5024// =============================================================================
5025
5026#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
5027#[inline]
5028const fn sanitize_backend_features_for_capabilities(
5029    requested: BackendFeatures,
5030    capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
5031) -> BackendFeatures {
5032    let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
5033    let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
5034
5035    BackendFeatures {
5036        mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
5037        bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
5038        focus_events: requested.focus_events && focus_events_supported,
5039        kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
5040    }
5041}
5042
5043#[cfg(feature = "native-backend")]
5044impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
5045    /// Create a program backed by the native TTY backend (no Crossterm).
5046    ///
5047    /// This opens a live terminal session via `ftui-tty`, entering raw mode
5048    /// and enabling the requested features. When the program exits (or panics),
5049    /// `TtyBackend::drop()` restores the terminal to its original state.
5050    ///
5051    /// **Unix-only.** `ftui-tty` does not yet have a Windows-native backend.
5052    /// On non-Unix targets call [`Program::with_config`] (with `crossterm-compat`)
5053    /// instead — calling `with_native_backend` from Windows used to silently
5054    /// fall through to the headless 0×0 test backend and produce a single
5055    /// init-frame-then-silence pattern that looks like a hung TUI.
5056    #[cfg(unix)]
5057    pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
5058    where
5059        M::Message: Send + 'static,
5060    {
5061        let capabilities = ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
5062        let mouse_capture = config.resolved_mouse_capture();
5063        let requested_features = BackendFeatures {
5064            mouse_capture,
5065            bracketed_paste: config.bracketed_paste,
5066            focus_events: config.focus_reporting,
5067            kitty_keyboard: config.kitty_keyboard,
5068        };
5069        let features =
5070            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
5071        let options = ftui_tty::TtySessionOptions {
5072            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
5073            features,
5074            intercept_signals: config.intercept_signals,
5075        };
5076        let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
5077
5078        let writer = TerminalWriter::with_diff_config(
5079            io::stdout(),
5080            config.screen_mode,
5081            config.ui_anchor,
5082            capabilities,
5083            config.diff_config.clone(),
5084        );
5085
5086        Self::with_event_source(model, backend, features, writer, config)
5087    }
5088}
5089
5090impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
5091    /// Run the main event loop.
5092    ///
5093    /// This is the main entry point. It handles:
5094    /// 1. Initialization (terminal setup, raw mode)
5095    /// 2. Event polling and message dispatch
5096    /// 3. Frame rendering
5097    /// 4. Shutdown (terminal cleanup)
5098    pub fn run(&mut self) -> io::Result<()> {
5099        self.run_event_loop()
5100    }
5101
5102    #[inline]
5103    fn observed_termination_signal(&self) -> Option<i32> {
5104        if self.intercept_signals {
5105            check_termination_signal()
5106        } else {
5107            None
5108        }
5109    }
5110
5111    /// Access widget scheduling signals captured on the last render.
5112    #[inline]
5113    pub fn last_widget_signals(&self) -> &[WidgetSignal] {
5114        &self.widget_signals
5115    }
5116
5117    /// Snapshot immediate-drain runtime counters.
5118    #[inline]
5119    pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
5120        self.immediate_drain_stats
5121    }
5122
5123    /// The inner event loop, separated for proper cleanup handling.
5124    fn run_event_loop(&mut self) -> io::Result<()> {
5125        // Auto-load state on start
5126        if self.persistence_config.auto_load {
5127            self.load_state();
5128        }
5129
5130        // Initialize
5131        let cmd = {
5132            let _span = info_span!("ftui.program.init").entered();
5133            self.model.init()
5134        };
5135        self.execute_cmd(cmd)?;
5136
5137        let mut termination_signal = self.observed_termination_signal();
5138        if self.running && termination_signal.is_none() {
5139            // Reconcile initial subscriptions
5140            self.reconcile_subscriptions();
5141
5142            // Initial render
5143            self.render_frame()?;
5144        }
5145
5146        // Main loop
5147        let mut loop_count: u64 = 0;
5148        while self.running {
5149            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5150            if termination_signal.is_some() {
5151                self.running = false;
5152                break;
5153            }
5154
5155            loop_count += 1;
5156            // Log heartbeat every 100 iterations to avoid flooding stderr
5157            if loop_count.is_multiple_of(100) {
5158                crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
5159            }
5160
5161            // Poll for input with tick timeout
5162            let timeout = self.effective_timeout();
5163
5164            // Poll for events with timeout
5165            let poll_result = self.events.poll_event(timeout)?;
5166            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5167            if termination_signal.is_some() {
5168                self.running = false;
5169                break;
5170            }
5171            if poll_result {
5172                self.drain_ready_events()?;
5173            }
5174            if !self.running {
5175                break;
5176            }
5177            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5178            if termination_signal.is_some() {
5179                self.running = false;
5180                break;
5181            }
5182
5183            // Process subscription messages
5184            self.process_subscription_messages()?;
5185            if !self.running {
5186                break;
5187            }
5188
5189            // Process background task results
5190            self.process_task_results()?;
5191            self.reap_finished_tasks();
5192            if !self.running {
5193                break;
5194            }
5195
5196            self.process_resize_coalescer()?;
5197            if !self.running {
5198                break;
5199            }
5200            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5201            if termination_signal.is_some() {
5202                self.running = false;
5203                break;
5204            }
5205
5206            // Detect screen transitions from any update() calls above.
5207            // A.2: notifies the tick strategy so predictive strategies learn.
5208            // D.3: force-ticks the newly active screen for immediate refresh.
5209            self.check_screen_transition();
5210
5211            // Check for tick - deliver to model so periodic logic can run
5212            if self.should_tick() {
5213                self.tick_count = self.tick_count.wrapping_add(1);
5214                let tick_count = self.tick_count;
5215
5216                let mut used_screen_dispatch = false;
5217
5218                // Per-screen tick dispatch: if the model supports multi-screen
5219                // dispatch and a tick strategy is configured, tick individual
5220                // screens selectively instead of calling monolithic
5221                // `update(Tick)`.
5222                if let Some(strategy) = self.tick_strategy.as_mut() {
5223                    // Snapshot screen topology first so the mutable borrow of the
5224                    // dispatch adapter does not overlap strategy decisions.
5225                    let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
5226                        let active = dispatch.active_screen_id();
5227                        let all_screens = dispatch.screen_ids();
5228                        (active, all_screens)
5229                    });
5230
5231                    if let Some((active, all_screens)) = dispatch_snapshot {
5232                        used_screen_dispatch = true;
5233
5234                        // Feed active-screen transitions into the strategy so
5235                        // predictive strategies can learn from real navigation.
5236                        if let Some(previous_active) =
5237                            self.last_active_screen_for_strategy.as_deref()
5238                            && previous_active != active
5239                        {
5240                            strategy.on_screen_transition(previous_active, &active);
5241                        }
5242                        self.last_active_screen_for_strategy = Some(active.clone());
5243
5244                        let all_screens_count = all_screens.len();
5245                        let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
5246                        // Active screen is always ticked.
5247                        tick_targets.push(active.clone());
5248
5249                        // Tick inactive screens according to the strategy.
5250                        for screen_id in all_screens {
5251                            if screen_id != active
5252                                && strategy.should_tick(&screen_id, tick_count, &active)
5253                                    == crate::tick_strategy::TickDecision::Tick
5254                            {
5255                                tick_targets.push(screen_id);
5256                            }
5257                        }
5258
5259                        // Compute skipped screens for tracing.
5260                        let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
5261
5262                        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5263                            for screen_id in &tick_targets {
5264                                dispatch.tick_screen(screen_id, tick_count);
5265                            }
5266                        }
5267
5268                        trace!(
5269                            tick = tick_count,
5270                            active = %active,
5271                            ticked = tick_targets.len(),
5272                            skipped = skipped_count,
5273                            "tick_strategy.frame"
5274                        );
5275
5276                        // Maintenance tick for the strategy.
5277                        strategy.maintenance_tick(tick_count);
5278                        self.mark_dirty();
5279                    }
5280                }
5281
5282                if used_screen_dispatch && self.running {
5283                    self.reconcile_subscriptions();
5284                }
5285
5286                if !used_screen_dispatch {
5287                    // Monolithic model path does not expose active-screen
5288                    // transitions, so clear dispatch-local transition state.
5289                    self.last_active_screen_for_strategy = None;
5290                    let msg = M::Message::from(Event::Tick);
5291                    let cmd = {
5292                        let _span = debug_span!(
5293                            "ftui.program.update",
5294                            msg_type = "Tick",
5295                            duration_us = tracing::field::Empty,
5296                            cmd_type = tracing::field::Empty
5297                        )
5298                        .entered();
5299                        let start = Instant::now();
5300                        let cmd = self.model.update(msg);
5301                        tracing::Span::current()
5302                            .record("duration_us", start.elapsed().as_micros() as u64);
5303                        tracing::Span::current()
5304                            .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5305                        cmd
5306                    };
5307                    self.mark_dirty();
5308                    self.execute_cmd(cmd)?;
5309                    if self.running {
5310                        self.reconcile_subscriptions();
5311                    }
5312                }
5313            }
5314
5315            // Check for periodic checkpoint save
5316            self.check_checkpoint_save();
5317
5318            // Detect locale changes outside the event loop.
5319            self.check_locale_change();
5320            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5321            if termination_signal.is_some() {
5322                self.running = false;
5323                break;
5324            }
5325
5326            // Render if dirty
5327            if self.dirty {
5328                self.render_frame()?;
5329            }
5330
5331            // Periodic grapheme pool GC
5332            if loop_count.is_multiple_of(1000) {
5333                self.writer.gc(None);
5334            }
5335        }
5336
5337        let shutdown_cmd = {
5338            let _span = info_span!("ftui.program.shutdown").entered();
5339            self.model.on_shutdown()
5340        };
5341        self.execute_cmd(shutdown_cmd)?;
5342
5343        // Auto-save state on exit
5344        if self.persistence_config.auto_save {
5345            self.save_state();
5346        }
5347
5348        // Shut down tick strategy (gives strategies a chance to persist state)
5349        if let Some(ref mut strategy) = self.tick_strategy {
5350            strategy.shutdown();
5351        }
5352
5353        // Stop all subscriptions on exit
5354        self.subscriptions.stop_all();
5355        self.task_executor.shutdown();
5356        self.reap_finished_tasks();
5357        self.drain_shutdown_task_results()?;
5358
5359        if let Some(signal) = termination_signal {
5360            clear_termination_signal();
5361            let err = io::Error::new(
5362                io::ErrorKind::Interrupted,
5363                SignalTerminationError { signal },
5364            );
5365            debug_assert_eq!(signal_termination_from_error(&err), Some(signal));
5366            return Err(err);
5367        }
5368
5369        Ok(())
5370    }
5371
5372    /// Drain ready events while bounding zero-timeout polling work.
5373    ///
5374    /// The runtime preserves low-latency draining by polling with
5375    /// `Duration::ZERO`, but switches to a bounded backoff path when a burst
5376    /// exceeds configured immediate-drain budgets.
5377    fn drain_ready_events(&mut self) -> io::Result<()> {
5378        self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
5379
5380        let zero_poll_limit = self
5381            .immediate_drain_config
5382            .max_zero_timeout_polls_per_burst
5383            .max(1);
5384        let max_burst_duration = self.immediate_drain_config.max_burst_duration;
5385        let backoff_timeout = self.immediate_drain_config.backoff_timeout;
5386
5387        let mut burst_start = Instant::now();
5388        let mut zero_polls_in_burst_window: u64 = 0;
5389        let mut capped_this_burst = false;
5390
5391        loop {
5392            if let Some(event) = self.events.read_event()? {
5393                self.handle_event(event)?;
5394                if !self.running {
5395                    break;
5396                }
5397            }
5398
5399            let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
5400                || burst_start.elapsed() >= max_burst_duration;
5401
5402            if budget_exhausted {
5403                if !capped_this_burst {
5404                    capped_this_burst = true;
5405                    self.immediate_drain_stats.capped_bursts =
5406                        self.immediate_drain_stats.capped_bursts.saturating_add(1);
5407                }
5408
5409                self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5410                    .immediate_drain_stats
5411                    .max_zero_timeout_polls_in_burst
5412                    .max(zero_polls_in_burst_window);
5413
5414                std::thread::yield_now();
5415                self.immediate_drain_stats.backoff_polls =
5416                    self.immediate_drain_stats.backoff_polls.saturating_add(1);
5417                if !self.events.poll_event(backoff_timeout)? {
5418                    break;
5419                }
5420                zero_polls_in_burst_window = 0;
5421                burst_start = Instant::now();
5422                continue;
5423            }
5424
5425            self.immediate_drain_stats.zero_timeout_polls = self
5426                .immediate_drain_stats
5427                .zero_timeout_polls
5428                .saturating_add(1);
5429            zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
5430            if !self.events.poll_event(Duration::ZERO)? {
5431                break;
5432            }
5433        }
5434
5435        self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5436            .immediate_drain_stats
5437            .max_zero_timeout_polls_in_burst
5438            .max(zero_polls_in_burst_window);
5439
5440        Ok(())
5441    }
5442
5443    /// Load state from the persistence registry.
5444    fn load_state(&mut self) {
5445        if let Some(registry) = &self.state_registry {
5446            match registry.load() {
5447                Ok(count) => {
5448                    info!(count, "loaded widget state from persistence");
5449                }
5450                Err(e) => {
5451                    tracing::warn!(error = %e, "failed to load widget state");
5452                }
5453            }
5454        }
5455    }
5456
5457    /// Save state to the persistence registry.
5458    fn save_state(&mut self) {
5459        if let Some(registry) = &self.state_registry {
5460            match registry.flush() {
5461                Ok(true) => {
5462                    debug!("saved widget state to persistence");
5463                }
5464                Ok(false) => {
5465                    // No changes to save
5466                }
5467                Err(e) => {
5468                    tracing::warn!(error = %e, "failed to save widget state");
5469                }
5470            }
5471        }
5472    }
5473
5474    /// Check if it's time for a periodic checkpoint save.
5475    fn check_checkpoint_save(&mut self) {
5476        if let Some(interval) = self.persistence_config.checkpoint_interval
5477            && self.last_checkpoint.elapsed() >= interval
5478        {
5479            self.save_state();
5480            self.last_checkpoint = Instant::now();
5481        }
5482    }
5483
5484    fn handle_event(&mut self, event: Event) -> io::Result<()> {
5485        // Track event start time and type for fairness scheduling.
5486        let event_start = Instant::now();
5487        let fairness_event_type = Self::classify_event_for_fairness(&event);
5488        if fairness_event_type == FairnessEventType::Input {
5489            self.fairness_guard.input_arrived(event_start);
5490        }
5491
5492        // Record event before processing (no-op when recorder is None or idle).
5493        if let Some(recorder) = &mut self.event_recorder {
5494            recorder.record(&event);
5495        }
5496
5497        let event = match event {
5498            Event::Resize { width, height } => {
5499                debug!(
5500                    width,
5501                    height,
5502                    behavior = ?self.resize_behavior,
5503                    "Resize event received"
5504                );
5505                if let Some((forced_width, forced_height)) = self.forced_size {
5506                    debug!(
5507                        forced_width,
5508                        forced_height, "Resize ignored due to forced size override"
5509                    );
5510                    self.fairness_guard.event_processed(
5511                        fairness_event_type,
5512                        event_start.elapsed(),
5513                        Instant::now(),
5514                    );
5515                    return Ok(());
5516                }
5517                // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
5518                let width = width.max(1);
5519                let height = height.max(1);
5520                match self.resize_behavior {
5521                    ResizeBehavior::Immediate => {
5522                        self.resize_coalescer
5523                            .record_external_apply(width, height, Instant::now());
5524                        let result = self.apply_resize(width, height, Duration::ZERO, false);
5525                        self.fairness_guard.event_processed(
5526                            fairness_event_type,
5527                            event_start.elapsed(),
5528                            Instant::now(),
5529                        );
5530                        return result;
5531                    }
5532                    ResizeBehavior::Throttled => {
5533                        let action = self.resize_coalescer.handle_resize(width, height);
5534                        if let CoalesceAction::ApplyResize {
5535                            width,
5536                            height,
5537                            coalesce_time,
5538                            forced_by_deadline,
5539                        } = action
5540                        {
5541                            let result =
5542                                self.apply_resize(width, height, coalesce_time, forced_by_deadline);
5543                            self.fairness_guard.event_processed(
5544                                fairness_event_type,
5545                                event_start.elapsed(),
5546                                Instant::now(),
5547                            );
5548                            return result;
5549                        }
5550
5551                        self.fairness_guard.event_processed(
5552                            fairness_event_type,
5553                            event_start.elapsed(),
5554                            Instant::now(),
5555                        );
5556                        return Ok(());
5557                    }
5558                }
5559            }
5560            other => other,
5561        };
5562
5563        let msg = M::Message::from(event);
5564        let cmd = {
5565            let _span = debug_span!(
5566                "ftui.program.update",
5567                msg_type = "event",
5568                duration_us = tracing::field::Empty,
5569                cmd_type = tracing::field::Empty
5570            )
5571            .entered();
5572            let start = Instant::now();
5573            let cmd = self.model.update(msg);
5574            let elapsed_us = start.elapsed().as_micros() as u64;
5575            self.last_update_us = Some(elapsed_us);
5576            tracing::Span::current().record("duration_us", elapsed_us);
5577            tracing::Span::current()
5578                .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5579            cmd
5580        };
5581        self.mark_dirty();
5582        self.execute_cmd(cmd)?;
5583        if self.running {
5584            self.reconcile_subscriptions();
5585        }
5586
5587        // Track input event processing for fairness.
5588        self.fairness_guard.event_processed(
5589            fairness_event_type,
5590            event_start.elapsed(),
5591            Instant::now(),
5592        );
5593
5594        Ok(())
5595    }
5596
5597    /// Classify an event for fairness tracking.
5598    fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
5599        match event {
5600            Event::Key(_)
5601            | Event::Mouse(_)
5602            | Event::Paste(_)
5603            | Event::Ime(_)
5604            | Event::Focus(_)
5605            | Event::Clipboard(_) => FairnessEventType::Input,
5606            Event::Resize { .. } => FairnessEventType::Resize,
5607            Event::Tick => FairnessEventType::Tick,
5608        }
5609    }
5610
5611    /// Reconcile the model's declared subscriptions with running ones.
5612    fn reconcile_subscriptions(&mut self) {
5613        let _span = debug_span!(
5614            "ftui.program.subscriptions",
5615            active_count = tracing::field::Empty,
5616            started = tracing::field::Empty,
5617            stopped = tracing::field::Empty
5618        )
5619        .entered();
5620        let subs = self.model.subscriptions();
5621        let before_count = self.subscriptions.active_count();
5622        self.subscriptions.reconcile(subs);
5623        let after_count = self.subscriptions.active_count();
5624        let started = after_count.saturating_sub(before_count);
5625        let stopped = before_count.saturating_sub(after_count);
5626        crate::debug_trace!(
5627            "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
5628            before_count,
5629            after_count,
5630            started,
5631            stopped
5632        );
5633        if after_count == 0 {
5634            crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5635        }
5636        let current = tracing::Span::current();
5637        current.record("active_count", after_count);
5638        // started/stopped would require tracking in SubscriptionManager
5639        current.record("started", started);
5640        current.record("stopped", stopped);
5641    }
5642
5643    /// Process pending messages from subscriptions.
5644    fn process_subscription_messages(&mut self) -> io::Result<()> {
5645        let messages = self.subscriptions.drain_messages();
5646        let msg_count = messages.len();
5647        if msg_count > 0 {
5648            crate::debug_trace!("processing {} subscription message(s)", msg_count);
5649        }
5650        for msg in messages {
5651            let cmd = {
5652                let _span = debug_span!(
5653                    "ftui.program.update",
5654                    msg_type = "subscription",
5655                    duration_us = tracing::field::Empty,
5656                    cmd_type = tracing::field::Empty
5657                )
5658                .entered();
5659                let start = Instant::now();
5660                let cmd = self.model.update(msg);
5661                let elapsed_us = start.elapsed().as_micros() as u64;
5662                self.last_update_us = Some(elapsed_us);
5663                tracing::Span::current().record("duration_us", elapsed_us);
5664                tracing::Span::current()
5665                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5666                cmd
5667            };
5668            self.mark_dirty();
5669            self.execute_cmd(cmd)?;
5670            if !self.running {
5671                break;
5672            }
5673        }
5674        if self.running && self.dirty {
5675            self.reconcile_subscriptions();
5676        }
5677        Ok(())
5678    }
5679
5680    /// Process results from background tasks.
5681    fn process_task_results(&mut self) -> io::Result<()> {
5682        while let Ok(msg) = self.task_receiver.try_recv() {
5683            let cmd = {
5684                let _span = debug_span!(
5685                    "ftui.program.update",
5686                    msg_type = "task",
5687                    duration_us = tracing::field::Empty,
5688                    cmd_type = tracing::field::Empty
5689                )
5690                .entered();
5691                let start = Instant::now();
5692                let cmd = self.model.update(msg);
5693                let elapsed_us = start.elapsed().as_micros() as u64;
5694                self.last_update_us = Some(elapsed_us);
5695                tracing::Span::current().record("duration_us", elapsed_us);
5696                tracing::Span::current()
5697                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5698                cmd
5699            };
5700            self.mark_dirty();
5701            self.execute_cmd(cmd)?;
5702            if !self.running {
5703                break;
5704            }
5705        }
5706        if self.running && self.dirty {
5707            self.reconcile_subscriptions();
5708        }
5709        Ok(())
5710    }
5711
5712    /// Execute a command.
5713    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5714        self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5715        match cmd {
5716            Cmd::None => {}
5717            Cmd::Quit => self.running = false,
5718            Cmd::Msg(m) => {
5719                let start = Instant::now();
5720                let cmd = self.model.update(m);
5721                let elapsed_us = start.elapsed().as_micros() as u64;
5722                self.last_update_us = Some(elapsed_us);
5723                self.mark_dirty();
5724                self.execute_cmd(cmd)?;
5725            }
5726            Cmd::Batch(cmds) => {
5727                // Batch currently executes sequentially. This is intentional
5728                // until an async runtime or task scheduler is added.
5729                for c in cmds {
5730                    self.execute_cmd(c)?;
5731                    if !self.running {
5732                        break;
5733                    }
5734                }
5735            }
5736            Cmd::Sequence(cmds) => {
5737                for c in cmds {
5738                    self.execute_cmd(c)?;
5739                    if !self.running {
5740                        break;
5741                    }
5742                }
5743            }
5744            Cmd::Tick(duration) => {
5745                self.tick_rate = Some(duration);
5746                self.last_tick = Instant::now();
5747            }
5748            Cmd::Log(text) => {
5749                let sanitized = sanitize(&text);
5750                let mut text_crlf = if sanitized.contains('\n') {
5751                    sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5752                } else {
5753                    sanitized.into_owned()
5754                };
5755                if !text_crlf.ends_with("\r\n") {
5756                    if text_crlf.ends_with('\n') {
5757                        text_crlf.pop();
5758                    }
5759                    text_crlf.push_str("\r\n");
5760                }
5761                self.writer.write_log(&text_crlf)?;
5762            }
5763            Cmd::Task(spec, f) => {
5764                crate::effect_system::record_command_effect("task", 0);
5765                self.task_executor.submit(spec, f);
5766            }
5767            Cmd::SaveState => {
5768                self.save_state();
5769            }
5770            Cmd::RestoreState => {
5771                self.load_state();
5772            }
5773            Cmd::SetMouseCapture(enabled) => {
5774                self.backend_features.mouse_capture = enabled;
5775                self.events.set_features(self.backend_features)?;
5776            }
5777            Cmd::SetTickStrategy(strategy) => {
5778                let new_name = strategy.name().to_owned();
5779                if let Some(mut previous) = self.tick_strategy.replace(strategy) {
5780                    let old_name = previous.name().to_owned();
5781                    previous.shutdown();
5782                    info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
5783                } else {
5784                    info!(new = %new_name, "tick strategy changed at runtime");
5785                }
5786                self.last_active_screen_for_strategy = None;
5787            }
5788        }
5789        Ok(())
5790    }
5791
5792    /// Detect active-screen transitions after any `update()` call and react:
5793    ///
5794    /// - **A.2** — notify the tick strategy via `on_screen_transition()` so
5795    ///   predictive strategies can learn navigation patterns.
5796    /// - **D.3** — force-tick the newly active screen so it renders fresh
5797    ///   content immediately, without waiting for the next tick interval.
5798    ///
5799    /// This is a no-op when no tick strategy is configured or when the model
5800    /// does not implement [`ScreenTickDispatch`].
5801    fn check_screen_transition(&mut self) {
5802        if self.tick_strategy.is_none() {
5803            return;
5804        }
5805
5806        // Snapshot the current active screen (releases &mut self.model).
5807        let current_active = match self.model.as_screen_tick_dispatch() {
5808            Some(dispatch) => dispatch.active_screen_id(),
5809            None => return,
5810        };
5811
5812        // First observation: just record, no transition event.
5813        let previous = match self.last_active_screen_for_strategy.take() {
5814            Some(prev) => prev,
5815            None => {
5816                self.last_active_screen_for_strategy = Some(current_active);
5817                return;
5818            }
5819        };
5820
5821        if previous == current_active {
5822            self.last_active_screen_for_strategy = Some(current_active);
5823            return;
5824        }
5825
5826        // A.2: Notify strategy of the transition.
5827        if let Some(strategy) = self.tick_strategy.as_mut() {
5828            strategy.on_screen_transition(&previous, &current_active);
5829        }
5830
5831        // D.3: Force-tick the newly active screen immediately.
5832        let mut force_ticked = false;
5833        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5834            dispatch.tick_screen(&current_active, self.tick_count);
5835            force_ticked = true;
5836        }
5837        if force_ticked && self.running {
5838            self.reconcile_subscriptions();
5839        }
5840
5841        self.last_active_screen_for_strategy = Some(current_active);
5842        self.mark_dirty();
5843    }
5844
5845    fn reap_finished_tasks(&mut self) {
5846        self.task_executor.reap_finished();
5847    }
5848
5849    fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
5850        while let Ok(msg) = self.task_receiver.try_recv() {
5851            let cmd = {
5852                let _span = debug_span!(
5853                    "ftui.program.update",
5854                    msg_type = "shutdown_task",
5855                    duration_us = tracing::field::Empty,
5856                    cmd_type = tracing::field::Empty
5857                )
5858                .entered();
5859                let start = Instant::now();
5860                let cmd = self.model.update(msg);
5861                let elapsed_us = start.elapsed().as_micros() as u64;
5862                self.last_update_us = Some(elapsed_us);
5863                tracing::Span::current().record("duration_us", elapsed_us);
5864                tracing::Span::current()
5865                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5866                cmd
5867            };
5868            self.mark_dirty();
5869            self.execute_cmd(cmd)?;
5870        }
5871        Ok(())
5872    }
5873
5874    /// Render a frame with budget tracking.
5875    fn render_frame(&mut self) -> io::Result<()> {
5876        crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
5877
5878        self.frame_idx = self.frame_idx.wrapping_add(1);
5879        let frame_idx = self.frame_idx;
5880        let degradation_start = self.budget.degradation();
5881
5882        // Reset budget for new frame, potentially upgrading quality
5883        self.budget.next_frame();
5884
5885        // Check frame guardrails (memory/queue limits)
5886        let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
5887        // Synchronous program has effectively zero queue depth.
5888        let verdict = self.guardrails.check_frame(memory_bytes, 0);
5889
5890        if verdict.should_drop_frame() {
5891            // Emergency shed: skip this frame entirely to prevent OOM
5892            return Ok(());
5893        }
5894
5895        if verdict.should_degrade() {
5896            // Apply guardrail-recommended degradation if it's stricter than budget's
5897            let current = self.budget.degradation();
5898            if verdict.recommended_level > current {
5899                self.budget.set_degradation(verdict.recommended_level);
5900            }
5901        }
5902
5903        // Apply conformal risk gate before rendering (if enabled)
5904        let mut conformal_prediction = None;
5905        if let Some(predictor) = self.conformal_predictor.as_ref() {
5906            let baseline_us = self
5907                .last_frame_time_us
5908                .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5909            let diff_strategy = self
5910                .writer
5911                .last_diff_strategy()
5912                .unwrap_or(DiffStrategy::Full);
5913            let frame_height_hint = self.writer.render_height_hint().max(1);
5914            let key = BucketKey::from_context(
5915                self.writer.screen_mode(),
5916                diff_strategy,
5917                self.width,
5918                frame_height_hint,
5919            );
5920            let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
5921            let prediction = predictor.predict(key, baseline_us, budget_us);
5922            if prediction.risk {
5923                self.budget.degrade();
5924                info!(
5925                    bucket = %prediction.bucket,
5926                    upper_us = prediction.upper_us,
5927                    budget_us = prediction.budget_us,
5928                    fallback_level = prediction.fallback_level,
5929                    degradation = self.budget.degradation().as_str(),
5930                    "conformal gate triggered strategy downgrade"
5931                );
5932                debug!(
5933                    monotonic.counter.conformal_gate_triggers_total = 1_u64,
5934                    bucket = %prediction.bucket,
5935                    "conformal gate trigger"
5936                );
5937            }
5938            debug!(
5939                bucket = %prediction.bucket,
5940                upper_us = prediction.upper_us,
5941                budget_us = prediction.budget_us,
5942                fallback = prediction.fallback_level,
5943                risk = prediction.risk,
5944                "conformal risk gate"
5945            );
5946            debug!(
5947                monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
5948                bucket = %prediction.bucket,
5949                "conformal prediction interval width"
5950            );
5951            conformal_prediction = Some(prediction);
5952        }
5953
5954        // Early skip if budget says to skip this frame entirely
5955        if self.budget.exhausted() {
5956            self.budget.record_frame_time(Duration::ZERO);
5957            let load_snapshot =
5958                self.update_load_governor_snapshot(frame_idx, 0.0, conformal_prediction.as_ref());
5959            self.emit_budget_evidence(
5960                frame_idx,
5961                degradation_start,
5962                0.0,
5963                conformal_prediction.as_ref(),
5964                &load_snapshot,
5965            );
5966            crate::debug_trace!(
5967                "frame skipped: budget exhausted (degradation={})",
5968                self.budget.degradation().as_str()
5969            );
5970            debug!(
5971                degradation = self.budget.degradation().as_str(),
5972                "frame skipped: budget exhausted before render"
5973            );
5974            // Keep dirty=true: the UI update was never presented, so a
5975            // future frame must still pick it up.
5976            return Ok(());
5977        }
5978
5979        let auto_bounds = self.writer.inline_auto_bounds();
5980        let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
5981        let mut should_measure = needs_measure;
5982        if auto_bounds.is_some()
5983            && let Some(state) = self.inline_auto_remeasure.as_mut()
5984        {
5985            let decision = state.sampler.decide(Instant::now());
5986            if decision.should_sample {
5987                should_measure = true;
5988            }
5989        } else {
5990            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
5991        }
5992
5993        // --- Render phase ---
5994        let render_start = Instant::now();
5995        if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
5996            let measure_height = if needs_measure {
5997                self.writer.render_height_hint().max(1)
5998            } else {
5999                max_height.max(1)
6000            };
6001            let (measure_buffer, _) = self.render_measure_buffer(measure_height);
6002            let measured_height = measure_buffer.content_height();
6003            let clamped = measured_height.clamp(min_height, max_height);
6004            let previous_height = self.writer.auto_ui_height();
6005            self.writer.set_auto_ui_height(clamped);
6006            if let Some(state) = self.inline_auto_remeasure.as_mut() {
6007                let threshold = state.config.change_threshold_rows;
6008                let violated = previous_height
6009                    .map(|prev| prev.abs_diff(clamped) >= threshold)
6010                    .unwrap_or(false);
6011                state.sampler.observe(violated);
6012            }
6013        }
6014        if auto_bounds.is_some()
6015            && let Some(state) = self.inline_auto_remeasure.as_ref()
6016        {
6017            let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
6018            crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
6019        }
6020
6021        let frame_height = self.writer.render_height_hint().max(1);
6022        let _frame_span = info_span!(
6023            "ftui.render.frame",
6024            width = self.width,
6025            height = frame_height,
6026            duration_us = tracing::field::Empty
6027        )
6028        .entered();
6029        let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
6030        self.update_widget_refresh_plan(frame_idx);
6031        let render_elapsed = render_start.elapsed();
6032        let mut present_elapsed = Duration::ZERO;
6033        let mut presented = false;
6034
6035        // Check if render phase overspent its budget
6036        let render_budget = self.budget.phase_budgets().render;
6037        if render_elapsed > render_budget {
6038            debug!(
6039                render_ms = render_elapsed.as_millis() as u32,
6040                budget_ms = render_budget.as_millis() as u32,
6041                "render phase exceeded budget"
6042            );
6043            // With the load governor active, the controller decides degradation
6044            // from measured frame history at the next frame boundary. The
6045            // legacy path keeps its immediate threshold fallback.
6046            if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
6047                self.budget.degrade();
6048            }
6049        }
6050
6051        // --- Present phase ---
6052        if !self.budget.exhausted() {
6053            let present_start = Instant::now();
6054            {
6055                let _present_span = debug_span!("ftui.render.present").entered();
6056                self.writer
6057                    .present_ui_owned(buffer, cursor, cursor_visible)?;
6058            }
6059            presented = true;
6060            present_elapsed = present_start.elapsed();
6061
6062            let present_budget = self.budget.phase_budgets().present;
6063            if present_elapsed > present_budget {
6064                debug!(
6065                    present_ms = present_elapsed.as_millis() as u32,
6066                    budget_ms = present_budget.as_millis() as u32,
6067                    "present phase exceeded budget"
6068                );
6069            }
6070        } else {
6071            debug!(
6072                degradation = self.budget.degradation().as_str(),
6073                elapsed_ms = self.budget.elapsed().as_millis() as u32,
6074                "frame present skipped: budget exhausted after render"
6075            );
6076        }
6077
6078        if let Some(ref frame_timing) = self.frame_timing {
6079            let update_us = self.last_update_us.unwrap_or(0);
6080            let render_us = render_elapsed.as_micros() as u64;
6081            let present_us = present_elapsed.as_micros() as u64;
6082            let diff_us = if presented {
6083                self.writer
6084                    .take_last_present_timings()
6085                    .map(|timings| timings.diff_us)
6086                    .unwrap_or(0)
6087            } else {
6088                let _ = self.writer.take_last_present_timings();
6089                0
6090            };
6091            let total_us = update_us
6092                .saturating_add(render_us)
6093                .saturating_add(present_us);
6094            let timing = FrameTiming {
6095                frame_idx,
6096                update_us,
6097                render_us,
6098                diff_us,
6099                present_us,
6100                total_us,
6101            };
6102            frame_timing.sink.record_frame(&timing);
6103        }
6104
6105        let frame_time = render_elapsed.saturating_add(present_elapsed);
6106        self.budget.record_frame_time(frame_time);
6107        let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
6108
6109        if let (Some(predictor), Some(prediction)) = (
6110            self.conformal_predictor.as_mut(),
6111            conformal_prediction.as_ref(),
6112        ) {
6113            let diff_strategy = self
6114                .writer
6115                .last_diff_strategy()
6116                .unwrap_or(DiffStrategy::Full);
6117            let key = BucketKey::from_context(
6118                self.writer.screen_mode(),
6119                diff_strategy,
6120                self.width,
6121                frame_height,
6122            );
6123            predictor.observe(key, prediction.y_hat, frame_time_us);
6124        }
6125        self.last_frame_time_us = Some(frame_time_us);
6126        let load_snapshot = self.update_load_governor_snapshot(
6127            frame_idx,
6128            frame_time_us,
6129            conformal_prediction.as_ref(),
6130        );
6131        self.emit_budget_evidence(
6132            frame_idx,
6133            degradation_start,
6134            frame_time_us,
6135            conformal_prediction.as_ref(),
6136            &load_snapshot,
6137        );
6138
6139        // Only clear dirty when the frame was actually presented.
6140        // If present was skipped (budget exhausted after render), the UI
6141        // update was never shown and must be retried on the next frame.
6142        if presented {
6143            self.dirty = false;
6144        }
6145
6146        Ok(())
6147    }
6148
6149    fn update_load_governor_snapshot(
6150        &mut self,
6151        _frame_idx: u64,
6152        frame_time_us: f64,
6153        conformal_prediction: Option<&ConformalPrediction>,
6154    ) -> LoadGovernorSnapshot {
6155        let budget_us = conformal_prediction
6156            .map(|prediction| prediction.budget_us)
6157            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6158        let resize_stats = self.resize_coalescer.stats();
6159        self.load_governor.observe(LoadGovernorObservation {
6160            frame_time_us,
6161            budget_us,
6162            degradation: self.budget.degradation(),
6163            queue: crate::effect_system::queue_telemetry(),
6164            resize_coalescing_active: resize_stats.has_pending
6165                || !matches!(resize_stats.regime, crate::resize_coalescer::Regime::Steady),
6166            strict_semantics_violation: false,
6167        })
6168    }
6169
6170    fn emit_budget_evidence(
6171        &self,
6172        frame_idx: u64,
6173        degradation_start: DegradationLevel,
6174        frame_time_us: f64,
6175        conformal_prediction: Option<&ConformalPrediction>,
6176        load_snapshot: &LoadGovernorSnapshot,
6177    ) {
6178        let Some(telemetry) = self.budget.telemetry() else {
6179            set_budget_snapshot(None);
6180            return;
6181        };
6182
6183        let budget_us = conformal_prediction
6184            .map(|prediction| prediction.budget_us)
6185            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6186        let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
6187        let degradation_after = self.budget.degradation();
6188
6189        let evidence = BudgetDecisionEvidence {
6190            frame_idx,
6191            decision: BudgetDecisionEvidence::decision_from_levels(
6192                degradation_start,
6193                degradation_after,
6194            ),
6195            controller_decision: telemetry.last_decision,
6196            degradation_before: degradation_start,
6197            degradation_after,
6198            frame_time_us,
6199            budget_us,
6200            pid_output: telemetry.pid_output,
6201            pid_p: telemetry.pid_p,
6202            pid_i: telemetry.pid_i,
6203            pid_d: telemetry.pid_d,
6204            e_value: telemetry.e_value,
6205            frames_observed: telemetry.frames_observed,
6206            frames_since_change: telemetry.frames_since_change,
6207            in_warmup: telemetry.in_warmup,
6208            controller_reason: telemetry.decision_reason,
6209            load_governor: *load_snapshot,
6210            conformal,
6211        };
6212
6213        let conformal_snapshot = evidence
6214            .conformal
6215            .as_ref()
6216            .map(|snapshot| ConformalSnapshot {
6217                bucket_key: snapshot.bucket_key.clone(),
6218                sample_count: snapshot.n_b,
6219                upper_us: snapshot.upper_us,
6220                risk: snapshot.risk,
6221            });
6222        set_budget_snapshot(Some(BudgetDecisionSnapshot {
6223            frame_idx: evidence.frame_idx,
6224            decision: evidence.decision,
6225            controller_decision: evidence.controller_decision,
6226            degradation_before: evidence.degradation_before,
6227            degradation_after: evidence.degradation_after,
6228            frame_time_us: evidence.frame_time_us,
6229            budget_us: evidence.budget_us,
6230            pid_output: evidence.pid_output,
6231            e_value: evidence.e_value,
6232            frames_observed: evidence.frames_observed,
6233            frames_since_change: evidence.frames_since_change,
6234            in_warmup: evidence.in_warmup,
6235            conformal: conformal_snapshot,
6236        }));
6237
6238        if let Some(ref sink) = self.evidence_sink {
6239            let _ = sink.write_jsonl(&evidence.to_jsonl());
6240        }
6241    }
6242
6243    fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
6244        if !self.widget_refresh_config.enabled {
6245            self.widget_refresh_plan.clear();
6246            return;
6247        }
6248
6249        let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
6250        let degradation = self.budget.degradation();
6251        self.widget_refresh_plan.recompute(
6252            frame_idx,
6253            budget_us,
6254            degradation,
6255            &self.widget_signals,
6256            &self.widget_refresh_config,
6257        );
6258
6259        if let Some(ref sink) = self.evidence_sink {
6260            let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
6261        }
6262    }
6263
6264    fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
6265        // Reset the per-frame arena so widgets get fresh scratch space.
6266        self.frame_arena.reset();
6267
6268        // Note: Frame borrows the pool and links from writer.
6269        // We scope it so it drops before we call present_ui (which needs exclusive writer access).
6270        let buffer = self.writer.take_render_buffer(self.width, frame_height);
6271        let (pool, links) = self.writer.pool_and_links_mut();
6272        let mut frame = Frame::from_buffer(buffer, pool);
6273        frame.set_degradation(self.budget.degradation());
6274        frame.set_links(links);
6275        frame.set_widget_budget(self.widget_refresh_plan.as_budget());
6276        frame.set_arena(&self.frame_arena);
6277
6278        let view_start = Instant::now();
6279        let _view_span = debug_span!(
6280            "ftui.program.view",
6281            duration_us = tracing::field::Empty,
6282            widget_count = tracing::field::Empty
6283        )
6284        .entered();
6285        self.model.view(&mut frame);
6286        self.widget_signals = frame.take_widget_signals();
6287        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6288        // widget_count would require tracking in Frame
6289
6290        (frame.buffer, frame.cursor_position, frame.cursor_visible)
6291    }
6292
6293    fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
6294        let Some(ref sink) = self.evidence_sink else {
6295            return;
6296        };
6297
6298        let config = self.fairness_guard.config();
6299        if !self.fairness_config_logged {
6300            let config_entry = FairnessConfigEvidence {
6301                enabled: config.enabled,
6302                input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6303                dominance_threshold: config.dominance_threshold,
6304                fairness_threshold: config.fairness_threshold,
6305            };
6306            let _ = sink.write_jsonl(&config_entry.to_jsonl());
6307            self.fairness_config_logged = true;
6308        }
6309
6310        let evidence = FairnessDecisionEvidence {
6311            frame_idx: self.frame_idx,
6312            decision: if decision.should_process {
6313                "allow"
6314            } else {
6315                "yield"
6316            },
6317            reason: decision.reason.as_str(),
6318            pending_input_latency_ms: decision
6319                .pending_input_latency
6320                .map(|latency| latency.as_millis() as u64),
6321            jain_index: decision.jain_index,
6322            resize_dominance_count: dominance_count,
6323            dominance_threshold: config.dominance_threshold,
6324            fairness_threshold: config.fairness_threshold,
6325            input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6326        };
6327
6328        let _ = sink.write_jsonl(&evidence.to_jsonl());
6329    }
6330
6331    fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
6332        // Reset the per-frame arena for measurement pass.
6333        self.frame_arena.reset();
6334
6335        let pool = self.writer.pool_mut();
6336        let mut frame = Frame::new(self.width, frame_height, pool);
6337        frame.set_degradation(self.budget.degradation());
6338        frame.set_arena(&self.frame_arena);
6339
6340        let view_start = Instant::now();
6341        let _view_span = debug_span!(
6342            "ftui.program.view",
6343            duration_us = tracing::field::Empty,
6344            widget_count = tracing::field::Empty
6345        )
6346        .entered();
6347        self.model.view(&mut frame);
6348        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6349
6350        (frame.buffer, frame.cursor_position)
6351    }
6352
6353    /// Calculate the effective poll timeout.
6354    fn effective_timeout(&self) -> Duration {
6355        if let Some(tick_rate) = self.tick_rate {
6356            let elapsed = self.last_tick.elapsed();
6357            let mut timeout = tick_rate.saturating_sub(elapsed);
6358            if self.resize_behavior.uses_coalescer()
6359                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6360            {
6361                timeout = timeout.min(resize_timeout);
6362            }
6363            timeout
6364        } else {
6365            let mut timeout = self.poll_timeout;
6366            if self.resize_behavior.uses_coalescer()
6367                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6368            {
6369                timeout = timeout.min(resize_timeout);
6370            }
6371            timeout
6372        }
6373    }
6374
6375    /// Check if we should send a tick.
6376    fn should_tick(&mut self) -> bool {
6377        if let Some(tick_rate) = self.tick_rate
6378            && self.last_tick.elapsed() >= tick_rate
6379        {
6380            self.last_tick = Instant::now();
6381            return true;
6382        }
6383        false
6384    }
6385
6386    fn process_resize_coalescer(&mut self) -> io::Result<()> {
6387        if !self.resize_behavior.uses_coalescer() {
6388            return Ok(());
6389        }
6390
6391        // Check fairness: if input is starving, skip resize application this cycle.
6392        // This ensures input events are processed before resize is finalized.
6393        let dominance_count = self.fairness_guard.resize_dominance_count();
6394        let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
6395        self.emit_fairness_evidence(&fairness_decision, dominance_count);
6396        if !fairness_decision.should_process {
6397            debug!(
6398                reason = ?fairness_decision.reason,
6399                pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
6400                "Resize yielding to input for fairness"
6401            );
6402            // Skip resize application this cycle to allow input processing.
6403            return Ok(());
6404        }
6405
6406        let action = self.resize_coalescer.tick();
6407        let resize_snapshot =
6408            self.resize_coalescer
6409                .logs()
6410                .last()
6411                .map(|entry| ResizeDecisionSnapshot {
6412                    event_idx: entry.event_idx,
6413                    action: entry.action,
6414                    dt_ms: entry.dt_ms,
6415                    event_rate: entry.event_rate,
6416                    regime: entry.regime,
6417                    pending_size: entry.pending_size,
6418                    applied_size: entry.applied_size,
6419                    time_since_render_ms: entry.time_since_render_ms,
6420                    bocpd: self
6421                        .resize_coalescer
6422                        .bocpd()
6423                        .and_then(|detector| detector.last_evidence().cloned()),
6424                });
6425        set_resize_snapshot(resize_snapshot);
6426
6427        match action {
6428            CoalesceAction::ApplyResize {
6429                width,
6430                height,
6431                coalesce_time,
6432                forced_by_deadline,
6433            } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
6434            _ => Ok(()),
6435        }
6436    }
6437
6438    fn apply_resize(
6439        &mut self,
6440        width: u16,
6441        height: u16,
6442        coalesce_time: Duration,
6443        forced_by_deadline: bool,
6444    ) -> io::Result<()> {
6445        // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
6446        let width = width.max(1);
6447        let height = height.max(1);
6448        self.width = width;
6449        self.height = height;
6450        self.writer.set_size(width, height);
6451        info!(
6452            width = width,
6453            height = height,
6454            coalesce_ms = coalesce_time.as_millis() as u64,
6455            forced = forced_by_deadline,
6456            "Resize applied"
6457        );
6458
6459        let msg = M::Message::from(Event::Resize { width, height });
6460        let start = Instant::now();
6461        let cmd = self.model.update(msg);
6462        let elapsed_us = start.elapsed().as_micros() as u64;
6463        self.last_update_us = Some(elapsed_us);
6464        self.mark_dirty();
6465        self.execute_cmd(cmd)?;
6466        if self.running && self.dirty {
6467            self.reconcile_subscriptions();
6468        }
6469        Ok(())
6470    }
6471
6472    // removed: resize placeholder rendering (continuous reflow preferred)
6473
6474    /// Get a reference to the model.
6475    pub fn model(&self) -> &M {
6476        &self.model
6477    }
6478
6479    /// Get a mutable reference to the model.
6480    pub fn model_mut(&mut self) -> &mut M {
6481        &mut self.model
6482    }
6483
6484    /// Check if the program is running.
6485    pub fn is_running(&self) -> bool {
6486        self.running
6487    }
6488
6489    /// Get the current tick rate, if one has been installed.
6490    #[must_use]
6491    pub const fn tick_rate(&self) -> Option<Duration> {
6492        self.tick_rate
6493    }
6494
6495    /// Get the number of commands actually executed by the runtime.
6496    #[must_use]
6497    pub const fn executed_cmd_count(&self) -> usize {
6498        self.executed_cmd_count
6499    }
6500
6501    /// Request a quit.
6502    pub fn quit(&mut self) {
6503        self.running = false;
6504    }
6505
6506    /// Get a reference to the state registry, if configured.
6507    pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
6508        self.state_registry.as_ref()
6509    }
6510
6511    /// Check if state persistence is enabled.
6512    pub fn has_persistence(&self) -> bool {
6513        self.state_registry.is_some()
6514    }
6515
6516    /// Query the current tick strategy's debug statistics.
6517    ///
6518    /// Returns key-value pairs describing the strategy's internal state
6519    /// (e.g. strategy name, divisors, confidence, transition counts).
6520    /// Returns an empty vec if no tick strategy is configured.
6521    #[must_use]
6522    pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
6523        self.tick_strategy
6524            .as_ref()
6525            .map(|s| s.debug_stats())
6526            .unwrap_or_default()
6527    }
6528
6529    /// Trigger a manual save of widget state.
6530    ///
6531    /// Returns the result of the flush operation, or `Ok(false)` if
6532    /// persistence is not configured.
6533    pub fn trigger_save(&mut self) -> StorageResult<bool> {
6534        if let Some(registry) = &self.state_registry {
6535            registry.flush()
6536        } else {
6537            Ok(false)
6538        }
6539    }
6540
6541    /// Trigger a manual load of widget state.
6542    ///
6543    /// Returns the number of entries loaded, or `Ok(0)` if persistence
6544    /// is not configured.
6545    pub fn trigger_load(&mut self) -> StorageResult<usize> {
6546        if let Some(registry) = &self.state_registry {
6547            registry.load()
6548        } else {
6549            Ok(0)
6550        }
6551    }
6552
6553    fn mark_dirty(&mut self) {
6554        self.dirty = true;
6555    }
6556
6557    fn check_locale_change(&mut self) {
6558        let version = self.locale_context.version();
6559        if version != self.locale_version {
6560            self.locale_version = version;
6561            self.mark_dirty();
6562        }
6563    }
6564
6565    /// Mark the UI as needing redraw.
6566    pub fn request_redraw(&mut self) {
6567        self.mark_dirty();
6568    }
6569
6570    /// Request a re-measure of inline auto UI height on next render.
6571    pub fn request_ui_height_remeasure(&mut self) {
6572        if self.writer.inline_auto_bounds().is_some() {
6573            self.writer.clear_auto_ui_height();
6574            if let Some(state) = self.inline_auto_remeasure.as_mut() {
6575                state.reset();
6576            }
6577            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6578            self.mark_dirty();
6579        }
6580    }
6581
6582    /// Start recording events into a macro.
6583    ///
6584    /// If already recording, the current recording is discarded and a new one starts.
6585    /// The current terminal size is captured as metadata.
6586    pub fn start_recording(&mut self, name: impl Into<String>) {
6587        let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
6588        recorder.start();
6589        self.event_recorder = Some(recorder);
6590    }
6591
6592    /// Stop recording and return the recorded macro, if any.
6593    ///
6594    /// Returns `None` if not currently recording.
6595    pub fn stop_recording(&mut self) -> Option<InputMacro> {
6596        self.event_recorder.take().map(EventRecorder::finish)
6597    }
6598
6599    /// Check if event recording is active.
6600    pub fn is_recording(&self) -> bool {
6601        self.event_recorder
6602            .as_ref()
6603            .is_some_and(EventRecorder::is_recording)
6604    }
6605}
6606
6607/// Builder for creating and running programs.
6608pub struct App;
6609
6610impl App {
6611    /// Create a new app builder with the given model.
6612    #[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
6613    pub fn new<M: Model>(model: M) -> AppBuilder<M> {
6614        AppBuilder {
6615            model,
6616            config: ProgramConfig::default(),
6617        }
6618    }
6619
6620    /// Create a fullscreen app.
6621    pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
6622        AppBuilder {
6623            model,
6624            config: ProgramConfig::fullscreen(),
6625        }
6626    }
6627
6628    /// Create an inline app with the given height.
6629    pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
6630        AppBuilder {
6631            model,
6632            config: ProgramConfig::inline(height),
6633        }
6634    }
6635
6636    /// Create an inline app with automatic UI height.
6637    pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
6638        AppBuilder {
6639            model,
6640            config: ProgramConfig::inline_auto(min_height, max_height),
6641        }
6642    }
6643
6644    /// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
6645    ///
6646    /// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
6647    /// so that `view_string()` output is rendered through the standard kernel pipeline.
6648    pub fn string_model<S: crate::string_model::StringModel>(
6649        model: S,
6650    ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
6651        AppBuilder {
6652            model: crate::string_model::StringModelAdapter::new(model),
6653            config: ProgramConfig::fullscreen(),
6654        }
6655    }
6656}
6657
6658/// Builder for configuring and running programs.
6659#[must_use]
6660pub struct AppBuilder<M: Model> {
6661    model: M,
6662    config: ProgramConfig,
6663}
6664
6665impl<M: Model> AppBuilder<M> {
6666    /// Set the screen mode.
6667    pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6668        self.config.screen_mode = mode;
6669        self
6670    }
6671
6672    /// Set the UI anchor.
6673    pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6674        self.config.ui_anchor = anchor;
6675        self
6676    }
6677
6678    /// Force mouse capture on.
6679    pub fn with_mouse(mut self) -> Self {
6680        self.config.mouse_capture_policy = MouseCapturePolicy::On;
6681        self
6682    }
6683
6684    /// Set mouse capture policy for this app.
6685    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6686        self.config.mouse_capture_policy = policy;
6687        self
6688    }
6689
6690    /// Force mouse capture enabled/disabled for this app.
6691    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6692        self.config.mouse_capture_policy = if enabled {
6693            MouseCapturePolicy::On
6694        } else {
6695            MouseCapturePolicy::Off
6696        };
6697        self
6698    }
6699
6700    /// Set the frame budget configuration.
6701    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6702        self.config.budget = budget;
6703        self
6704    }
6705
6706    /// Set the runtime load-governor configuration.
6707    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
6708        self.config.load_governor = config;
6709        self
6710    }
6711
6712    /// Disable the adaptive load governor for this app.
6713    pub fn without_load_governor(mut self) -> Self {
6714        self.config.load_governor = LoadGovernorConfig::disabled();
6715        self
6716    }
6717
6718    /// Set the evidence JSONL sink configuration.
6719    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
6720        self.config.evidence_sink = config;
6721        self
6722    }
6723
6724    /// Set the render-trace recorder configuration.
6725    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
6726        self.config.render_trace = config;
6727        self
6728    }
6729
6730    /// Set the widget refresh selection configuration.
6731    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
6732        self.config.widget_refresh = config;
6733        self
6734    }
6735
6736    /// Set the effect queue scheduling configuration.
6737    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
6738        self.config.effect_queue = config;
6739        self
6740    }
6741
6742    /// Enable inline auto UI height remeasurement.
6743    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
6744        self.config.inline_auto_remeasure = Some(config);
6745        self
6746    }
6747
6748    /// Disable inline auto UI height remeasurement.
6749    pub fn without_inline_auto_remeasure(mut self) -> Self {
6750        self.config.inline_auto_remeasure = None;
6751        self
6752    }
6753
6754    /// Set the locale context used for rendering.
6755    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
6756        self.config.locale_context = locale_context;
6757        self
6758    }
6759
6760    /// Set the base locale used for rendering.
6761    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
6762        self.config.locale_context = LocaleContext::new(locale);
6763        self
6764    }
6765
6766    /// Set the resize coalescer configuration.
6767    pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
6768        self.config.resize_coalescer = config;
6769        self
6770    }
6771
6772    /// Set the resize handling behavior.
6773    pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
6774        self.config.resize_behavior = behavior;
6775        self
6776    }
6777
6778    /// Toggle legacy immediate-resize behavior for migration.
6779    pub fn legacy_resize(mut self, enabled: bool) -> Self {
6780        if enabled {
6781            self.config.resize_behavior = ResizeBehavior::Immediate;
6782        }
6783        self
6784    }
6785
6786    /// Set the tick strategy for selective background screen ticking.
6787    pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
6788        self.config.tick_strategy = Some(strategy);
6789        self
6790    }
6791
6792    /// Run the application using the legacy Crossterm backend.
6793    #[cfg(feature = "crossterm-compat")]
6794    pub fn run(self) -> io::Result<()>
6795    where
6796        M::Message: Send + 'static,
6797    {
6798        let mut program = Program::with_config(self.model, self.config)?;
6799        let result = program.run();
6800        if let Err(ref err) = result
6801            && let Some(signal) = signal_termination_from_error(err)
6802        {
6803            drop(program);
6804            std::process::exit(128 + signal);
6805        }
6806        result
6807    }
6808
6809    /// Run the application using the native TTY backend.
6810    #[cfg(all(feature = "native-backend", unix))]
6811    pub fn run_native(self) -> io::Result<()>
6812    where
6813        M::Message: Send + 'static,
6814    {
6815        let mut program = Program::with_native_backend(self.model, self.config)?;
6816        let result = program.run();
6817        if let Err(ref err) = result
6818            && let Some(signal) = signal_termination_from_error(err)
6819        {
6820            drop(program);
6821            std::process::exit(128 + signal);
6822        }
6823        result
6824    }
6825
6826    /// Run the application using the legacy Crossterm backend.
6827    #[cfg(not(feature = "crossterm-compat"))]
6828    pub fn run(self) -> io::Result<()>
6829    where
6830        M::Message: Send + 'static,
6831    {
6832        let _ = (self.model, self.config);
6833        Err(io::Error::new(
6834            io::ErrorKind::Unsupported,
6835            "enable `crossterm-compat` feature to use AppBuilder::run()",
6836        ))
6837    }
6838
6839    /// Run the application using the native TTY backend.
6840    ///
6841    /// On non-Unix targets the native backend is unavailable; call
6842    /// [`AppBuilder::run`] (with `crossterm-compat`) instead.
6843    #[cfg(any(not(feature = "native-backend"), not(unix)))]
6844    pub fn run_native(self) -> io::Result<()>
6845    where
6846        M::Message: Send + 'static,
6847    {
6848        let _ = (self.model, self.config);
6849        // Prefer the platform-level message: a Windows user without
6850        // `native-backend` enabled would otherwise be told to enable the
6851        // feature, only to discover after rebuilding that it's still
6852        // Unix-only. Pointing them at crossterm-compat up front avoids the
6853        // two-step debug.
6854        #[cfg(not(unix))]
6855        let msg = "AppBuilder::run_native() is Unix-only; use AppBuilder::run() (crossterm-compat) on this platform";
6856        #[cfg(all(unix, not(feature = "native-backend")))]
6857        let msg = "enable `native-backend` feature to use AppBuilder::run_native()";
6858        Err(io::Error::new(io::ErrorKind::Unsupported, msg))
6859    }
6860}
6861
6862// =============================================================================
6863// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
6864// =============================================================================
6865//
6866// # M/G/1 Queueing Model for Event Batching
6867//
6868// ## Problem
6869//
6870// The event loop must balance two objectives:
6871// 1. **Low latency**: Process events quickly (small batch window τ).
6872// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
6873//
6874// ## Model
6875//
6876// We model the event loop as an M/G/1 queue:
6877// - Events arrive at rate λ (Poisson process, reasonable for human input).
6878// - Service time S has mean E[S] and variance Var[S] (render + present).
6879// - Utilization ρ = λ·E[S] must be < 1 for stability.
6880//
6881// ## Pollaczek–Khinchine Mean Waiting Time
6882//
6883// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
6884// where E[S²] = Var[S] + E[S]².
6885//
6886// ## Optimal Batch Window τ
6887//
6888// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
6889// the per-frame render cost. The effective per-event latency is:
6890//
6891//   L(τ) = τ/2 + E[S]
6892//         (waiting in batch)  (service)
6893//
6894// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
6895// giving utilization ρ_eff = E[S]/τ.
6896//
6897// Minimizing L(τ) subject to ρ_eff < 1:
6898//   L(τ) = τ/2 + E[S]
6899//   dL/dτ = 1/2  (always positive, so smaller τ is always better for latency)
6900//
6901// But we need ρ_eff < 1, so τ > E[S].
6902//
6903// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
6904// where headroom_factor provides margin (typically 1.5–2.0).
6905//
6906// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
6907// where λ_target is the max frame rate we want to sustain.
6908//
6909// ## Failure Modes
6910//
6911// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
6912//    (degrade to lower frame rate), or drop stale events.
6913// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
6914//    exponential moving average of λ smooths this; high burst periods
6915//    temporarily increase τ.
6916// 3. **Variable service time**: Render complexity varies per frame. Using
6917//    EMA of E[S] tracks this adaptively.
6918//
6919// ## Observable Telemetry
6920//
6921// - λ_est: Exponential moving average of inter-arrival times.
6922// - es_est: Exponential moving average of service (render) times.
6923// - ρ_est: λ_est × es_est (estimated utilization).
6924
6925/// Adaptive batch window controller based on M/G/1 queueing model.
6926///
6927/// Estimates arrival rate λ and service time `E[S]` from observations,
6928/// then computes the optimal batch window τ to maintain stability
6929/// (ρ < 1) while minimizing latency.
6930#[derive(Debug, Clone)]
6931pub struct BatchController {
6932    /// Exponential moving average of inter-arrival time (seconds).
6933    ema_inter_arrival_s: f64,
6934    /// Exponential moving average of service time (seconds).
6935    ema_service_s: f64,
6936    /// EMA smoothing factor (0..1). Higher = more responsive.
6937    alpha: f64,
6938    /// Minimum batch window (floor).
6939    tau_min_s: f64,
6940    /// Maximum batch window (cap for responsiveness).
6941    tau_max_s: f64,
6942    /// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
6943    headroom: f64,
6944    /// Last event arrival timestamp.
6945    last_arrival: Option<Instant>,
6946    /// Number of observations.
6947    observations: u64,
6948}
6949
6950impl BatchController {
6951    /// Create a new controller with sensible defaults.
6952    ///
6953    /// - `alpha`: EMA smoothing (default 0.2)
6954    /// - `tau_min`: minimum batch window (default 1ms)
6955    /// - `tau_max`: maximum batch window (default 50ms)
6956    /// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
6957    pub fn new() -> Self {
6958        Self {
6959            ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
6960            ema_service_s: 0.002,     // assume 2ms render initially
6961            alpha: 0.2,
6962            tau_min_s: 0.001, // 1ms floor
6963            tau_max_s: 0.050, // 50ms cap
6964            headroom: 2.0,
6965            last_arrival: None,
6966            observations: 0,
6967        }
6968    }
6969
6970    /// Record an event arrival, updating the inter-arrival estimate.
6971    pub fn observe_arrival(&mut self, now: Instant) {
6972        if let Some(last) = self.last_arrival {
6973            let dt = now.saturating_duration_since(last).as_secs_f64();
6974            if dt > 0.0 && dt < 10.0 {
6975                // Guard against stale gaps (e.g., app was suspended)
6976                self.ema_inter_arrival_s =
6977                    self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
6978                self.observations += 1;
6979            }
6980        }
6981        self.last_arrival = Some(now);
6982    }
6983
6984    /// Record a service (render) time observation.
6985    pub fn observe_service(&mut self, duration: Duration) {
6986        let dt = duration.as_secs_f64();
6987        if (0.0..10.0).contains(&dt) {
6988            self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
6989        }
6990    }
6991
6992    /// Estimated arrival rate λ (events/second).
6993    #[inline]
6994    pub fn lambda_est(&self) -> f64 {
6995        if self.ema_inter_arrival_s > 0.0 {
6996            1.0 / self.ema_inter_arrival_s
6997        } else {
6998            0.0
6999        }
7000    }
7001
7002    /// Estimated service time `E[S]` (seconds).
7003    #[inline]
7004    pub fn service_est_s(&self) -> f64 {
7005        self.ema_service_s
7006    }
7007
7008    /// Estimated utilization ρ = λ × `E[S]`.
7009    #[inline]
7010    pub fn rho_est(&self) -> f64 {
7011        self.lambda_est() * self.ema_service_s
7012    }
7013
7014    /// Compute the optimal batch window τ (seconds).
7015    ///
7016    /// τ = clamp(`E[S]` × headroom, τ_min, τ_max)
7017    ///
7018    /// When ρ approaches 1, τ increases to maintain stability.
7019    pub fn tau_s(&self) -> f64 {
7020        let base = self.ema_service_s * self.headroom;
7021        base.clamp(self.tau_min_s, self.tau_max_s)
7022    }
7023
7024    /// Compute the optimal batch window as a Duration.
7025    pub fn tau(&self) -> Duration {
7026        Duration::from_secs_f64(self.tau_s())
7027    }
7028
7029    /// Check if the system is stable (ρ < 1).
7030    #[inline]
7031    pub fn is_stable(&self) -> bool {
7032        self.rho_est() < 1.0
7033    }
7034
7035    /// Number of observations recorded.
7036    #[inline]
7037    pub fn observations(&self) -> u64 {
7038        self.observations
7039    }
7040}
7041
7042impl Default for BatchController {
7043    fn default() -> Self {
7044        Self::new()
7045    }
7046}
7047
7048#[cfg(test)]
7049mod tests {
7050    use super::*;
7051    use ftui_core::terminal_capabilities::TerminalCapabilities;
7052    use ftui_layout::PaneDragResizeEffect;
7053    use ftui_render::buffer::Buffer;
7054    use ftui_render::cell::Cell;
7055    use ftui_render::diff_strategy::DiffStrategy;
7056    use ftui_render::frame::CostEstimateSource;
7057    use serde_json::Value;
7058    use std::collections::{HashMap, VecDeque};
7059    use std::path::PathBuf;
7060    use std::sync::mpsc;
7061    use std::sync::{
7062        Arc,
7063        atomic::{AtomicUsize, Ordering},
7064    };
7065
7066    // Simple test model
7067    struct TestModel {
7068        value: i32,
7069    }
7070
7071    #[derive(Debug)]
7072    enum TestMsg {
7073        Increment,
7074        Decrement,
7075        Quit,
7076    }
7077
7078    impl From<Event> for TestMsg {
7079        fn from(_event: Event) -> Self {
7080            TestMsg::Increment
7081        }
7082    }
7083
7084    impl Model for TestModel {
7085        type Message = TestMsg;
7086
7087        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7088            match msg {
7089                TestMsg::Increment => {
7090                    self.value += 1;
7091                    Cmd::none()
7092                }
7093                TestMsg::Decrement => {
7094                    self.value -= 1;
7095                    Cmd::none()
7096                }
7097                TestMsg::Quit => Cmd::quit(),
7098            }
7099        }
7100
7101        fn view(&self, _frame: &mut Frame) {
7102            // No-op for tests
7103        }
7104    }
7105
7106    #[test]
7107    fn cmd_none() {
7108        let cmd: Cmd<TestMsg> = Cmd::none();
7109        assert!(matches!(cmd, Cmd::None));
7110    }
7111
7112    #[test]
7113    fn cmd_quit() {
7114        let cmd: Cmd<TestMsg> = Cmd::quit();
7115        assert!(matches!(cmd, Cmd::Quit));
7116    }
7117
7118    #[test]
7119    fn cmd_msg() {
7120        let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7121        assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
7122    }
7123
7124    #[test]
7125    fn cmd_batch_empty() {
7126        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
7127        assert!(matches!(cmd, Cmd::None));
7128    }
7129
7130    #[test]
7131    fn cmd_batch_single() {
7132        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
7133        assert!(matches!(cmd, Cmd::Quit));
7134    }
7135
7136    #[test]
7137    fn cmd_batch_multiple() {
7138        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
7139        assert!(matches!(cmd, Cmd::Batch(_)));
7140    }
7141
7142    #[test]
7143    fn cmd_sequence_empty() {
7144        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
7145        assert!(matches!(cmd, Cmd::None));
7146    }
7147
7148    #[test]
7149    fn cmd_tick() {
7150        let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
7151        assert!(matches!(cmd, Cmd::Tick(_)));
7152    }
7153
7154    #[test]
7155    fn cmd_task() {
7156        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7157        assert!(matches!(cmd, Cmd::Task(..)));
7158    }
7159
7160    #[test]
7161    fn cmd_debug_format() {
7162        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7163        let debug = format!("{cmd:?}");
7164        assert_eq!(
7165            debug,
7166            "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
7167        );
7168    }
7169
7170    #[test]
7171    fn model_subscriptions_default_empty() {
7172        let model = TestModel { value: 0 };
7173        let subs = model.subscriptions();
7174        assert!(subs.is_empty());
7175    }
7176
7177    #[test]
7178    fn program_config_default() {
7179        let config = ProgramConfig::default();
7180        assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
7181        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7182        assert!(!config.resolved_mouse_capture());
7183        assert!(config.bracketed_paste);
7184        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
7185        assert!(config.inline_auto_remeasure.is_none());
7186        assert!(config.conformal_config.is_none());
7187        assert!(config.diff_config.bayesian_enabled);
7188        assert!(config.diff_config.dirty_rows_enabled);
7189        assert!(!config.resize_coalescer.enable_bocpd);
7190        assert!(!config.effect_queue.enabled);
7191        assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
7192        assert_eq!(
7193            config.immediate_drain.max_burst_duration,
7194            Duration::from_millis(2)
7195        );
7196        assert_eq!(
7197            config.immediate_drain.backoff_timeout,
7198            Duration::from_millis(1)
7199        );
7200        assert_eq!(
7201            config.resize_coalescer.steady_delay_ms,
7202            CoalescerConfig::default().steady_delay_ms
7203        );
7204    }
7205
7206    #[test]
7207    fn program_config_with_immediate_drain() {
7208        let custom = ImmediateDrainConfig {
7209            max_zero_timeout_polls_per_burst: 7,
7210            max_burst_duration: Duration::from_millis(9),
7211            backoff_timeout: Duration::from_millis(3),
7212        };
7213        let config = ProgramConfig::default().with_immediate_drain(custom.clone());
7214        assert_eq!(
7215            config.immediate_drain.max_zero_timeout_polls_per_burst,
7216            custom.max_zero_timeout_polls_per_burst
7217        );
7218        assert_eq!(
7219            config.immediate_drain.max_burst_duration,
7220            custom.max_burst_duration
7221        );
7222        assert_eq!(
7223            config.immediate_drain.backoff_timeout,
7224            custom.backoff_timeout
7225        );
7226    }
7227
7228    #[test]
7229    fn program_config_fullscreen() {
7230        let config = ProgramConfig::fullscreen();
7231        assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
7232    }
7233
7234    #[test]
7235    fn program_config_inline() {
7236        let config = ProgramConfig::inline(10);
7237        assert!(matches!(
7238            config.screen_mode,
7239            ScreenMode::Inline { ui_height: 10 }
7240        ));
7241    }
7242
7243    #[test]
7244    fn program_config_inline_auto() {
7245        let config = ProgramConfig::inline_auto(3, 9);
7246        assert!(matches!(
7247            config.screen_mode,
7248            ScreenMode::InlineAuto {
7249                min_height: 3,
7250                max_height: 9
7251            }
7252        ));
7253        assert!(config.inline_auto_remeasure.is_some());
7254    }
7255
7256    #[test]
7257    fn program_config_with_mouse() {
7258        let config = ProgramConfig::default().with_mouse();
7259        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7260        assert!(config.resolved_mouse_capture());
7261    }
7262
7263    #[cfg(feature = "native-backend")]
7264    #[test]
7265    fn sanitize_backend_features_disables_unsupported_features() {
7266        let requested = BackendFeatures {
7267            mouse_capture: true,
7268            bracketed_paste: true,
7269            focus_events: true,
7270            kitty_keyboard: true,
7271        };
7272        let sanitized =
7273            sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
7274        assert_eq!(sanitized, BackendFeatures::default());
7275    }
7276
7277    #[cfg(feature = "native-backend")]
7278    #[test]
7279    fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
7280        let requested = BackendFeatures {
7281            mouse_capture: true,
7282            bracketed_paste: true,
7283            focus_events: true,
7284            kitty_keyboard: true,
7285        };
7286        let caps = TerminalCapabilities::builder()
7287            .mouse_sgr(true)
7288            .bracketed_paste(true)
7289            .focus_events(true)
7290            .kitty_keyboard(true)
7291            .in_wezterm_mux(true)
7292            .build();
7293        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7294
7295        assert!(sanitized.mouse_capture);
7296        assert!(sanitized.bracketed_paste);
7297        assert!(!sanitized.focus_events);
7298        assert!(!sanitized.kitty_keyboard);
7299    }
7300
7301    #[cfg(feature = "native-backend")]
7302    #[test]
7303    fn sanitize_backend_features_is_conservative_in_tmux() {
7304        let requested = BackendFeatures {
7305            mouse_capture: true,
7306            bracketed_paste: true,
7307            focus_events: true,
7308            kitty_keyboard: true,
7309        };
7310        let caps = TerminalCapabilities::builder()
7311            .mouse_sgr(true)
7312            .bracketed_paste(true)
7313            .focus_events(true)
7314            .kitty_keyboard(true)
7315            .in_tmux(true)
7316            .build();
7317        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7318
7319        assert!(sanitized.mouse_capture);
7320        assert!(sanitized.bracketed_paste);
7321        assert!(!sanitized.focus_events);
7322        assert!(!sanitized.kitty_keyboard);
7323    }
7324
7325    #[test]
7326    fn program_config_mouse_policy_auto_altscreen() {
7327        let config = ProgramConfig::fullscreen();
7328        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7329        assert!(config.resolved_mouse_capture());
7330    }
7331
7332    #[test]
7333    fn program_config_mouse_policy_force_off() {
7334        let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
7335        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
7336        assert!(!config.resolved_mouse_capture());
7337    }
7338
7339    #[test]
7340    fn program_config_mouse_policy_force_on_inline() {
7341        let config = ProgramConfig::inline(6).with_mouse_enabled(true);
7342        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7343        assert!(config.resolved_mouse_capture());
7344    }
7345
7346    fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
7347        PaneResizeTarget {
7348            split_id: ftui_layout::PaneId::MIN,
7349            axis,
7350        }
7351    }
7352
7353    fn pane_id(raw: u64) -> ftui_layout::PaneId {
7354        ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
7355    }
7356
7357    fn nested_pane_tree() -> ftui_layout::PaneTree {
7358        let root = pane_id(1);
7359        let left = pane_id(2);
7360        let right_split = pane_id(3);
7361        let right_top = pane_id(4);
7362        let right_bottom = pane_id(5);
7363        let snapshot = ftui_layout::PaneTreeSnapshot {
7364            schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
7365            root,
7366            next_id: pane_id(6),
7367            nodes: vec![
7368                ftui_layout::PaneNodeRecord::split(
7369                    root,
7370                    None,
7371                    ftui_layout::PaneSplit {
7372                        axis: SplitAxis::Horizontal,
7373                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7374                        first: left,
7375                        second: right_split,
7376                    },
7377                ),
7378                ftui_layout::PaneNodeRecord::leaf(
7379                    left,
7380                    Some(root),
7381                    ftui_layout::PaneLeaf::new("left"),
7382                ),
7383                ftui_layout::PaneNodeRecord::split(
7384                    right_split,
7385                    Some(root),
7386                    ftui_layout::PaneSplit {
7387                        axis: SplitAxis::Vertical,
7388                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7389                        first: right_top,
7390                        second: right_bottom,
7391                    },
7392                ),
7393                ftui_layout::PaneNodeRecord::leaf(
7394                    right_top,
7395                    Some(right_split),
7396                    ftui_layout::PaneLeaf::new("right_top"),
7397                ),
7398                ftui_layout::PaneNodeRecord::leaf(
7399                    right_bottom,
7400                    Some(right_split),
7401                    ftui_layout::PaneLeaf::new("right_bottom"),
7402                ),
7403            ],
7404            extensions: std::collections::BTreeMap::new(),
7405        };
7406        ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
7407    }
7408
7409    #[test]
7410    fn pane_terminal_splitter_resolution_is_deterministic() {
7411        let tree = nested_pane_tree();
7412        let layout = tree
7413            .solve_layout(Rect::new(0, 0, 50, 20))
7414            .expect("layout should solve");
7415        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7416        assert_eq!(handles.len(), 2);
7417
7418        // Intersection between root vertical splitter and right-side horizontal
7419        // splitter deterministically resolves to smaller split ID.
7420        let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
7421            .expect("overlap cell should resolve");
7422        assert_eq!(overlap.split_id, pane_id(1));
7423        assert_eq!(overlap.axis, SplitAxis::Horizontal);
7424
7425        let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
7426            .expect("right split should resolve");
7427        assert_eq!(right_only.split_id, pane_id(3));
7428        assert_eq!(right_only.axis, SplitAxis::Vertical);
7429    }
7430
7431    #[test]
7432    fn pane_terminal_splitter_hits_register_and_decode_target() {
7433        let tree = nested_pane_tree();
7434        let layout = tree
7435            .solve_layout(Rect::new(0, 0, 50, 20))
7436            .expect("layout should solve");
7437        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7438
7439        let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
7440        let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
7441        let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
7442        assert_eq!(registered, handles.len());
7443
7444        let root_hit = frame
7445            .hit_test(25, 2)
7446            .expect("root splitter should be hittable");
7447        assert_eq!(root_hit.1, HitRegion::Handle);
7448        let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
7449        assert_eq!(root_target.split_id, pane_id(1));
7450        assert_eq!(root_target.axis, SplitAxis::Horizontal);
7451
7452        let right_hit = frame
7453            .hit_test(40, 10)
7454            .expect("right splitter should be hittable");
7455        assert_eq!(right_hit.1, HitRegion::Handle);
7456        let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
7457        assert_eq!(right_target.split_id, pane_id(3));
7458        assert_eq!(right_target.axis, SplitAxis::Vertical);
7459    }
7460
7461    #[test]
7462    fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
7463        let mut adapter =
7464            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7465        let target = pane_target(SplitAxis::Horizontal);
7466
7467        let down = Event::Mouse(MouseEvent::new(
7468            MouseEventKind::Down(MouseButton::Left),
7469            10,
7470            4,
7471        ));
7472        let down_dispatch = adapter.translate(&down, Some(target));
7473        let down_event = down_dispatch
7474            .primary_event
7475            .as_ref()
7476            .expect("pointer down semantic event");
7477        assert_eq!(down_event.sequence, 1);
7478        assert!(matches!(
7479            down_event.kind,
7480            PaneSemanticInputEventKind::PointerDown {
7481                target: actual_target,
7482                pointer_id: 1,
7483                button: PanePointerButton::Primary,
7484                position
7485            } if actual_target == target && position == PanePointerPosition::new(10, 4)
7486        ));
7487        assert!(down_event.validate().is_ok());
7488
7489        let drag = Event::Mouse(MouseEvent::new(
7490            MouseEventKind::Drag(MouseButton::Left),
7491            14,
7492            4,
7493        ));
7494        let drag_dispatch = adapter.translate(&drag, None);
7495        let drag_event = drag_dispatch
7496            .primary_event
7497            .as_ref()
7498            .expect("pointer move semantic event");
7499        assert_eq!(drag_event.sequence, 2);
7500        assert!(matches!(
7501            drag_event.kind,
7502            PaneSemanticInputEventKind::PointerMove {
7503                target: actual_target,
7504                pointer_id: 1,
7505                position,
7506                delta_x: 4,
7507                delta_y: 0
7508            } if actual_target == target && position == PanePointerPosition::new(14, 4)
7509        ));
7510        let drag_motion = drag_dispatch
7511            .motion
7512            .expect("drag should emit motion metadata");
7513        assert_eq!(drag_motion.delta_x, 4);
7514        assert_eq!(drag_motion.delta_y, 0);
7515        assert_eq!(drag_motion.direction_changes, 0);
7516        assert!(drag_motion.speed > 0.0);
7517        assert!(drag_dispatch.pressure_snap_profile().is_some());
7518
7519        let up = Event::Mouse(MouseEvent::new(
7520            MouseEventKind::Up(MouseButton::Left),
7521            14,
7522            4,
7523        ));
7524        let up_dispatch = adapter.translate(&up, None);
7525        let up_event = up_dispatch
7526            .primary_event
7527            .as_ref()
7528            .expect("pointer up semantic event");
7529        assert_eq!(up_event.sequence, 3);
7530        assert!(matches!(
7531            up_event.kind,
7532            PaneSemanticInputEventKind::PointerUp {
7533                target: actual_target,
7534                pointer_id: 1,
7535                button: PanePointerButton::Primary,
7536                position
7537            } if actual_target == target && position == PanePointerPosition::new(14, 4)
7538        ));
7539        let up_motion = up_dispatch
7540            .motion
7541            .expect("up should emit final motion metadata");
7542        assert_eq!(up_motion.delta_x, 4);
7543        assert_eq!(up_motion.delta_y, 0);
7544        assert_eq!(up_motion.direction_changes, 0);
7545        let inertial_throw = up_dispatch
7546            .inertial_throw
7547            .expect("up should emit inertial throw metadata");
7548        assert_eq!(
7549            up_dispatch.projected_position,
7550            Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
7551        );
7552        assert_eq!(adapter.active_pointer_id(), None);
7553        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7554    }
7555
7556    #[test]
7557    fn pane_terminal_adapter_focus_loss_emits_cancel() {
7558        let mut adapter =
7559            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7560        let target = pane_target(SplitAxis::Vertical);
7561
7562        let down = Event::Mouse(MouseEvent::new(
7563            MouseEventKind::Down(MouseButton::Left),
7564            3,
7565            9,
7566        ));
7567        let _ = adapter.translate(&down, Some(target));
7568        assert_eq!(adapter.active_pointer_id(), Some(1));
7569
7570        let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
7571        let cancel_event = cancel_dispatch
7572            .primary_event
7573            .as_ref()
7574            .expect("focus-loss cancel event");
7575        assert!(matches!(
7576            cancel_event.kind,
7577            PaneSemanticInputEventKind::Cancel {
7578                target: Some(actual_target),
7579                reason: PaneCancelReason::FocusLost
7580            } if actual_target == target
7581        ));
7582        assert_eq!(adapter.active_pointer_id(), None);
7583        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7584    }
7585
7586    #[test]
7587    fn pane_terminal_adapter_recovers_missing_mouse_up() {
7588        let mut adapter =
7589            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7590        let first_target = pane_target(SplitAxis::Horizontal);
7591        let second_target = pane_target(SplitAxis::Vertical);
7592
7593        let first_down = Event::Mouse(MouseEvent::new(
7594            MouseEventKind::Down(MouseButton::Left),
7595            5,
7596            5,
7597        ));
7598        let _ = adapter.translate(&first_down, Some(first_target));
7599
7600        let second_down = Event::Mouse(MouseEvent::new(
7601            MouseEventKind::Down(MouseButton::Left),
7602            8,
7603            11,
7604        ));
7605        let dispatch = adapter.translate(&second_down, Some(second_target));
7606        let recovery = dispatch
7607            .recovery_event
7608            .as_ref()
7609            .expect("recovery cancel expected");
7610        assert!(matches!(
7611            recovery.kind,
7612            PaneSemanticInputEventKind::Cancel {
7613                target: Some(actual_target),
7614                reason: PaneCancelReason::PointerCancel
7615            } if actual_target == first_target
7616        ));
7617        let primary = dispatch
7618            .primary_event
7619            .as_ref()
7620            .expect("second pointer down expected");
7621        assert!(matches!(
7622            primary.kind,
7623            PaneSemanticInputEventKind::PointerDown {
7624                target: actual_target,
7625                pointer_id: 1,
7626                button: PanePointerButton::Primary,
7627                position
7628            } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
7629        ));
7630        assert_eq!(recovery.sequence, 2);
7631        assert_eq!(primary.sequence, 3);
7632        assert!(matches!(
7633            dispatch.log.outcome,
7634            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
7635        ));
7636        assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
7637    }
7638
7639    #[test]
7640    fn pane_terminal_adapter_modifier_parity() {
7641        let mut adapter =
7642            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7643        let target = pane_target(SplitAxis::Horizontal);
7644
7645        let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
7646            .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
7647        let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
7648        let event = dispatch.primary_event.expect("semantic event");
7649        assert!(event.modifiers.shift);
7650        assert!(event.modifiers.alt);
7651        assert!(event.modifiers.ctrl);
7652        assert!(event.modifiers.meta);
7653    }
7654
7655    #[test]
7656    fn pane_terminal_adapter_keyboard_resize_mapping() {
7657        let mut adapter =
7658            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7659        let target = pane_target(SplitAxis::Horizontal);
7660
7661        let key = KeyEvent::new(KeyCode::Right);
7662        let dispatch = adapter.translate(&Event::Key(key), Some(target));
7663        let event = dispatch.primary_event.expect("keyboard resize event");
7664        assert!(matches!(
7665            event.kind,
7666            PaneSemanticInputEventKind::KeyboardResize {
7667                target: actual_target,
7668                direction: PaneResizeDirection::Increase,
7669                units: 1
7670            } if actual_target == target
7671        ));
7672
7673        let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
7674        let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
7675        let shifted_event = shifted_dispatch
7676            .primary_event
7677            .expect("shifted resize event");
7678        assert!(matches!(
7679            shifted_event.kind,
7680            PaneSemanticInputEventKind::KeyboardResize {
7681                direction: PaneResizeDirection::Increase,
7682                units: 5,
7683                ..
7684            }
7685        ));
7686        assert!(shifted_event.modifiers.shift);
7687    }
7688
7689    #[test]
7690    fn pane_terminal_adapter_keyboard_resize_requires_focus() {
7691        let mut adapter =
7692            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7693        let target = pane_target(SplitAxis::Horizontal);
7694
7695        let _ = adapter.translate(&Event::Focus(false), None);
7696        assert!(!adapter.window_focused());
7697
7698        let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7699        assert!(unfocused.primary_event.is_none());
7700        assert!(matches!(
7701            unfocused.log.outcome,
7702            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
7703        ));
7704
7705        let _ = adapter.translate(&Event::Focus(true), None);
7706        assert!(adapter.window_focused());
7707
7708        let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7709        assert!(focused.primary_event.is_some());
7710    }
7711
7712    #[test]
7713    fn pane_terminal_adapter_drag_updates_are_coalesced() {
7714        let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
7715            drag_update_coalesce_distance: 2,
7716            ..PaneTerminalAdapterConfig::default()
7717        })
7718        .expect("valid adapter");
7719        let target = pane_target(SplitAxis::Horizontal);
7720
7721        let down = Event::Mouse(MouseEvent::new(
7722            MouseEventKind::Down(MouseButton::Left),
7723            10,
7724            4,
7725        ));
7726        let _ = adapter.translate(&down, Some(target));
7727
7728        let drag_start = Event::Mouse(MouseEvent::new(
7729            MouseEventKind::Drag(MouseButton::Left),
7730            14,
7731            4,
7732        ));
7733        let started = adapter.translate(&drag_start, None);
7734        assert!(started.primary_event.is_some());
7735        assert!(matches!(
7736            adapter.machine_state(),
7737            PaneDragResizeState::Dragging { .. }
7738        ));
7739
7740        let coalesced = Event::Mouse(MouseEvent::new(
7741            MouseEventKind::Drag(MouseButton::Left),
7742            15,
7743            4,
7744        ));
7745        let coalesced_dispatch = adapter.translate(&coalesced, None);
7746        assert!(coalesced_dispatch.primary_event.is_none());
7747        assert!(matches!(
7748            coalesced_dispatch.log.outcome,
7749            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
7750        ));
7751
7752        let forwarded = Event::Mouse(MouseEvent::new(
7753            MouseEventKind::Drag(MouseButton::Left),
7754            16,
7755            4,
7756        ));
7757        let forwarded_dispatch = adapter.translate(&forwarded, None);
7758        let forwarded_event = forwarded_dispatch
7759            .primary_event
7760            .as_ref()
7761            .expect("coalesced movement should flush once threshold reached");
7762        assert!(matches!(
7763            forwarded_event.kind,
7764            PaneSemanticInputEventKind::PointerMove {
7765                delta_x: 2,
7766                delta_y: 0,
7767                ..
7768            }
7769        ));
7770    }
7771
7772    #[test]
7773    fn pane_terminal_adapter_motion_tracks_direction_changes() {
7774        let mut adapter =
7775            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7776        let target = pane_target(SplitAxis::Horizontal);
7777
7778        let down = Event::Mouse(MouseEvent::new(
7779            MouseEventKind::Down(MouseButton::Left),
7780            10,
7781            4,
7782        ));
7783        let _ = adapter.translate(&down, Some(target));
7784
7785        let drag_forward = Event::Mouse(MouseEvent::new(
7786            MouseEventKind::Drag(MouseButton::Left),
7787            14,
7788            4,
7789        ));
7790        let forward_dispatch = adapter.translate(&drag_forward, None);
7791        let forward_motion = forward_dispatch
7792            .motion
7793            .expect("forward drag should emit motion metadata");
7794        assert_eq!(forward_motion.direction_changes, 0);
7795
7796        let drag_reverse = Event::Mouse(MouseEvent::new(
7797            MouseEventKind::Drag(MouseButton::Left),
7798            12,
7799            4,
7800        ));
7801        let reverse_dispatch = adapter.translate(&drag_reverse, None);
7802        let reverse_motion = reverse_dispatch
7803            .motion
7804            .expect("reverse drag should emit motion metadata");
7805        assert_eq!(reverse_motion.direction_changes, 1);
7806
7807        let up = Event::Mouse(MouseEvent::new(
7808            MouseEventKind::Up(MouseButton::Left),
7809            12,
7810            4,
7811        ));
7812        let up_dispatch = adapter.translate(&up, None);
7813        let up_motion = up_dispatch
7814            .motion
7815            .expect("release should include cumulative motion metadata");
7816        assert_eq!(up_motion.direction_changes, 1);
7817    }
7818
7819    #[test]
7820    fn pane_terminal_adapter_translate_with_handles_resolves_target() {
7821        let tree = nested_pane_tree();
7822        let layout = tree
7823            .solve_layout(Rect::new(0, 0, 50, 20))
7824            .expect("layout should solve");
7825        let handles =
7826            pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
7827        let mut adapter =
7828            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7829
7830        let down = Event::Mouse(MouseEvent::new(
7831            MouseEventKind::Down(MouseButton::Left),
7832            25,
7833            10,
7834        ));
7835        let dispatch = adapter.translate_with_handles(&down, &handles);
7836        let event = dispatch
7837            .primary_event
7838            .as_ref()
7839            .expect("pointer down should be routed from handles");
7840        assert!(matches!(
7841            event.kind,
7842            PaneSemanticInputEventKind::PointerDown {
7843                target:
7844                    PaneResizeTarget {
7845                        split_id,
7846                        axis: SplitAxis::Horizontal
7847                    },
7848                ..
7849            } if split_id == pane_id(1)
7850        ));
7851    }
7852
7853    #[test]
7854    fn model_update() {
7855        let mut model = TestModel { value: 0 };
7856        model.update(TestMsg::Increment);
7857        assert_eq!(model.value, 1);
7858        model.update(TestMsg::Decrement);
7859        assert_eq!(model.value, 0);
7860        assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
7861    }
7862
7863    #[test]
7864    fn model_init_default() {
7865        let mut model = TestModel { value: 0 };
7866        let cmd = model.init();
7867        assert!(matches!(cmd, Cmd::None));
7868    }
7869
7870    // Resize coalescer behavior is covered by resize_coalescer.rs tests.
7871
7872    // =========================================================================
7873    // DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
7874    // =========================================================================
7875
7876    #[test]
7877    fn cmd_sequence_executes_in_order() {
7878        // Verify that Cmd::Sequence executes commands in declared order
7879        use crate::simulator::ProgramSimulator;
7880
7881        struct SeqModel {
7882            trace: Vec<i32>,
7883        }
7884
7885        #[derive(Debug)]
7886        enum SeqMsg {
7887            Append(i32),
7888            TriggerSequence,
7889        }
7890
7891        impl From<Event> for SeqMsg {
7892            fn from(_: Event) -> Self {
7893                SeqMsg::Append(0)
7894            }
7895        }
7896
7897        impl Model for SeqModel {
7898            type Message = SeqMsg;
7899
7900            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7901                match msg {
7902                    SeqMsg::Append(n) => {
7903                        self.trace.push(n);
7904                        Cmd::none()
7905                    }
7906                    SeqMsg::TriggerSequence => Cmd::sequence(vec![
7907                        Cmd::msg(SeqMsg::Append(1)),
7908                        Cmd::msg(SeqMsg::Append(2)),
7909                        Cmd::msg(SeqMsg::Append(3)),
7910                    ]),
7911                }
7912            }
7913
7914            fn view(&self, _frame: &mut Frame) {}
7915        }
7916
7917        let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
7918        sim.init();
7919        sim.send(SeqMsg::TriggerSequence);
7920
7921        assert_eq!(sim.model().trace, vec![1, 2, 3]);
7922    }
7923
7924    #[test]
7925    fn cmd_batch_executes_all_regardless_of_order() {
7926        // Verify that Cmd::Batch executes all commands
7927        use crate::simulator::ProgramSimulator;
7928
7929        struct BatchModel {
7930            values: Vec<i32>,
7931        }
7932
7933        #[derive(Debug)]
7934        enum BatchMsg {
7935            Add(i32),
7936            TriggerBatch,
7937        }
7938
7939        impl From<Event> for BatchMsg {
7940            fn from(_: Event) -> Self {
7941                BatchMsg::Add(0)
7942            }
7943        }
7944
7945        impl Model for BatchModel {
7946            type Message = BatchMsg;
7947
7948            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7949                match msg {
7950                    BatchMsg::Add(n) => {
7951                        self.values.push(n);
7952                        Cmd::none()
7953                    }
7954                    BatchMsg::TriggerBatch => Cmd::batch(vec![
7955                        Cmd::msg(BatchMsg::Add(10)),
7956                        Cmd::msg(BatchMsg::Add(20)),
7957                        Cmd::msg(BatchMsg::Add(30)),
7958                    ]),
7959                }
7960            }
7961
7962            fn view(&self, _frame: &mut Frame) {}
7963        }
7964
7965        let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
7966        sim.init();
7967        sim.send(BatchMsg::TriggerBatch);
7968
7969        // All values should be present
7970        assert_eq!(sim.model().values.len(), 3);
7971        assert!(sim.model().values.contains(&10));
7972        assert!(sim.model().values.contains(&20));
7973        assert!(sim.model().values.contains(&30));
7974    }
7975
7976    #[test]
7977    fn cmd_sequence_stops_on_quit() {
7978        // Verify that Cmd::Sequence stops processing after Quit
7979        use crate::simulator::ProgramSimulator;
7980
7981        struct SeqQuitModel {
7982            trace: Vec<i32>,
7983        }
7984
7985        #[derive(Debug)]
7986        enum SeqQuitMsg {
7987            Append(i32),
7988            TriggerSequenceWithQuit,
7989        }
7990
7991        impl From<Event> for SeqQuitMsg {
7992            fn from(_: Event) -> Self {
7993                SeqQuitMsg::Append(0)
7994            }
7995        }
7996
7997        impl Model for SeqQuitModel {
7998            type Message = SeqQuitMsg;
7999
8000            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8001                match msg {
8002                    SeqQuitMsg::Append(n) => {
8003                        self.trace.push(n);
8004                        Cmd::none()
8005                    }
8006                    SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
8007                        Cmd::msg(SeqQuitMsg::Append(1)),
8008                        Cmd::quit(),
8009                        Cmd::msg(SeqQuitMsg::Append(2)), // Should not execute
8010                    ]),
8011                }
8012            }
8013
8014            fn view(&self, _frame: &mut Frame) {}
8015        }
8016
8017        let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
8018        sim.init();
8019        sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
8020
8021        assert_eq!(sim.model().trace, vec![1]);
8022        assert!(!sim.is_running());
8023    }
8024
8025    #[test]
8026    fn identical_input_produces_identical_state() {
8027        // Verify deterministic state transitions
8028        use crate::simulator::ProgramSimulator;
8029
8030        fn run_scenario() -> Vec<i32> {
8031            struct DetModel {
8032                values: Vec<i32>,
8033            }
8034
8035            #[derive(Debug, Clone)]
8036            enum DetMsg {
8037                Add(i32),
8038                Double,
8039            }
8040
8041            impl From<Event> for DetMsg {
8042                fn from(_: Event) -> Self {
8043                    DetMsg::Add(1)
8044                }
8045            }
8046
8047            impl Model for DetModel {
8048                type Message = DetMsg;
8049
8050                fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8051                    match msg {
8052                        DetMsg::Add(n) => {
8053                            self.values.push(n);
8054                            Cmd::none()
8055                        }
8056                        DetMsg::Double => {
8057                            if let Some(&last) = self.values.last() {
8058                                self.values.push(last * 2);
8059                            }
8060                            Cmd::none()
8061                        }
8062                    }
8063                }
8064
8065                fn view(&self, _frame: &mut Frame) {}
8066            }
8067
8068            let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
8069            sim.init();
8070            sim.send(DetMsg::Add(5));
8071            sim.send(DetMsg::Double);
8072            sim.send(DetMsg::Add(3));
8073            sim.send(DetMsg::Double);
8074
8075            sim.model().values.clone()
8076        }
8077
8078        // Run the same scenario multiple times
8079        let run1 = run_scenario();
8080        let run2 = run_scenario();
8081        let run3 = run_scenario();
8082
8083        assert_eq!(run1, run2);
8084        assert_eq!(run2, run3);
8085        assert_eq!(run1, vec![5, 10, 3, 6]);
8086    }
8087
8088    #[test]
8089    fn identical_state_produces_identical_render() {
8090        // Verify consistent render outputs for identical inputs
8091        use crate::simulator::ProgramSimulator;
8092
8093        struct RenderModel {
8094            counter: i32,
8095        }
8096
8097        #[derive(Debug)]
8098        enum RenderMsg {
8099            Set(i32),
8100        }
8101
8102        impl From<Event> for RenderMsg {
8103            fn from(_: Event) -> Self {
8104                RenderMsg::Set(0)
8105            }
8106        }
8107
8108        impl Model for RenderModel {
8109            type Message = RenderMsg;
8110
8111            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8112                match msg {
8113                    RenderMsg::Set(n) => {
8114                        self.counter = n;
8115                        Cmd::none()
8116                    }
8117                }
8118            }
8119
8120            fn view(&self, frame: &mut Frame) {
8121                let text = format!("Value: {}", self.counter);
8122                for (i, c) in text.chars().enumerate() {
8123                    if (i as u16) < frame.width() {
8124                        use ftui_render::cell::Cell;
8125                        frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
8126                    }
8127                }
8128            }
8129        }
8130
8131        // Create two simulators with the same state
8132        let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
8133        let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
8134
8135        let buf1 = sim1.capture_frame(80, 24);
8136        let buf2 = sim2.capture_frame(80, 24);
8137
8138        // Compare buffer contents
8139        for y in 0..24 {
8140            for x in 0..80 {
8141                let cell1 = buf1.get(x, y).unwrap();
8142                let cell2 = buf2.get(x, y).unwrap();
8143                assert_eq!(
8144                    cell1.content.as_char(),
8145                    cell2.content.as_char(),
8146                    "Mismatch at ({}, {})",
8147                    x,
8148                    y
8149                );
8150            }
8151        }
8152    }
8153
8154    // Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
8155
8156    #[test]
8157    fn cmd_log_creates_log_command() {
8158        let cmd: Cmd<TestMsg> = Cmd::log("test message");
8159        assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
8160    }
8161
8162    #[test]
8163    fn cmd_log_from_string() {
8164        let msg = String::from("dynamic message");
8165        let cmd: Cmd<TestMsg> = Cmd::log(msg);
8166        assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
8167    }
8168
8169    #[test]
8170    fn program_simulator_logs_jsonl_with_seed_and_run_id() {
8171        // Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
8172        use crate::simulator::ProgramSimulator;
8173
8174        struct LogModel {
8175            run_id: &'static str,
8176            seed: u64,
8177        }
8178
8179        #[derive(Debug)]
8180        enum LogMsg {
8181            Emit,
8182        }
8183
8184        impl From<Event> for LogMsg {
8185            fn from(_: Event) -> Self {
8186                LogMsg::Emit
8187            }
8188        }
8189
8190        impl Model for LogModel {
8191            type Message = LogMsg;
8192
8193            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
8194                let line = format!(
8195                    r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
8196                    self.run_id, self.seed
8197                );
8198                Cmd::log(line)
8199            }
8200
8201            fn view(&self, _frame: &mut Frame) {}
8202        }
8203
8204        let mut sim = ProgramSimulator::new(LogModel {
8205            run_id: "test-run-001",
8206            seed: 4242,
8207        });
8208        sim.init();
8209        sim.send(LogMsg::Emit);
8210
8211        let logs = sim.logs();
8212        assert_eq!(logs.len(), 1);
8213        assert!(logs[0].contains(r#""run_id":"test-run-001""#));
8214        assert!(logs[0].contains(r#""seed":4242"#));
8215    }
8216
8217    #[test]
8218    fn cmd_sequence_single_unwraps() {
8219        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
8220        // Single element sequence should unwrap to the inner command
8221        assert!(matches!(cmd, Cmd::Quit));
8222    }
8223
8224    #[test]
8225    fn cmd_sequence_multiple() {
8226        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
8227        assert!(matches!(cmd, Cmd::Sequence(_)));
8228    }
8229
8230    #[test]
8231    fn cmd_default_is_none() {
8232        let cmd: Cmd<TestMsg> = Cmd::default();
8233        assert!(matches!(cmd, Cmd::None));
8234    }
8235
8236    #[test]
8237    fn cmd_debug_all_variants() {
8238        // Test Debug impl for all variants
8239        let none: Cmd<TestMsg> = Cmd::none();
8240        assert_eq!(format!("{none:?}"), "None");
8241
8242        let quit: Cmd<TestMsg> = Cmd::quit();
8243        assert_eq!(format!("{quit:?}"), "Quit");
8244
8245        let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
8246        assert!(format!("{msg:?}").starts_with("Msg("));
8247
8248        let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
8249        assert!(format!("{batch:?}").starts_with("Batch("));
8250
8251        let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
8252        assert!(format!("{seq:?}").starts_with("Sequence("));
8253
8254        let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
8255        assert!(format!("{tick:?}").starts_with("Tick("));
8256
8257        let log: Cmd<TestMsg> = Cmd::log("test");
8258        assert!(format!("{log:?}").starts_with("Log("));
8259    }
8260
8261    #[test]
8262    fn program_config_with_budget() {
8263        let budget = FrameBudgetConfig {
8264            total: Duration::from_millis(50),
8265            ..Default::default()
8266        };
8267        let config = ProgramConfig::default().with_budget(budget);
8268        assert_eq!(config.budget.total, Duration::from_millis(50));
8269    }
8270
8271    #[test]
8272    fn load_governor_default_is_enabled() {
8273        let config = LoadGovernorConfig::default();
8274        assert!(config.enabled);
8275        assert_eq!(
8276            config.budget_controller.degradation_floor,
8277            DegradationLevel::SimpleBorders
8278        );
8279    }
8280
8281    #[test]
8282    fn program_config_load_governor_builders() {
8283        let governor = LoadGovernorConfig::disabled().with_enabled(true);
8284        let config = ProgramConfig::default().with_load_governor(governor);
8285        assert!(config.load_governor.enabled);
8286
8287        let config = config.without_load_governor();
8288        assert!(!config.load_governor.enabled);
8289    }
8290
8291    fn governor_observation(
8292        frame_time_ms: f64,
8293        in_flight: u64,
8294        dropped: u64,
8295        degradation: DegradationLevel,
8296        resize_coalescing_active: bool,
8297        strict_semantics_violation: bool,
8298    ) -> LoadGovernorObservation {
8299        LoadGovernorObservation {
8300            frame_time_us: frame_time_ms * 1_000.0,
8301            budget_us: 16_000.0,
8302            degradation,
8303            queue: crate::effect_system::QueueTelemetry {
8304                enqueued: in_flight.saturating_add(dropped),
8305                processed: 0,
8306                dropped,
8307                high_water: in_flight,
8308                in_flight,
8309            },
8310            resize_coalescing_active,
8311            strict_semantics_violation,
8312        }
8313    }
8314
8315    fn test_load_governor(max_queue_depth: usize, recovery_intervals: u8) -> LoadGovernorState {
8316        let policy = LoadGovernorPolicy {
8317            recovery_intervals,
8318            ..Default::default()
8319        };
8320        LoadGovernorState::new(
8321            LoadGovernorConfig::enabled().with_policy(policy),
8322            max_queue_depth,
8323        )
8324    }
8325
8326    #[test]
8327    fn load_governor_policy_defaults_match_runtime_contract() {
8328        let policy = LoadGovernorPolicy::default().normalized();
8329
8330        assert_eq!(policy.stressed_queue_watermark, 0.5);
8331        assert_eq!(policy.degraded_queue_watermark, 0.8);
8332        assert_eq!(policy.recovery_queue_watermark, 0.25);
8333        assert_eq!(policy.recovery_intervals, 3);
8334        assert_eq!(policy.budget_overrun_soft_ratio, 1.0);
8335    }
8336
8337    #[test]
8338    fn load_governor_classifies_queue_watermarks_and_recovery() {
8339        let mut governor = test_load_governor(100, 2);
8340
8341        let steady = governor.observe(governor_observation(
8342            8.0,
8343            0,
8344            0,
8345            DegradationLevel::Full,
8346            false,
8347            false,
8348        ));
8349        assert_eq!(steady.mode, RuntimeLoadMode::Healthy);
8350        assert_eq!(steady.pressure_class, RuntimePressureClass::SteadyState);
8351        assert_eq!(steady.disposition, RuntimeWorkDisposition::AdmitAll);
8352
8353        let stressed = governor.observe(governor_observation(
8354            8.0,
8355            50,
8356            0,
8357            DegradationLevel::Full,
8358            false,
8359            false,
8360        ));
8361        assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
8362        assert_eq!(stressed.pressure_class, RuntimePressureClass::SoftOverload);
8363        assert_eq!(
8364            stressed.disposition,
8365            RuntimeWorkDisposition::CoalesceVisibleDeferBackground
8366        );
8367        assert_eq!(stressed.reason_code, "queue_stressed_watermark");
8368
8369        let degraded = governor.observe(governor_observation(
8370            8.0,
8371            80,
8372            0,
8373            DegradationLevel::Full,
8374            false,
8375            false,
8376        ));
8377        assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
8378        assert_eq!(degraded.pressure_class, RuntimePressureClass::HardOverload);
8379        assert_eq!(
8380            degraded.disposition,
8381            RuntimeWorkDisposition::DeferBackgroundDropBestEffort
8382        );
8383        assert_eq!(degraded.reason_code, "queue_degraded_watermark");
8384
8385        let recovery_pending = governor.observe(governor_observation(
8386            8.0,
8387            10,
8388            0,
8389            DegradationLevel::Full,
8390            false,
8391            false,
8392        ));
8393        assert_eq!(recovery_pending.mode, RuntimeLoadMode::Degraded);
8394        assert_eq!(recovery_pending.reason_code, "recovery_hysteresis_pending");
8395        assert_eq!(recovery_pending.recovery_intervals_observed, 1);
8396
8397        let recovered = governor.observe(governor_observation(
8398            8.0,
8399            10,
8400            0,
8401            DegradationLevel::Full,
8402            false,
8403            false,
8404        ));
8405        assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
8406        assert_eq!(recovered.reason_code, "recovery_hysteresis_satisfied");
8407        assert_eq!(
8408            recovered.disposition,
8409            RuntimeWorkDisposition::ReadmitAfterHysteresis
8410        );
8411
8412        let healthy = governor.observe(governor_observation(
8413            8.0,
8414            10,
8415            0,
8416            DegradationLevel::Full,
8417            false,
8418            false,
8419        ));
8420        assert_eq!(healthy.mode, RuntimeLoadMode::Healthy);
8421        assert_eq!(healthy.reason_code, "recovered_interval_closed");
8422    }
8423
8424    #[test]
8425    fn load_governor_uses_uncapped_budget_pressure_fallback() {
8426        let mut governor = test_load_governor(0, 2);
8427
8428        let stressed = governor.observe(governor_observation(
8429            20.0,
8430            0,
8431            0,
8432            DegradationLevel::Full,
8433            false,
8434            false,
8435        ));
8436        assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
8437        assert_eq!(stressed.reason_code, "frame_budget_overrun");
8438        assert_eq!(stressed.queue_max_depth, None);
8439
8440        let degraded = governor.observe(governor_observation(
8441            8.0,
8442            0,
8443            0,
8444            DegradationLevel::SimpleBorders,
8445            false,
8446            false,
8447        ));
8448        assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
8449        assert_eq!(degraded.reason_code, "budget_degradation_active");
8450    }
8451
8452    #[test]
8453    fn load_governor_strict_semantics_failure_is_terminal() {
8454        let mut governor = test_load_governor(100, 2);
8455
8456        let unsafe_snapshot = governor.observe(governor_observation(
8457            8.0,
8458            0,
8459            0,
8460            DegradationLevel::Full,
8461            false,
8462            true,
8463        ));
8464
8465        assert_eq!(unsafe_snapshot.mode, RuntimeLoadMode::Unsafe);
8466        assert_eq!(unsafe_snapshot.pressure_class, RuntimePressureClass::Unsafe);
8467        assert_eq!(
8468            unsafe_snapshot.disposition,
8469            RuntimeWorkDisposition::FailFastStrictGuarantee
8470        );
8471        assert!(!unsafe_snapshot.strict_semantics_preserved);
8472        assert_eq!(unsafe_snapshot.reason_code, "strict_semantics_violation");
8473    }
8474
8475    #[test]
8476    fn load_governor_unsafe_latches_through_later_pressure() {
8477        let mut governor = test_load_governor(100, 2);
8478
8479        // Enter Unsafe via a strict-semantics violation.
8480        let entered = governor.observe(governor_observation(
8481            8.0,
8482            0,
8483            0,
8484            DegradationLevel::Full,
8485            false,
8486            true,
8487        ));
8488        assert_eq!(entered.mode, RuntimeLoadMode::Unsafe);
8489
8490        // A later hard-overload interval (queue ratio >= degraded watermark) with
8491        // NO fresh violation must not downgrade the terminal Unsafe state.
8492        let hard = governor.observe(governor_observation(
8493            8.0,
8494            90,
8495            0,
8496            DegradationLevel::SimpleBorders,
8497            false,
8498            false,
8499        ));
8500        assert_eq!(hard.mode, RuntimeLoadMode::Unsafe);
8501        assert_eq!(
8502            hard.disposition,
8503            RuntimeWorkDisposition::FailFastStrictGuarantee
8504        );
8505        assert!(!hard.strict_semantics_preserved);
8506        assert_eq!(hard.reason_code, "strict_semantics_violation");
8507
8508        // A soft-overload interval (would otherwise classify as Stressed) must
8509        // also keep Unsafe latched rather than escaping downward.
8510        let soft = governor.observe(governor_observation(
8511            8.0,
8512            60,
8513            0,
8514            DegradationLevel::Full,
8515            true,
8516            false,
8517        ));
8518        assert_eq!(soft.mode, RuntimeLoadMode::Unsafe);
8519
8520        // And a fully steady interval never recovers out of Unsafe.
8521        let steady = governor.observe(governor_observation(
8522            8.0,
8523            0,
8524            0,
8525            DegradationLevel::Full,
8526            false,
8527            false,
8528        ));
8529        assert_eq!(steady.mode, RuntimeLoadMode::Unsafe);
8530        assert!(!steady.strict_semantics_preserved);
8531    }
8532
8533    #[test]
8534    fn load_governor_hysteresis_prevents_single_sample_recovery() {
8535        let mut governor = test_load_governor(100, 3);
8536
8537        governor.observe(governor_observation(
8538            8.0,
8539            90,
8540            0,
8541            DegradationLevel::Full,
8542            false,
8543            false,
8544        ));
8545
8546        for expected in 1..=2 {
8547            let snapshot = governor.observe(governor_observation(
8548                8.0,
8549                0,
8550                0,
8551                DegradationLevel::Full,
8552                false,
8553                false,
8554            ));
8555            assert_eq!(snapshot.mode, RuntimeLoadMode::Degraded);
8556            assert_eq!(snapshot.recovery_intervals_observed, expected);
8557            assert_eq!(snapshot.reason_code, "recovery_hysteresis_pending");
8558        }
8559
8560        let recovered = governor.observe(governor_observation(
8561            8.0,
8562            0,
8563            0,
8564            DegradationLevel::Full,
8565            false,
8566            false,
8567        ));
8568        assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
8569    }
8570
8571    #[test]
8572    fn load_governor_stress_e2e_evidence_shows_degraded_and_recovery() {
8573        let mut governor = test_load_governor(50, 2);
8574        let scenario = [
8575            governor_observation(8.0, 0, 0, DegradationLevel::Full, false, false),
8576            governor_observation(18.0, 25, 0, DegradationLevel::Full, true, false),
8577            governor_observation(22.0, 45, 1, DegradationLevel::SimpleBorders, true, false),
8578            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
8579            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
8580            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
8581        ];
8582
8583        let mut modes = Vec::new();
8584        for observation in scenario {
8585            let snapshot = governor.observe(observation);
8586            modes.push(snapshot.mode);
8587            println!(
8588                "{{\"test\":\"load_governor_stress_e2e\",\"mode\":\"{}\",\"pressure_class\":\"{}\",\"work_disposition\":\"{}\",\"reason\":\"{}\",\"transition\":{},\"deferred\":{},\"coalesced\":{},\"dropped\":{}}}",
8589                snapshot.mode.as_str(),
8590                snapshot.pressure_class.as_str(),
8591                snapshot.disposition.as_str(),
8592                snapshot.reason_code,
8593                snapshot.transition,
8594                snapshot.deferred_work_total,
8595                snapshot.coalesced_work_total,
8596                snapshot.dropped_work_total
8597            );
8598        }
8599
8600        assert!(modes.contains(&RuntimeLoadMode::Stressed));
8601        assert!(modes.contains(&RuntimeLoadMode::Degraded));
8602        assert!(modes.contains(&RuntimeLoadMode::Recovered));
8603        assert_eq!(modes.last(), Some(&RuntimeLoadMode::Healthy));
8604    }
8605
8606    #[test]
8607    fn headless_program_default_load_governor_attaches_controller() {
8608        let program =
8609            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8610        assert!(program.budget.controller().is_some());
8611    }
8612
8613    #[test]
8614    fn headless_program_load_governor_target_tracks_frame_budget() {
8615        let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
8616            total: Duration::from_millis(50),
8617            ..Default::default()
8618        });
8619        let program = headless_program_with_config(TestModel { value: 0 }, config);
8620        assert_eq!(
8621            program.budget.controller().unwrap().config().target,
8622            Duration::from_millis(50)
8623        );
8624    }
8625
8626    #[test]
8627    fn headless_program_without_load_governor_uses_legacy_budget() {
8628        let program = headless_program_with_config(
8629            TestModel { value: 0 },
8630            ProgramConfig::default().without_load_governor(),
8631        );
8632        assert!(program.budget.controller().is_none());
8633    }
8634
8635    #[test]
8636    fn app_builder_without_load_governor_sets_config() {
8637        let builder = App::new(TestModel { value: 0 }).without_load_governor();
8638        assert!(!builder.config.load_governor.enabled);
8639    }
8640
8641    #[test]
8642    fn program_config_with_conformal() {
8643        let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
8644            alpha: 0.2,
8645            ..Default::default()
8646        });
8647        assert!(config.conformal_config.is_some());
8648        assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
8649    }
8650
8651    #[test]
8652    fn program_config_forced_size_clamps_minimums() {
8653        let config = ProgramConfig::default().with_forced_size(0, 0);
8654        assert_eq!(config.forced_size, Some((1, 1)));
8655
8656        let cleared = config.without_forced_size();
8657        assert!(cleared.forced_size.is_none());
8658    }
8659
8660    #[test]
8661    fn effect_queue_config_defaults_are_safe() {
8662        let config = EffectQueueConfig::default();
8663        assert!(!config.enabled);
8664        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
8665        assert!(config.scheduler.smith_enabled);
8666        assert!(!config.scheduler.preemptive);
8667        assert_eq!(config.scheduler.aging_factor, 0.0);
8668        assert_eq!(config.scheduler.wait_starve_ms, 0.0);
8669    }
8670
8671    #[test]
8672    fn handle_effect_command_enqueues_or_executes_inline() {
8673        let (result_tx, result_rx) = mpsc::channel::<u32>();
8674        let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
8675        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8676
8677        let ran = Arc::new(AtomicUsize::new(0));
8678        let ran_task = ran.clone();
8679        let cmd = EffectCommand::Enqueue(
8680            TaskSpec::default(),
8681            Box::new(move || {
8682                ran_task.fetch_add(1, Ordering::SeqCst);
8683                7
8684            }),
8685        );
8686
8687        let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
8688        assert_eq!(shutdown, EffectLoopControl::Continue);
8689        assert_eq!(ran.load(Ordering::SeqCst), 0);
8690        assert_eq!(tasks.len(), 1);
8691        assert!(result_rx.try_recv().is_err());
8692
8693        let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
8694            max_queue_size: 0,
8695            ..Default::default()
8696        });
8697        let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8698        let ran_full = Arc::new(AtomicUsize::new(0));
8699        let ran_full_task = ran_full.clone();
8700        let cmd_full = EffectCommand::Enqueue(
8701            TaskSpec::default(),
8702            Box::new(move || {
8703                ran_full_task.fetch_add(1, Ordering::SeqCst);
8704                42
8705            }),
8706        );
8707
8708        let shutdown_full = handle_effect_command(
8709            cmd_full,
8710            &mut full_scheduler,
8711            &mut full_tasks,
8712            &result_tx,
8713            None,
8714            0,
8715        );
8716        assert_eq!(shutdown_full, EffectLoopControl::Continue);
8717        assert!(full_tasks.is_empty());
8718        assert_eq!(ran_full.load(Ordering::SeqCst), 1);
8719        assert_eq!(
8720            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
8721            42
8722        );
8723
8724        let shutdown = handle_effect_command(
8725            EffectCommand::Shutdown,
8726            &mut full_scheduler,
8727            &mut full_tasks,
8728            &result_tx,
8729            None,
8730            0,
8731        );
8732        assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
8733    }
8734
8735    #[test]
8736    fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
8737        let evidence_path = temp_evidence_path("task_executor_backpressure");
8738        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
8739        let sink = EvidenceSink::from_config(&sink_config)
8740            .expect("evidence sink config")
8741            .expect("evidence sink enabled");
8742        let (result_tx, result_rx) = mpsc::channel::<u32>();
8743        let mut scheduler = QueueingScheduler::new(SchedulerConfig {
8744            max_queue_size: 0,
8745            ..Default::default()
8746        });
8747        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8748
8749        let shutdown = handle_effect_command(
8750            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
8751            &mut scheduler,
8752            &mut tasks,
8753            &result_tx,
8754            Some(&sink),
8755            0,
8756        );
8757
8758        assert_eq!(shutdown, EffectLoopControl::Continue);
8759        assert!(tasks.is_empty());
8760        assert_eq!(
8761            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
8762            7
8763        );
8764
8765        let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
8766        assert_eq!(backpressure_line["backend"], "queued");
8767        assert_eq!(backpressure_line["action"], "inline_fallback");
8768        assert_eq!(backpressure_line["max_queue_size"], 0);
8769        assert_eq!(backpressure_line["total_rejected"], 1);
8770
8771        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
8772        assert_eq!(completion_line["backend"], "queued-inline-fallback");
8773        assert!(completion_line["duration_us"].is_number());
8774    }
8775
8776    #[test]
8777    fn effect_queue_loop_executes_tasks_and_shutdowns() {
8778        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8779        let (result_tx, result_rx) = mpsc::channel::<u32>();
8780        let config = EffectQueueConfig {
8781            enabled: true,
8782            backend: TaskExecutorBackend::EffectQueue,
8783            scheduler: SchedulerConfig {
8784                preemptive: false,
8785                ..Default::default()
8786            },
8787            explicit_backend: true,
8788            ..Default::default()
8789        };
8790
8791        let handle = std::thread::spawn(move || {
8792            effect_queue_loop(config, cmd_rx, result_tx, None);
8793        });
8794
8795        cmd_tx
8796            .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
8797            .unwrap();
8798        cmd_tx
8799            .send(EffectCommand::Enqueue(
8800                TaskSpec::new(2.0, 5.0).with_name("second"),
8801                Box::new(|| 20),
8802            ))
8803            .unwrap();
8804
8805        let mut results = vec![
8806            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8807            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8808        ];
8809        results.sort_unstable();
8810        assert_eq!(results, vec![10, 20]);
8811
8812        cmd_tx.send(EffectCommand::Shutdown).unwrap();
8813        let _ = handle.join();
8814    }
8815
8816    #[test]
8817    fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
8818        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8819        let (result_tx, result_rx) = mpsc::channel::<u32>();
8820        let config = EffectQueueConfig {
8821            enabled: true,
8822            backend: TaskExecutorBackend::EffectQueue,
8823            scheduler: SchedulerConfig {
8824                preemptive: false,
8825                ..Default::default()
8826            },
8827            explicit_backend: true,
8828            ..Default::default()
8829        };
8830
8831        let handle = std::thread::spawn(move || {
8832            effect_queue_loop(config, cmd_rx, result_tx, None);
8833        });
8834
8835        cmd_tx
8836            .send(EffectCommand::Enqueue(
8837                TaskSpec::default().with_name("slow"),
8838                Box::new(|| {
8839                    std::thread::sleep(Duration::from_millis(20));
8840                    10
8841                }),
8842            ))
8843            .unwrap();
8844        cmd_tx
8845            .send(EffectCommand::Enqueue(
8846                TaskSpec::new(2.0, 5.0).with_name("fast"),
8847                Box::new(|| 20),
8848            ))
8849            .unwrap();
8850        cmd_tx.send(EffectCommand::Shutdown).unwrap();
8851
8852        let mut results = vec![
8853            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8854            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8855        ];
8856        results.sort_unstable();
8857        assert_eq!(results, vec![10, 20]);
8858
8859        handle
8860            .join()
8861            .expect("effect queue thread joins after draining");
8862    }
8863
8864    #[test]
8865    fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
8866        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8867        let (result_tx, result_rx) = mpsc::channel::<u32>();
8868        let config = EffectQueueConfig {
8869            enabled: true,
8870            backend: TaskExecutorBackend::EffectQueue,
8871            scheduler: SchedulerConfig {
8872                preemptive: false,
8873                ..Default::default()
8874            },
8875            explicit_backend: true,
8876            ..Default::default()
8877        };
8878
8879        let handle = std::thread::spawn(move || {
8880            effect_queue_loop(config, cmd_rx, result_tx, None);
8881        });
8882
8883        cmd_tx
8884            .send(EffectCommand::Enqueue(
8885                TaskSpec::new(3.0, 1.0).with_name("panic"),
8886                Box::new(|| panic!("queued panic")),
8887            ))
8888            .unwrap();
8889        cmd_tx
8890            .send(EffectCommand::Enqueue(
8891                TaskSpec::new(1.0, 5.0).with_name("after"),
8892                Box::new(|| 99),
8893            ))
8894            .unwrap();
8895
8896        assert_eq!(
8897            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8898            99
8899        );
8900
8901        cmd_tx.send(EffectCommand::Shutdown).unwrap();
8902        handle
8903            .join()
8904            .expect("effect queue thread survives task panic");
8905    }
8906
8907    #[test]
8908    fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
8909        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8910        let (result_tx, result_rx) = mpsc::channel::<u32>();
8911        let config = EffectQueueConfig {
8912            enabled: true,
8913            backend: TaskExecutorBackend::EffectQueue,
8914            scheduler: SchedulerConfig {
8915                preemptive: false,
8916                ..Default::default()
8917            },
8918            explicit_backend: true,
8919            ..Default::default()
8920        };
8921
8922        let handle = std::thread::spawn(move || {
8923            effect_queue_loop(config, cmd_rx, result_tx, None);
8924        });
8925
8926        cmd_tx
8927            .send(EffectCommand::Enqueue(
8928                TaskSpec::default().with_name("slow"),
8929                Box::new(|| {
8930                    std::thread::sleep(Duration::from_millis(20));
8931                    10
8932                }),
8933            ))
8934            .unwrap();
8935        cmd_tx.send(EffectCommand::Shutdown).unwrap();
8936        cmd_tx
8937            .send(EffectCommand::Enqueue(
8938                TaskSpec::new(1.0, 1.0).with_name("late"),
8939                Box::new(|| 99),
8940            ))
8941            .unwrap();
8942
8943        assert_eq!(
8944            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8945            10
8946        );
8947        assert!(
8948            result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
8949            "post-shutdown enqueue should not execute"
8950        );
8951
8952        handle
8953            .join()
8954            .expect("effect queue thread joins after rejecting post-shutdown work");
8955    }
8956
8957    #[test]
8958    fn effect_queue_enqueue_after_shutdown_records_drop() {
8959        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
8960        drop(rx);
8961
8962        let queue = EffectQueue {
8963            sender: tx,
8964            handle: None,
8965            closed: true,
8966        };
8967        let runs = Arc::new(AtomicUsize::new(0));
8968        let before = crate::effect_system::effects_queue_dropped();
8969
8970        queue.enqueue(
8971            TaskSpec::default(),
8972            Box::new({
8973                let runs = Arc::clone(&runs);
8974                move || {
8975                    runs.fetch_add(1, Ordering::SeqCst);
8976                    7
8977                }
8978            }),
8979        );
8980
8981        let after = crate::effect_system::effects_queue_dropped();
8982        assert_eq!(runs.load(Ordering::SeqCst), 0);
8983        assert!(
8984            after > before,
8985            "enqueue after shutdown should increment dropped counter"
8986        );
8987    }
8988
8989    #[test]
8990    fn effect_queue_enqueue_with_closed_channel_records_drop() {
8991        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
8992        drop(rx);
8993
8994        let queue = EffectQueue {
8995            sender: tx,
8996            handle: None,
8997            closed: false,
8998        };
8999        let runs = Arc::new(AtomicUsize::new(0));
9000        let before = crate::effect_system::effects_queue_dropped();
9001
9002        queue.enqueue(
9003            TaskSpec::default(),
9004            Box::new({
9005                let runs = Arc::clone(&runs);
9006                move || {
9007                    runs.fetch_add(1, Ordering::SeqCst);
9008                    9
9009                }
9010            }),
9011        );
9012
9013        let after = crate::effect_system::effects_queue_dropped();
9014        assert_eq!(runs.load(Ordering::SeqCst), 0);
9015        assert!(
9016            after > before,
9017            "enqueue into a closed queue channel should increment dropped counter"
9018        );
9019    }
9020
9021    // =========================================================================
9022    // Backpressure tests (bd-2zd0a)
9023    // =========================================================================
9024
9025    #[test]
9026    fn backpressure_drops_tasks_beyond_max_depth() {
9027        let (result_tx, _result_rx) = mpsc::channel::<u32>();
9028        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9029        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9030
9031        // Enqueue 2 tasks with max_depth=2 — should succeed
9032        let r1 = handle_effect_command(
9033            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
9034            &mut scheduler,
9035            &mut tasks,
9036            &result_tx,
9037            None,
9038            2,
9039        );
9040        assert_eq!(r1, EffectLoopControl::Continue);
9041        assert_eq!(tasks.len(), 1);
9042
9043        let r2 = handle_effect_command(
9044            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
9045            &mut scheduler,
9046            &mut tasks,
9047            &result_tx,
9048            None,
9049            2,
9050        );
9051        assert_eq!(r2, EffectLoopControl::Continue);
9052        assert_eq!(tasks.len(), 2);
9053
9054        // 3rd task should be dropped (depth=2 >= max_depth=2)
9055        let dropped_before = crate::effect_system::effects_queue_dropped();
9056        let r3 = handle_effect_command(
9057            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
9058            &mut scheduler,
9059            &mut tasks,
9060            &result_tx,
9061            None,
9062            2,
9063        );
9064        assert_eq!(r3, EffectLoopControl::Continue);
9065        assert_eq!(
9066            tasks.len(),
9067            2,
9068            "task should have been dropped, not enqueued"
9069        );
9070        assert!(
9071            crate::effect_system::effects_queue_dropped() > dropped_before,
9072            "dropped counter should increment"
9073        );
9074    }
9075
9076    #[test]
9077    fn backpressure_zero_depth_means_unbounded() {
9078        let (result_tx, _result_rx) = mpsc::channel::<u32>();
9079        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9080        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9081
9082        // With max_depth=0, can enqueue many tasks
9083        for i in 0..20 {
9084            let r = handle_effect_command(
9085                EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
9086                &mut scheduler,
9087                &mut tasks,
9088                &result_tx,
9089                None,
9090                0,
9091            );
9092            assert_eq!(r, EffectLoopControl::Continue);
9093        }
9094        // All should be enqueued (some may have been inlined by scheduler, but none dropped)
9095    }
9096
9097    #[test]
9098    fn inline_auto_remeasure_reset_clears_decision() {
9099        let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
9100        state.sampler.decide(Instant::now());
9101        assert!(state.sampler.last_decision().is_some());
9102
9103        state.reset();
9104        assert!(state.sampler.last_decision().is_none());
9105    }
9106
9107    #[test]
9108    fn budget_decision_jsonl_contains_required_fields() {
9109        let evidence = BudgetDecisionEvidence {
9110            frame_idx: 7,
9111            decision: BudgetDecision::Degrade,
9112            controller_decision: BudgetDecision::Hold,
9113            degradation_before: DegradationLevel::Full,
9114            degradation_after: DegradationLevel::NoStyling,
9115            frame_time_us: 12_345.678,
9116            budget_us: 16_000.0,
9117            pid_output: 1.25,
9118            pid_p: 0.5,
9119            pid_i: 0.25,
9120            pid_d: 0.5,
9121            e_value: 2.0,
9122            frames_observed: 42,
9123            frames_since_change: 3,
9124            in_warmup: false,
9125            controller_reason: BudgetDecisionReason::OverloadEvidencePassed,
9126            load_governor: LoadGovernorSnapshot {
9127                mode: RuntimeLoadMode::Degraded,
9128                mode_before: RuntimeLoadMode::Stressed,
9129                pressure_class: RuntimePressureClass::HardOverload,
9130                disposition: RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
9131                reason_code: "budget_degradation_active",
9132                transition: true,
9133                strict_semantics_preserved: true,
9134                queue_in_flight: 8,
9135                queue_max_depth: Some(10),
9136                queue_dropped_delta: 0,
9137                resize_coalescing_active: false,
9138                recovery_intervals_observed: 0,
9139                recovery_intervals_required: 3,
9140                deferred_work_total: 2,
9141                coalesced_work_total: 1,
9142                dropped_work_total: 0,
9143            },
9144            conformal: Some(ConformalEvidence {
9145                bucket_key: "inline:dirty:10".to_string(),
9146                n_b: 32,
9147                alpha: 0.05,
9148                q_b: 1000.0,
9149                y_hat: 12_000.0,
9150                upper_us: 13_000.0,
9151                risk: true,
9152                fallback_level: 1,
9153                window_size: 256,
9154                reset_count: 2,
9155            }),
9156        };
9157
9158        let jsonl = evidence.to_jsonl();
9159        assert!(jsonl.contains("\"event\":\"budget_decision\""));
9160        assert!(jsonl.contains("\"decision\":\"degrade\""));
9161        assert!(jsonl.contains("\"decision_controller\":\"stay\""));
9162        assert!(jsonl.contains("\"decision_controller_reason\":\"overload_evidence_passed\""));
9163        assert!(jsonl.contains("\"degradation_before\":\"Full\""));
9164        assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
9165        assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
9166        assert!(jsonl.contains("\"budget_us\":16000.000000"));
9167        assert!(jsonl.contains("\"pid_output\":1.250000"));
9168        assert!(jsonl.contains("\"e_value\":2.000000"));
9169        assert!(jsonl.contains("\"runtime_mode\":\"degraded\""));
9170        assert!(jsonl.contains("\"runtime_mode_before\":\"stressed\""));
9171        assert!(jsonl.contains("\"pressure_class\":\"hard_overload\""));
9172        assert!(jsonl.contains("\"work_disposition\":\"defer_background_drop_best_effort\""));
9173        assert!(jsonl.contains("\"governor_reason\":\"budget_degradation_active\""));
9174        assert!(jsonl.contains("\"governor_transition\":true"));
9175        assert!(jsonl.contains("\"strict_semantics_preserved\":true"));
9176        assert!(jsonl.contains("\"queue_in_flight\":8"));
9177        assert!(jsonl.contains("\"queue_max_depth\":10"));
9178        assert!(jsonl.contains("\"deferred_work_total\":2"));
9179        assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
9180        assert!(jsonl.contains("\"n_b\":32"));
9181        assert!(jsonl.contains("\"alpha\":0.050000"));
9182        assert!(jsonl.contains("\"q_b\":1000.000000"));
9183        assert!(jsonl.contains("\"y_hat\":12000.000000"));
9184        assert!(jsonl.contains("\"upper_us\":13000.000000"));
9185        assert!(jsonl.contains("\"risk\":true"));
9186        assert!(jsonl.contains("\"fallback_level\":1"));
9187        assert!(jsonl.contains("\"window_size\":256"));
9188        assert!(jsonl.contains("\"reset_count\":2"));
9189    }
9190
9191    fn make_signal(
9192        widget_id: u64,
9193        essential: bool,
9194        priority: f32,
9195        staleness_ms: u64,
9196        cost_us: f32,
9197    ) -> WidgetSignal {
9198        WidgetSignal {
9199            widget_id,
9200            essential,
9201            priority,
9202            staleness_ms,
9203            focus_boost: 0.0,
9204            interaction_boost: 0.0,
9205            area_cells: 1,
9206            cost_estimate_us: cost_us,
9207            recent_cost_us: 0.0,
9208            estimate_source: CostEstimateSource::FixedDefault,
9209        }
9210    }
9211
9212    fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
9213        let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
9214        let staleness_window = config.staleness_window_ms.max(1) as f32;
9215        let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
9216        let mut value = config.weight_priority * signal.priority
9217            + config.weight_staleness * staleness_score
9218            + config.weight_focus * signal.focus_boost
9219            + config.weight_interaction * signal.interaction_boost;
9220        if starved {
9221            value += config.starve_boost;
9222        }
9223        let raw_cost = if signal.recent_cost_us > 0.0 {
9224            signal.recent_cost_us
9225        } else {
9226            signal.cost_estimate_us
9227        };
9228        let cost_us = raw_cost.max(config.min_cost_us);
9229        (value, cost_us, starved)
9230    }
9231
9232    fn fifo_select(
9233        signals: &[WidgetSignal],
9234        budget_us: f64,
9235        config: &WidgetRefreshConfig,
9236    ) -> (Vec<u64>, f64, usize) {
9237        let mut selected = Vec::new();
9238        let mut total_value = 0.0f64;
9239        let mut starved_selected = 0usize;
9240        let mut remaining = budget_us;
9241
9242        for signal in signals {
9243            if !signal.essential {
9244                continue;
9245            }
9246            let (value, cost_us, starved) = signal_value_cost(signal, config);
9247            remaining -= cost_us as f64;
9248            total_value += value as f64;
9249            if starved {
9250                starved_selected = starved_selected.saturating_add(1);
9251            }
9252            selected.push(signal.widget_id);
9253        }
9254        for signal in signals {
9255            if signal.essential {
9256                continue;
9257            }
9258            let (value, cost_us, starved) = signal_value_cost(signal, config);
9259            if remaining >= cost_us as f64 {
9260                remaining -= cost_us as f64;
9261                total_value += value as f64;
9262                if starved {
9263                    starved_selected = starved_selected.saturating_add(1);
9264                }
9265                selected.push(signal.widget_id);
9266            }
9267        }
9268
9269        (selected, total_value, starved_selected)
9270    }
9271
9272    fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
9273        if signals.is_empty() {
9274            return Vec::new();
9275        }
9276        let mut rotated = Vec::with_capacity(signals.len());
9277        for idx in 0..signals.len() {
9278            rotated.push(signals[(idx + offset) % signals.len()].clone());
9279        }
9280        rotated
9281    }
9282
9283    #[test]
9284    fn widget_refresh_selects_essentials_first() {
9285        let signals = vec![
9286            make_signal(1, true, 0.6, 0, 5.0),
9287            make_signal(2, false, 0.9, 0, 4.0),
9288        ];
9289        let mut plan = WidgetRefreshPlan::new();
9290        let config = WidgetRefreshConfig::default();
9291        plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
9292        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9293        assert_eq!(selected, vec![1]);
9294        assert!(!plan.over_budget);
9295    }
9296
9297    #[test]
9298    fn widget_refresh_degradation_essential_only_skips_nonessential() {
9299        let signals = vec![
9300            make_signal(1, true, 0.5, 0, 2.0),
9301            make_signal(2, false, 1.0, 0, 1.0),
9302        ];
9303        let mut plan = WidgetRefreshPlan::new();
9304        let config = WidgetRefreshConfig::default();
9305        plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
9306        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9307        assert_eq!(selected, vec![1]);
9308        assert_eq!(plan.skipped_count, 1);
9309    }
9310
9311    #[test]
9312    fn widget_refresh_starvation_guard_forces_one_starved() {
9313        let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
9314        let mut plan = WidgetRefreshPlan::new();
9315        let config = WidgetRefreshConfig {
9316            starve_ms: 1_000,
9317            max_starved_per_frame: 1,
9318            ..Default::default()
9319        };
9320        plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
9321        assert_eq!(plan.selected.len(), 1);
9322        assert!(plan.selected[0].starved);
9323        assert!(plan.over_budget);
9324    }
9325
9326    #[test]
9327    fn widget_refresh_budget_blocks_when_no_selection() {
9328        let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
9329        let mut plan = WidgetRefreshPlan::new();
9330        let config = WidgetRefreshConfig {
9331            starve_ms: 0,
9332            max_starved_per_frame: 0,
9333            ..Default::default()
9334        };
9335        plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
9336        let budget = plan.as_budget();
9337        assert!(!budget.allows(42, false));
9338    }
9339
9340    #[test]
9341    fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
9342        let signals = vec![
9343            make_signal(1, false, 0.4, 0, 10.0),
9344            make_signal(2, false, 0.4, 0, 10.0),
9345            make_signal(3, false, 0.4, 0, 10.0),
9346            make_signal(4, false, 0.4, 0, 10.0),
9347        ];
9348        let mut plan = WidgetRefreshPlan::new();
9349        let config = WidgetRefreshConfig {
9350            starve_ms: 0,
9351            max_starved_per_frame: 0,
9352            max_drop_fraction: 0.5,
9353            ..Default::default()
9354        };
9355        plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
9356        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9357        assert_eq!(selected, vec![1, 2]);
9358    }
9359
9360    #[test]
9361    fn widget_refresh_greedy_beats_fifo_and_round_robin() {
9362        let signals = vec![
9363            make_signal(1, false, 0.1, 0, 6.0),
9364            make_signal(2, false, 0.2, 0, 6.0),
9365            make_signal(3, false, 1.0, 0, 4.0),
9366            make_signal(4, false, 0.9, 0, 3.0),
9367            make_signal(5, false, 0.8, 0, 3.0),
9368            make_signal(6, false, 0.1, 4_000, 2.0),
9369        ];
9370        let budget_us = 10.0;
9371        let config = WidgetRefreshConfig::default();
9372
9373        let mut plan = WidgetRefreshPlan::new();
9374        plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
9375        let greedy_value = plan.selected_value;
9376        let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9377
9378        let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
9379        let rotated = rotate_signals(&signals, 2);
9380        let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
9381
9382        assert!(
9383            greedy_value > fifo_value,
9384            "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
9385            greedy_selected,
9386            fifo_selected
9387        );
9388        assert!(
9389            greedy_value > rr_value,
9390            "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
9391            greedy_selected,
9392            rr_selected
9393        );
9394        assert!(
9395            plan.starved_selected > 0,
9396            "greedy did not select starved widget; greedy={:?}",
9397            greedy_selected
9398        );
9399    }
9400
9401    #[test]
9402    fn widget_refresh_jsonl_contains_required_fields() {
9403        let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
9404        let mut plan = WidgetRefreshPlan::new();
9405        let config = WidgetRefreshConfig::default();
9406        plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
9407        let jsonl = plan.to_jsonl();
9408        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
9409        assert!(jsonl.contains("\"frame_idx\":9"));
9410        assert!(jsonl.contains("\"selected_count\":1"));
9411        assert!(jsonl.contains("\"id\":7"));
9412    }
9413
9414    #[test]
9415    fn program_config_with_resize_coalescer() {
9416        let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
9417            steady_delay_ms: 8,
9418            burst_delay_ms: 20,
9419            hard_deadline_ms: 80,
9420            burst_enter_rate: 12.0,
9421            burst_exit_rate: 6.0,
9422            cooldown_frames: 2,
9423            rate_window_size: 6,
9424            enable_logging: true,
9425            enable_bocpd: false,
9426            bocpd_config: None,
9427        });
9428        assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
9429        assert!(config.resize_coalescer.enable_logging);
9430    }
9431
9432    #[test]
9433    fn program_config_with_resize_behavior() {
9434        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
9435        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
9436    }
9437
9438    #[test]
9439    fn program_config_with_legacy_resize_enabled() {
9440        let config = ProgramConfig::default().with_legacy_resize(true);
9441        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
9442    }
9443
9444    #[test]
9445    fn program_config_with_legacy_resize_disabled_keeps_default() {
9446        let config = ProgramConfig::default().with_legacy_resize(false);
9447        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
9448    }
9449
9450    fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
9451        let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
9452        let mut writer = TerminalWriter::with_diff_config(
9453            Vec::<u8>::new(),
9454            ScreenMode::AltScreen,
9455            UiAnchor::Bottom,
9456            TerminalCapabilities::basic(),
9457            config,
9458        );
9459        writer.set_size(8, 4);
9460
9461        let mut buffer = Buffer::new(8, 4);
9462        let mut trace = Vec::new();
9463
9464        writer.present_ui(&buffer, None, false).unwrap();
9465        trace.push(
9466            writer
9467                .last_diff_strategy()
9468                .unwrap_or(DiffStrategy::FullRedraw),
9469        );
9470
9471        buffer.set_raw(0, 0, Cell::from_char('A'));
9472        writer.present_ui(&buffer, None, false).unwrap();
9473        trace.push(
9474            writer
9475                .last_diff_strategy()
9476                .unwrap_or(DiffStrategy::FullRedraw),
9477        );
9478
9479        buffer.set_raw(1, 1, Cell::from_char('B'));
9480        writer.present_ui(&buffer, None, false).unwrap();
9481        trace.push(
9482            writer
9483                .last_diff_strategy()
9484                .unwrap_or(DiffStrategy::FullRedraw),
9485        );
9486
9487        trace
9488    }
9489
9490    fn coalescer_checksum(enable_bocpd: bool) -> String {
9491        let mut config = CoalescerConfig::default().with_logging(true);
9492        if enable_bocpd {
9493            config = config.with_bocpd();
9494        }
9495
9496        let base = Instant::now();
9497        let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
9498
9499        let events = [
9500            (0_u64, (82_u16, 24_u16)),
9501            (10, (83, 25)),
9502            (20, (84, 26)),
9503            (35, (90, 28)),
9504            (55, (92, 30)),
9505        ];
9506
9507        let mut idx = 0usize;
9508        for t_ms in (0_u64..=160).step_by(8) {
9509            let now = base + Duration::from_millis(t_ms);
9510            while idx < events.len() && events[idx].0 == t_ms {
9511                let (w, h) = events[idx].1;
9512                coalescer.handle_resize_at(w, h, now);
9513                idx += 1;
9514            }
9515            coalescer.tick_at(now);
9516        }
9517
9518        coalescer.decision_checksum_hex()
9519    }
9520
9521    fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
9522        if !enabled {
9523            return Vec::new();
9524        }
9525
9526        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
9527        let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
9528        let mut trace = Vec::new();
9529
9530        for i in 0..30 {
9531            let y_hat = 16_000.0 + (i as f64) * 15.0;
9532            let observed = y_hat + (i % 7) as f64 * 120.0;
9533            predictor.observe(key, y_hat, observed);
9534            let prediction = predictor.predict(key, y_hat, 20_000.0);
9535            trace.push((prediction.upper_us, prediction.risk));
9536        }
9537
9538        trace
9539    }
9540
9541    #[test]
9542    fn policy_toggle_matrix_determinism() {
9543        for &bayesian in &[false, true] {
9544            for &bocpd in &[false, true] {
9545                for &conformal in &[false, true] {
9546                    let diff_a = diff_strategy_trace(bayesian);
9547                    let diff_b = diff_strategy_trace(bayesian);
9548                    assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
9549
9550                    let checksum_a = coalescer_checksum(bocpd);
9551                    let checksum_b = coalescer_checksum(bocpd);
9552                    assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
9553
9554                    let conf_a = conformal_trace(conformal);
9555                    let conf_b = conformal_trace(conformal);
9556                    assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
9557
9558                    if conformal {
9559                        assert!(!conf_a.is_empty(), "conformal trace should be populated");
9560                    } else {
9561                        assert!(conf_a.is_empty(), "conformal trace should be empty");
9562                    }
9563                }
9564            }
9565        }
9566    }
9567
9568    #[test]
9569    fn resize_behavior_uses_coalescer_flag() {
9570        assert!(ResizeBehavior::Throttled.uses_coalescer());
9571        assert!(!ResizeBehavior::Immediate.uses_coalescer());
9572    }
9573
9574    #[test]
9575    fn nested_cmd_msg_executes_recursively() {
9576        // Verify that Cmd::Msg triggers recursive update
9577        use crate::simulator::ProgramSimulator;
9578
9579        struct NestedModel {
9580            depth: usize,
9581        }
9582
9583        #[derive(Debug)]
9584        enum NestedMsg {
9585            Nest(usize),
9586        }
9587
9588        impl From<Event> for NestedMsg {
9589            fn from(_: Event) -> Self {
9590                NestedMsg::Nest(0)
9591            }
9592        }
9593
9594        impl Model for NestedModel {
9595            type Message = NestedMsg;
9596
9597            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9598                match msg {
9599                    NestedMsg::Nest(n) => {
9600                        self.depth += 1;
9601                        if n > 0 {
9602                            Cmd::msg(NestedMsg::Nest(n - 1))
9603                        } else {
9604                            Cmd::none()
9605                        }
9606                    }
9607                }
9608            }
9609
9610            fn view(&self, _frame: &mut Frame) {}
9611        }
9612
9613        let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
9614        sim.init();
9615        sim.send(NestedMsg::Nest(3));
9616
9617        // Should have recursed 4 times (3, 2, 1, 0)
9618        assert_eq!(sim.model().depth, 4);
9619    }
9620
9621    #[test]
9622    fn task_executes_synchronously_in_simulator() {
9623        // In simulator, tasks execute synchronously
9624        use crate::simulator::ProgramSimulator;
9625
9626        struct TaskModel {
9627            completed: bool,
9628        }
9629
9630        #[derive(Debug)]
9631        enum TaskMsg {
9632            Complete,
9633            SpawnTask,
9634        }
9635
9636        impl From<Event> for TaskMsg {
9637            fn from(_: Event) -> Self {
9638                TaskMsg::Complete
9639            }
9640        }
9641
9642        impl Model for TaskModel {
9643            type Message = TaskMsg;
9644
9645            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9646                match msg {
9647                    TaskMsg::Complete => {
9648                        self.completed = true;
9649                        Cmd::none()
9650                    }
9651                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
9652                }
9653            }
9654
9655            fn view(&self, _frame: &mut Frame) {}
9656        }
9657
9658        let mut sim = ProgramSimulator::new(TaskModel { completed: false });
9659        sim.init();
9660        sim.send(TaskMsg::SpawnTask);
9661
9662        // Task should have completed synchronously
9663        assert!(sim.model().completed);
9664    }
9665
9666    #[test]
9667    fn multiple_updates_accumulate_correctly() {
9668        // Verify state accumulates correctly across multiple updates
9669        use crate::simulator::ProgramSimulator;
9670
9671        struct AccumModel {
9672            sum: i32,
9673        }
9674
9675        #[derive(Debug)]
9676        enum AccumMsg {
9677            Add(i32),
9678            Multiply(i32),
9679        }
9680
9681        impl From<Event> for AccumMsg {
9682            fn from(_: Event) -> Self {
9683                AccumMsg::Add(1)
9684            }
9685        }
9686
9687        impl Model for AccumModel {
9688            type Message = AccumMsg;
9689
9690            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9691                match msg {
9692                    AccumMsg::Add(n) => {
9693                        self.sum += n;
9694                        Cmd::none()
9695                    }
9696                    AccumMsg::Multiply(n) => {
9697                        self.sum *= n;
9698                        Cmd::none()
9699                    }
9700                }
9701            }
9702
9703            fn view(&self, _frame: &mut Frame) {}
9704        }
9705
9706        let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
9707        sim.init();
9708
9709        // (0 + 5) * 2 + 3 = 13
9710        sim.send(AccumMsg::Add(5));
9711        sim.send(AccumMsg::Multiply(2));
9712        sim.send(AccumMsg::Add(3));
9713
9714        assert_eq!(sim.model().sum, 13);
9715    }
9716
9717    #[test]
9718    fn init_command_executes_before_first_update() {
9719        // Verify init() command executes before any update
9720        use crate::simulator::ProgramSimulator;
9721
9722        struct InitModel {
9723            initialized: bool,
9724            updates: usize,
9725        }
9726
9727        #[derive(Debug)]
9728        enum InitMsg {
9729            Update,
9730            MarkInit,
9731        }
9732
9733        impl From<Event> for InitMsg {
9734            fn from(_: Event) -> Self {
9735                InitMsg::Update
9736            }
9737        }
9738
9739        impl Model for InitModel {
9740            type Message = InitMsg;
9741
9742            fn init(&mut self) -> Cmd<Self::Message> {
9743                Cmd::msg(InitMsg::MarkInit)
9744            }
9745
9746            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9747                match msg {
9748                    InitMsg::MarkInit => {
9749                        self.initialized = true;
9750                        Cmd::none()
9751                    }
9752                    InitMsg::Update => {
9753                        self.updates += 1;
9754                        Cmd::none()
9755                    }
9756                }
9757            }
9758
9759            fn view(&self, _frame: &mut Frame) {}
9760        }
9761
9762        let mut sim = ProgramSimulator::new(InitModel {
9763            initialized: false,
9764            updates: 0,
9765        });
9766        sim.init();
9767
9768        assert!(sim.model().initialized);
9769        sim.send(InitMsg::Update);
9770        assert_eq!(sim.model().updates, 1);
9771    }
9772
9773    // =========================================================================
9774    // INLINE MODE FRAME SIZING TESTS (bd-20vg)
9775    // =========================================================================
9776
9777    #[test]
9778    fn ui_height_returns_correct_value_inline_mode() {
9779        // Verify TerminalWriter.ui_height() returns ui_height in inline mode
9780        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9781        use ftui_core::terminal_capabilities::TerminalCapabilities;
9782
9783        let output = Vec::new();
9784        let writer = TerminalWriter::new(
9785            output,
9786            ScreenMode::Inline { ui_height: 10 },
9787            UiAnchor::Bottom,
9788            TerminalCapabilities::basic(),
9789        );
9790        assert_eq!(writer.ui_height(), 10);
9791    }
9792
9793    #[test]
9794    fn ui_height_returns_term_height_altscreen_mode() {
9795        // Verify TerminalWriter.ui_height() returns full terminal height in alt-screen mode
9796        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9797        use ftui_core::terminal_capabilities::TerminalCapabilities;
9798
9799        let output = Vec::new();
9800        let mut writer = TerminalWriter::new(
9801            output,
9802            ScreenMode::AltScreen,
9803            UiAnchor::Bottom,
9804            TerminalCapabilities::basic(),
9805        );
9806        writer.set_size(80, 24);
9807        assert_eq!(writer.ui_height(), 24);
9808    }
9809
9810    #[test]
9811    fn inline_mode_frame_uses_ui_height_not_terminal_height() {
9812        // Verify that in inline mode, the model receives a frame with ui_height,
9813        // not the full terminal height. This is the core fix for bd-20vg.
9814        use crate::simulator::ProgramSimulator;
9815        use std::cell::Cell as StdCell;
9816
9817        thread_local! {
9818            static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
9819        }
9820
9821        struct FrameSizeTracker;
9822
9823        #[derive(Debug)]
9824        enum SizeMsg {
9825            Check,
9826        }
9827
9828        impl From<Event> for SizeMsg {
9829            fn from(_: Event) -> Self {
9830                SizeMsg::Check
9831            }
9832        }
9833
9834        impl Model for FrameSizeTracker {
9835            type Message = SizeMsg;
9836
9837            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9838                Cmd::none()
9839            }
9840
9841            fn view(&self, frame: &mut Frame) {
9842                // Capture the frame height we receive
9843                CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
9844            }
9845        }
9846
9847        // Use simulator to verify frame dimension handling
9848        let mut sim = ProgramSimulator::new(FrameSizeTracker);
9849        sim.init();
9850
9851        // Capture with specific dimensions (simulates inline mode ui_height=10)
9852        let buf = sim.capture_frame(80, 10);
9853        assert_eq!(buf.height(), 10);
9854        assert_eq!(buf.width(), 80);
9855
9856        // Verify the frame has the correct dimensions
9857        // In inline mode with ui_height=10, the frame should be 10 rows tall,
9858        // NOT the full terminal height (e.g., 24).
9859    }
9860
9861    #[test]
9862    fn altscreen_frame_uses_full_terminal_height() {
9863        // Regression test: in alt-screen mode, frame should use full terminal height.
9864        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9865        use ftui_core::terminal_capabilities::TerminalCapabilities;
9866
9867        let output = Vec::new();
9868        let mut writer = TerminalWriter::new(
9869            output,
9870            ScreenMode::AltScreen,
9871            UiAnchor::Bottom,
9872            TerminalCapabilities::basic(),
9873        );
9874        writer.set_size(80, 40);
9875
9876        // In alt-screen, ui_height equals terminal height
9877        assert_eq!(writer.ui_height(), 40);
9878    }
9879
9880    #[test]
9881    fn ui_height_clamped_to_terminal_height() {
9882        // Verify ui_height doesn't exceed terminal height
9883        // (This is handled in present_inline, but ui_height() returns the configured value)
9884        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9885        use ftui_core::terminal_capabilities::TerminalCapabilities;
9886
9887        let output = Vec::new();
9888        let mut writer = TerminalWriter::new(
9889            output,
9890            ScreenMode::Inline { ui_height: 100 },
9891            UiAnchor::Bottom,
9892            TerminalCapabilities::basic(),
9893        );
9894        writer.set_size(80, 10);
9895
9896        // ui_height() returns configured value, but present_inline clamps
9897        // The Frame should be created with ui_height (100), which is later
9898        // clamped during presentation. For safety, we should use the min.
9899        // Note: This documents current behavior. A stricter fix might
9900        // have ui_height() return min(ui_height, term_height).
9901        assert_eq!(writer.ui_height(), 100);
9902    }
9903
9904    // =========================================================================
9905    // TICK DELIVERY TESTS (bd-3ufh)
9906    // =========================================================================
9907
9908    #[test]
9909    fn tick_event_delivered_to_model_update() {
9910        // Verify that Event::Tick is delivered to model.update()
9911        // This is the core fix: ticks now flow through the update pipeline.
9912        use crate::simulator::ProgramSimulator;
9913
9914        struct TickTracker {
9915            tick_count: usize,
9916        }
9917
9918        #[derive(Debug)]
9919        enum TickMsg {
9920            Tick,
9921            Other,
9922        }
9923
9924        impl From<Event> for TickMsg {
9925            fn from(event: Event) -> Self {
9926                match event {
9927                    Event::Tick => TickMsg::Tick,
9928                    _ => TickMsg::Other,
9929                }
9930            }
9931        }
9932
9933        impl Model for TickTracker {
9934            type Message = TickMsg;
9935
9936            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9937                match msg {
9938                    TickMsg::Tick => {
9939                        self.tick_count += 1;
9940                        Cmd::none()
9941                    }
9942                    TickMsg::Other => Cmd::none(),
9943                }
9944            }
9945
9946            fn view(&self, _frame: &mut Frame) {}
9947        }
9948
9949        let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
9950        sim.init();
9951
9952        // Manually inject tick event to simulate what the runtime does
9953        sim.inject_event(Event::Tick);
9954        assert_eq!(sim.model().tick_count, 1);
9955
9956        sim.inject_event(Event::Tick);
9957        sim.inject_event(Event::Tick);
9958        assert_eq!(sim.model().tick_count, 3);
9959    }
9960
9961    #[test]
9962    fn tick_command_sets_tick_rate() {
9963        // Verify Cmd::tick() sets the tick rate in the simulator
9964        use crate::simulator::{CmdRecord, ProgramSimulator};
9965
9966        struct TickModel;
9967
9968        #[derive(Debug)]
9969        enum Msg {
9970            SetTick,
9971            Noop,
9972        }
9973
9974        impl From<Event> for Msg {
9975            fn from(_: Event) -> Self {
9976                Msg::Noop
9977            }
9978        }
9979
9980        impl Model for TickModel {
9981            type Message = Msg;
9982
9983            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9984                match msg {
9985                    Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
9986                    Msg::Noop => Cmd::none(),
9987                }
9988            }
9989
9990            fn view(&self, _frame: &mut Frame) {}
9991        }
9992
9993        let mut sim = ProgramSimulator::new(TickModel);
9994        sim.init();
9995        sim.send(Msg::SetTick);
9996
9997        // Check that tick was recorded
9998        let commands = sim.command_log();
9999        assert!(
10000            commands
10001                .iter()
10002                .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
10003        );
10004    }
10005
10006    #[test]
10007    fn tick_can_trigger_further_commands() {
10008        // Verify that tick handling can return commands that are executed
10009        use crate::simulator::ProgramSimulator;
10010
10011        struct ChainModel {
10012            stage: usize,
10013        }
10014
10015        #[derive(Debug)]
10016        enum ChainMsg {
10017            Tick,
10018            Advance,
10019            Noop,
10020        }
10021
10022        impl From<Event> for ChainMsg {
10023            fn from(event: Event) -> Self {
10024                match event {
10025                    Event::Tick => ChainMsg::Tick,
10026                    _ => ChainMsg::Noop,
10027                }
10028            }
10029        }
10030
10031        impl Model for ChainModel {
10032            type Message = ChainMsg;
10033
10034            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10035                match msg {
10036                    ChainMsg::Tick => {
10037                        self.stage += 1;
10038                        // Return another message to be processed
10039                        Cmd::msg(ChainMsg::Advance)
10040                    }
10041                    ChainMsg::Advance => {
10042                        self.stage += 10;
10043                        Cmd::none()
10044                    }
10045                    ChainMsg::Noop => Cmd::none(),
10046                }
10047            }
10048
10049            fn view(&self, _frame: &mut Frame) {}
10050        }
10051
10052        let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
10053        sim.init();
10054        sim.inject_event(Event::Tick);
10055
10056        // Tick increments by 1, then Advance increments by 10
10057        assert_eq!(sim.model().stage, 11);
10058    }
10059
10060    #[test]
10061    fn tick_disabled_with_zero_duration() {
10062        // Verify that Duration::ZERO disables ticks (no busy loop)
10063        use crate::simulator::ProgramSimulator;
10064
10065        struct ZeroTickModel {
10066            disabled: bool,
10067        }
10068
10069        #[derive(Debug)]
10070        enum ZeroMsg {
10071            DisableTick,
10072            Noop,
10073        }
10074
10075        impl From<Event> for ZeroMsg {
10076            fn from(_: Event) -> Self {
10077                ZeroMsg::Noop
10078            }
10079        }
10080
10081        impl Model for ZeroTickModel {
10082            type Message = ZeroMsg;
10083
10084            fn init(&mut self) -> Cmd<Self::Message> {
10085                // Start with a tick enabled
10086                Cmd::tick(Duration::from_millis(100))
10087            }
10088
10089            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10090                match msg {
10091                    ZeroMsg::DisableTick => {
10092                        self.disabled = true;
10093                        // Setting tick to ZERO should effectively disable
10094                        Cmd::tick(Duration::ZERO)
10095                    }
10096                    ZeroMsg::Noop => Cmd::none(),
10097                }
10098            }
10099
10100            fn view(&self, _frame: &mut Frame) {}
10101        }
10102
10103        let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
10104        sim.init();
10105
10106        // Verify initial tick rate is set
10107        assert!(sim.tick_rate().is_some());
10108        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
10109
10110        // Disable ticks
10111        sim.send(ZeroMsg::DisableTick);
10112        assert!(sim.model().disabled);
10113
10114        // Note: The simulator still records the ZERO tick, but the runtime's
10115        // should_tick() handles ZERO duration appropriately
10116        assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
10117    }
10118
10119    #[test]
10120    fn tick_event_distinguishable_from_other_events() {
10121        // Verify Event::Tick can be distinguished in pattern matching
10122        let tick = Event::Tick;
10123        let key = Event::Key(ftui_core::event::KeyEvent::new(
10124            ftui_core::event::KeyCode::Char('a'),
10125        ));
10126
10127        assert!(matches!(tick, Event::Tick));
10128        assert!(!matches!(key, Event::Tick));
10129    }
10130
10131    #[test]
10132    fn tick_event_clone_and_eq() {
10133        // Verify Event::Tick implements Clone and Eq correctly
10134        let tick1 = Event::Tick;
10135        let tick2 = tick1.clone();
10136        assert_eq!(tick1, tick2);
10137    }
10138
10139    #[test]
10140    fn model_receives_tick_and_input_events() {
10141        // Verify model can handle both tick and input events correctly
10142        use crate::simulator::ProgramSimulator;
10143
10144        struct MixedModel {
10145            ticks: usize,
10146            keys: usize,
10147        }
10148
10149        #[derive(Debug)]
10150        enum MixedMsg {
10151            Tick,
10152            Key,
10153        }
10154
10155        impl From<Event> for MixedMsg {
10156            fn from(event: Event) -> Self {
10157                match event {
10158                    Event::Tick => MixedMsg::Tick,
10159                    _ => MixedMsg::Key,
10160                }
10161            }
10162        }
10163
10164        impl Model for MixedModel {
10165            type Message = MixedMsg;
10166
10167            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10168                match msg {
10169                    MixedMsg::Tick => {
10170                        self.ticks += 1;
10171                        Cmd::none()
10172                    }
10173                    MixedMsg::Key => {
10174                        self.keys += 1;
10175                        Cmd::none()
10176                    }
10177                }
10178            }
10179
10180            fn view(&self, _frame: &mut Frame) {}
10181        }
10182
10183        let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
10184        sim.init();
10185
10186        // Interleave tick and input events
10187        sim.inject_event(Event::Tick);
10188        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
10189            ftui_core::event::KeyCode::Char('a'),
10190        )));
10191        sim.inject_event(Event::Tick);
10192        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
10193            ftui_core::event::KeyCode::Char('b'),
10194        )));
10195        sim.inject_event(Event::Tick);
10196
10197        assert_eq!(sim.model().ticks, 3);
10198        assert_eq!(sim.model().keys, 2);
10199    }
10200
10201    // =========================================================================
10202    // HEADLESS PROGRAM TESTS (bd-1av4o.2)
10203    // =========================================================================
10204
10205    fn headless_program_with_resolved_config<M: Model>(
10206        model: M,
10207        config: ProgramConfig,
10208    ) -> Program<M, HeadlessEventSource, Vec<u8>>
10209    where
10210        M::Message: Send + 'static,
10211    {
10212        clear_termination_signal();
10213        let effect_queue_config = config.resolved_effect_queue_config();
10214        let capabilities = TerminalCapabilities::basic();
10215        let mut writer = TerminalWriter::with_diff_config(
10216            Vec::new(),
10217            config.screen_mode,
10218            config.ui_anchor,
10219            capabilities,
10220            config.diff_config.clone(),
10221        );
10222        let frame_timing = config.frame_timing.clone();
10223        writer.set_timing_enabled(frame_timing.is_some());
10224
10225        let (width, height) = config.forced_size.unwrap_or((80, 24));
10226        let width = width.max(1);
10227        let height = height.max(1);
10228        writer.set_size(width, height);
10229
10230        let mouse_capture = config.resolved_mouse_capture();
10231        let initial_features = BackendFeatures {
10232            mouse_capture,
10233            bracketed_paste: config.bracketed_paste,
10234            focus_events: config.focus_reporting,
10235            kitty_keyboard: config.kitty_keyboard,
10236        };
10237        let events = HeadlessEventSource::new(width, height, initial_features);
10238        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
10239            .expect("headless evidence sink config");
10240
10241        let budget = render_budget_from_program_config(&config);
10242        let load_governor = LoadGovernorState::new(
10243            config.load_governor.clone(),
10244            effect_queue_config.max_queue_depth,
10245        );
10246        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
10247        let locale_context = config.locale_context.clone();
10248        let locale_version = locale_context.version();
10249        let mut resize_coalescer =
10250            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
10251        if let Some(ref sink) = evidence_sink {
10252            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
10253        }
10254        let subscriptions = SubscriptionManager::new();
10255        let (task_sender, task_receiver) = std::sync::mpsc::channel();
10256        let inline_auto_remeasure = config
10257            .inline_auto_remeasure
10258            .clone()
10259            .map(InlineAutoRemeasureState::new);
10260        let guardrails = FrameGuardrails::new(config.guardrails);
10261        let task_executor = TaskExecutor::new(
10262            &effect_queue_config,
10263            task_sender.clone(),
10264            evidence_sink.clone(),
10265        )
10266        .expect("task executor");
10267
10268        Program {
10269            model,
10270            writer,
10271            events,
10272            backend_features: initial_features,
10273            running: true,
10274            tick_rate: None,
10275            executed_cmd_count: 0,
10276            last_tick: Instant::now(),
10277            dirty: true,
10278            frame_idx: 0,
10279            tick_count: 0,
10280            widget_signals: Vec::new(),
10281            widget_refresh_config: config.widget_refresh,
10282            widget_refresh_plan: WidgetRefreshPlan::new(),
10283            width,
10284            height,
10285            forced_size: config.forced_size,
10286            poll_timeout: config.poll_timeout,
10287            intercept_signals: config.intercept_signals,
10288            immediate_drain_config: config.immediate_drain,
10289            immediate_drain_stats: ImmediateDrainStats::default(),
10290            budget,
10291            load_governor,
10292            conformal_predictor,
10293            last_frame_time_us: None,
10294            last_update_us: None,
10295            frame_timing,
10296            locale_context,
10297            locale_version,
10298            resize_coalescer,
10299            evidence_sink,
10300            fairness_config_logged: false,
10301            resize_behavior: config.resize_behavior,
10302            fairness_guard: InputFairnessGuard::new(),
10303            event_recorder: None,
10304            subscriptions,
10305            #[cfg(test)]
10306            task_sender,
10307            task_receiver,
10308            task_executor,
10309            state_registry: config.persistence.registry.clone(),
10310            persistence_config: config.persistence,
10311            last_checkpoint: Instant::now(),
10312            inline_auto_remeasure,
10313            frame_arena: FrameArena::default(),
10314            guardrails,
10315            tick_strategy: config
10316                .tick_strategy
10317                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
10318            last_active_screen_for_strategy: None,
10319        }
10320    }
10321
10322    fn headless_program_with_config<M: Model>(
10323        model: M,
10324        config: ProgramConfig,
10325    ) -> Program<M, HeadlessEventSource, Vec<u8>>
10326    where
10327        M::Message: Send + 'static,
10328    {
10329        // Headless unit tests should not observe process-global shutdown state
10330        // unless they explicitly opt into signal interception.
10331        headless_program_with_resolved_config(model, config.with_signal_interception(false))
10332    }
10333
10334    fn headless_signal_program_with_config<M: Model>(
10335        model: M,
10336        config: ProgramConfig,
10337    ) -> Program<M, HeadlessEventSource, Vec<u8>>
10338    where
10339        M::Message: Send + 'static,
10340    {
10341        headless_program_with_resolved_config(model, config)
10342    }
10343
10344    fn temp_evidence_path(label: &str) -> PathBuf {
10345        static COUNTER: AtomicUsize = AtomicUsize::new(0);
10346        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
10347        let pid = std::process::id();
10348        let mut path = std::env::temp_dir();
10349        path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
10350        path
10351    }
10352
10353    fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
10354        let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
10355        let needle = format!("\"event\":\"{event}\"");
10356        let missing_msg = format!("missing {event} line");
10357        let line = jsonl
10358            .lines()
10359            .find(|line| line.contains(&needle))
10360            .expect(&missing_msg);
10361        serde_json::from_str(line).expect("valid evidence json")
10362    }
10363
10364    #[test]
10365    fn headless_apply_resize_updates_model_and_dimensions() {
10366        struct ResizeModel {
10367            last_size: Option<(u16, u16)>,
10368        }
10369
10370        #[derive(Debug)]
10371        enum ResizeMsg {
10372            Resize(u16, u16),
10373            Other,
10374        }
10375
10376        impl From<Event> for ResizeMsg {
10377            fn from(event: Event) -> Self {
10378                match event {
10379                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
10380                    _ => ResizeMsg::Other,
10381                }
10382            }
10383        }
10384
10385        impl Model for ResizeModel {
10386            type Message = ResizeMsg;
10387
10388            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10389                if let ResizeMsg::Resize(w, h) = msg {
10390                    self.last_size = Some((w, h));
10391                }
10392                Cmd::none()
10393            }
10394
10395            fn view(&self, _frame: &mut Frame) {}
10396        }
10397
10398        let mut program =
10399            headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
10400        program.dirty = false;
10401
10402        program
10403            .apply_resize(0, 0, Duration::ZERO, false)
10404            .expect("resize");
10405
10406        assert_eq!(program.width, 1);
10407        assert_eq!(program.height, 1);
10408        assert_eq!(program.model().last_size, Some((1, 1)));
10409        assert!(program.dirty);
10410    }
10411
10412    #[test]
10413    fn headless_apply_resize_reconciles_subscriptions() {
10414        use crate::subscription::{StopSignal, SubId, Subscription};
10415
10416        struct ResizeSubModel {
10417            subscribed: bool,
10418        }
10419
10420        #[derive(Debug)]
10421        enum ResizeSubMsg {
10422            Resize,
10423            Other,
10424        }
10425
10426        impl From<Event> for ResizeSubMsg {
10427            fn from(event: Event) -> Self {
10428                match event {
10429                    Event::Resize { .. } => Self::Resize,
10430                    _ => Self::Other,
10431                }
10432            }
10433        }
10434
10435        impl Model for ResizeSubModel {
10436            type Message = ResizeSubMsg;
10437
10438            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10439                if matches!(msg, ResizeSubMsg::Resize) {
10440                    self.subscribed = true;
10441                }
10442                Cmd::none()
10443            }
10444
10445            fn view(&self, _frame: &mut Frame) {}
10446
10447            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10448                if self.subscribed {
10449                    vec![Box::new(ResizeSubscription)]
10450                } else {
10451                    vec![]
10452                }
10453            }
10454        }
10455
10456        struct ResizeSubscription;
10457
10458        impl Subscription<ResizeSubMsg> for ResizeSubscription {
10459            fn id(&self) -> SubId {
10460                1
10461            }
10462
10463            fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
10464        }
10465
10466        let mut program = headless_program_with_config(
10467            ResizeSubModel { subscribed: false },
10468            ProgramConfig::default(),
10469        );
10470
10471        assert_eq!(program.subscriptions.active_count(), 0);
10472        program
10473            .apply_resize(120, 40, Duration::ZERO, false)
10474            .expect("resize");
10475
10476        assert!(program.model().subscribed);
10477        assert_eq!(program.subscriptions.active_count(), 1);
10478    }
10479
10480    #[test]
10481    fn headless_execute_cmd_log_writes_output() {
10482        let mut program =
10483            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10484        program.execute_cmd(Cmd::log("hello world")).expect("log");
10485
10486        let bytes = program.writer.into_inner().expect("writer output");
10487        let output = String::from_utf8_lossy(&bytes);
10488        assert!(output.contains("hello world"));
10489    }
10490
10491    #[test]
10492    fn headless_process_task_results_updates_model() {
10493        struct TaskModel {
10494            updates: usize,
10495        }
10496
10497        #[derive(Debug)]
10498        enum TaskMsg {
10499            Done,
10500        }
10501
10502        impl From<Event> for TaskMsg {
10503            fn from(_: Event) -> Self {
10504                TaskMsg::Done
10505            }
10506        }
10507
10508        impl Model for TaskModel {
10509            type Message = TaskMsg;
10510
10511            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10512                self.updates += 1;
10513                Cmd::none()
10514            }
10515
10516            fn view(&self, _frame: &mut Frame) {}
10517        }
10518
10519        let mut program =
10520            headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
10521        program.dirty = false;
10522        program.task_sender.send(TaskMsg::Done).unwrap();
10523
10524        program
10525            .process_task_results()
10526            .expect("process task results");
10527        assert_eq!(program.model().updates, 1);
10528        assert!(program.dirty);
10529    }
10530
10531    #[test]
10532    fn run_invokes_on_shutdown_after_quit() {
10533        use std::sync::{
10534            Arc,
10535            atomic::{AtomicUsize, Ordering},
10536        };
10537
10538        struct ShutdownModel {
10539            shutdowns: Arc<AtomicUsize>,
10540        }
10541
10542        #[derive(Debug, Clone, Copy)]
10543        enum ShutdownMsg {
10544            Quit,
10545            ShutdownRan,
10546        }
10547
10548        impl From<Event> for ShutdownMsg {
10549            fn from(_: Event) -> Self {
10550                ShutdownMsg::Quit
10551            }
10552        }
10553
10554        impl Model for ShutdownModel {
10555            type Message = ShutdownMsg;
10556
10557            fn init(&mut self) -> Cmd<Self::Message> {
10558                Cmd::quit()
10559            }
10560
10561            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10562                match msg {
10563                    ShutdownMsg::Quit => Cmd::quit(),
10564                    ShutdownMsg::ShutdownRan => {
10565                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
10566                        Cmd::none()
10567                    }
10568                }
10569            }
10570
10571            fn view(&self, _frame: &mut Frame) {}
10572
10573            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10574                Cmd::msg(ShutdownMsg::ShutdownRan)
10575            }
10576        }
10577
10578        let shutdowns = Arc::new(AtomicUsize::new(0));
10579        let mut program = headless_program_with_config(
10580            ShutdownModel {
10581                shutdowns: Arc::clone(&shutdowns),
10582            },
10583            ProgramConfig::default(),
10584        );
10585
10586        program.run().expect("program run");
10587
10588        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10589    }
10590
10591    #[test]
10592    fn run_processes_shutdown_task_results_before_exit() {
10593        use std::sync::{
10594            Arc,
10595            atomic::{AtomicUsize, Ordering},
10596        };
10597
10598        struct ShutdownTaskModel {
10599            shutdowns: Arc<AtomicUsize>,
10600        }
10601
10602        #[derive(Debug, Clone, Copy)]
10603        enum ShutdownTaskMsg {
10604            Quit,
10605            ShutdownRan,
10606        }
10607
10608        impl From<Event> for ShutdownTaskMsg {
10609            fn from(_: Event) -> Self {
10610                ShutdownTaskMsg::Quit
10611            }
10612        }
10613
10614        impl Model for ShutdownTaskModel {
10615            type Message = ShutdownTaskMsg;
10616
10617            fn init(&mut self) -> Cmd<Self::Message> {
10618                Cmd::quit()
10619            }
10620
10621            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10622                match msg {
10623                    ShutdownTaskMsg::Quit => Cmd::quit(),
10624                    ShutdownTaskMsg::ShutdownRan => {
10625                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
10626                        Cmd::none()
10627                    }
10628                }
10629            }
10630
10631            fn view(&self, _frame: &mut Frame) {}
10632
10633            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10634                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
10635            }
10636        }
10637
10638        let shutdowns = Arc::new(AtomicUsize::new(0));
10639        let mut program = headless_program_with_config(
10640            ShutdownTaskModel {
10641                shutdowns: Arc::clone(&shutdowns),
10642            },
10643            ProgramConfig::default(),
10644        );
10645
10646        program.run().expect("program run");
10647
10648        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10649    }
10650
10651    #[test]
10652    fn run_processes_shutdown_task_results_with_effect_queue_backend() {
10653        use std::sync::{
10654            Arc,
10655            atomic::{AtomicUsize, Ordering},
10656        };
10657
10658        struct ShutdownTaskModel {
10659            shutdowns: Arc<AtomicUsize>,
10660        }
10661
10662        #[derive(Debug, Clone, Copy)]
10663        enum ShutdownTaskMsg {
10664            Quit,
10665            ShutdownRan,
10666        }
10667
10668        impl From<Event> for ShutdownTaskMsg {
10669            fn from(_: Event) -> Self {
10670                ShutdownTaskMsg::Quit
10671            }
10672        }
10673
10674        impl Model for ShutdownTaskModel {
10675            type Message = ShutdownTaskMsg;
10676
10677            fn init(&mut self) -> Cmd<Self::Message> {
10678                Cmd::quit()
10679            }
10680
10681            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10682                match msg {
10683                    ShutdownTaskMsg::Quit => Cmd::quit(),
10684                    ShutdownTaskMsg::ShutdownRan => {
10685                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
10686                        Cmd::none()
10687                    }
10688                }
10689            }
10690
10691            fn view(&self, _frame: &mut Frame) {}
10692
10693            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10694                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
10695            }
10696        }
10697
10698        let shutdowns = Arc::new(AtomicUsize::new(0));
10699        let mut program = headless_program_with_config(
10700            ShutdownTaskModel {
10701                shutdowns: Arc::clone(&shutdowns),
10702            },
10703            ProgramConfig::default().with_effect_queue(
10704                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
10705            ),
10706        );
10707
10708        program.run().expect("program run");
10709
10710        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10711    }
10712
10713    #[test]
10714    fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
10715        use std::sync::{
10716            Arc,
10717            atomic::{AtomicUsize, Ordering},
10718        };
10719
10720        struct ShutdownTaskModel {
10721            shutdowns: Arc<AtomicUsize>,
10722            follow_up_runs: Arc<AtomicUsize>,
10723        }
10724
10725        #[derive(Debug, Clone, Copy)]
10726        enum ShutdownTaskMsg {
10727            Quit,
10728            ShutdownRan,
10729            FollowUp,
10730        }
10731
10732        impl From<Event> for ShutdownTaskMsg {
10733            fn from(_: Event) -> Self {
10734                ShutdownTaskMsg::Quit
10735            }
10736        }
10737
10738        impl Model for ShutdownTaskModel {
10739            type Message = ShutdownTaskMsg;
10740
10741            fn init(&mut self) -> Cmd<Self::Message> {
10742                Cmd::quit()
10743            }
10744
10745            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10746                match msg {
10747                    ShutdownTaskMsg::Quit => Cmd::quit(),
10748                    ShutdownTaskMsg::ShutdownRan => {
10749                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
10750                        let follow_up_runs = Arc::clone(&self.follow_up_runs);
10751                        Cmd::task(move || {
10752                            follow_up_runs.fetch_add(1, Ordering::SeqCst);
10753                            ShutdownTaskMsg::FollowUp
10754                        })
10755                    }
10756                    ShutdownTaskMsg::FollowUp => {
10757                        self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
10758                        Cmd::none()
10759                    }
10760                }
10761            }
10762
10763            fn view(&self, _frame: &mut Frame) {}
10764
10765            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10766                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
10767            }
10768        }
10769
10770        let shutdowns = Arc::new(AtomicUsize::new(0));
10771        let follow_up_runs = Arc::new(AtomicUsize::new(0));
10772        let mut program = headless_program_with_config(
10773            ShutdownTaskModel {
10774                shutdowns: Arc::clone(&shutdowns),
10775                follow_up_runs: Arc::clone(&follow_up_runs),
10776            },
10777            ProgramConfig::default(),
10778        );
10779
10780        program.run().expect("program run");
10781
10782        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10783        assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
10784    }
10785
10786    #[test]
10787    fn run_quit_from_init_skips_initial_render_and_subscription_start() {
10788        use crate::subscription::{StopSignal, SubId, Subscription};
10789
10790        struct InitQuitModel {
10791            render_calls: Arc<AtomicUsize>,
10792            subscription_starts: Arc<AtomicUsize>,
10793        }
10794
10795        #[derive(Debug, Clone, Copy)]
10796        enum InitQuitMsg {
10797            Noop,
10798        }
10799
10800        impl From<Event> for InitQuitMsg {
10801            fn from(_: Event) -> Self {
10802                Self::Noop
10803            }
10804        }
10805
10806        impl Model for InitQuitModel {
10807            type Message = InitQuitMsg;
10808
10809            fn init(&mut self) -> Cmd<Self::Message> {
10810                Cmd::quit()
10811            }
10812
10813            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
10814                Cmd::none()
10815            }
10816
10817            fn view(&self, _frame: &mut Frame) {
10818                self.render_calls.fetch_add(1, Ordering::SeqCst);
10819            }
10820
10821            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10822                vec![Box::new(InitQuitSubscription {
10823                    starts: Arc::clone(&self.subscription_starts),
10824                })]
10825            }
10826        }
10827
10828        struct InitQuitSubscription {
10829            starts: Arc<AtomicUsize>,
10830        }
10831
10832        impl Subscription<InitQuitMsg> for InitQuitSubscription {
10833            fn id(&self) -> SubId {
10834                1
10835            }
10836
10837            fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
10838                self.starts.fetch_add(1, Ordering::SeqCst);
10839                let _ = stop.wait_timeout(Duration::from_millis(10));
10840            }
10841        }
10842
10843        let render_calls = Arc::new(AtomicUsize::new(0));
10844        let subscription_starts = Arc::new(AtomicUsize::new(0));
10845        let mut program = headless_program_with_config(
10846            InitQuitModel {
10847                render_calls: Arc::clone(&render_calls),
10848                subscription_starts: Arc::clone(&subscription_starts),
10849            },
10850            ProgramConfig::default(),
10851        );
10852
10853        program.run().expect("program run");
10854
10855        assert_eq!(render_calls.load(Ordering::SeqCst), 0);
10856        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
10857    }
10858
10859    #[test]
10860    fn run_invokes_on_shutdown_before_returning_signal_error() {
10861        use std::sync::{
10862            Arc,
10863            atomic::{AtomicUsize, Ordering},
10864        };
10865
10866        struct ShutdownModel {
10867            shutdowns: Arc<AtomicUsize>,
10868        }
10869
10870        #[derive(Debug, Clone, Copy)]
10871        enum ShutdownMsg {
10872            Noop,
10873            ShutdownRan,
10874        }
10875
10876        impl From<Event> for ShutdownMsg {
10877            fn from(_: Event) -> Self {
10878                ShutdownMsg::Noop
10879            }
10880        }
10881
10882        impl Model for ShutdownModel {
10883            type Message = ShutdownMsg;
10884
10885            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10886                match msg {
10887                    ShutdownMsg::Noop => Cmd::none(),
10888                    ShutdownMsg::ShutdownRan => {
10889                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
10890                        Cmd::none()
10891                    }
10892                }
10893            }
10894
10895            fn view(&self, _frame: &mut Frame) {}
10896
10897            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10898                Cmd::msg(ShutdownMsg::ShutdownRan)
10899            }
10900        }
10901
10902        let shutdowns = Arc::new(AtomicUsize::new(0));
10903        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
10904            let mut program = headless_signal_program_with_config(
10905                ShutdownModel {
10906                    shutdowns: Arc::clone(&shutdowns),
10907                },
10908                ProgramConfig::default().with_signal_interception(true),
10909            );
10910
10911            ftui_core::shutdown_signal::record_pending_termination_signal(2);
10912            let err = program.run().expect_err("signal should stop runtime");
10913
10914            assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10915            assert_eq!(signal_termination_from_error(&err), Some(2));
10916            assert_eq!(check_termination_signal(), None);
10917        });
10918    }
10919
10920    #[test]
10921    fn run_pending_signal_skips_initial_render_and_subscription_start() {
10922        use crate::subscription::{StopSignal, SubId, Subscription};
10923
10924        struct SignalStopModel {
10925            render_calls: Arc<AtomicUsize>,
10926            subscription_starts: Arc<AtomicUsize>,
10927        }
10928
10929        #[derive(Debug, Clone, Copy)]
10930        enum SignalStopMsg {
10931            Noop,
10932        }
10933
10934        impl From<Event> for SignalStopMsg {
10935            fn from(_: Event) -> Self {
10936                Self::Noop
10937            }
10938        }
10939
10940        impl Model for SignalStopModel {
10941            type Message = SignalStopMsg;
10942
10943            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
10944                Cmd::none()
10945            }
10946
10947            fn view(&self, _frame: &mut Frame) {
10948                self.render_calls.fetch_add(1, Ordering::SeqCst);
10949            }
10950
10951            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10952                vec![Box::new(SignalStopSubscription {
10953                    starts: Arc::clone(&self.subscription_starts),
10954                })]
10955            }
10956        }
10957
10958        struct SignalStopSubscription {
10959            starts: Arc<AtomicUsize>,
10960        }
10961
10962        impl Subscription<SignalStopMsg> for SignalStopSubscription {
10963            fn id(&self) -> SubId {
10964                11
10965            }
10966
10967            fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
10968                self.starts.fetch_add(1, Ordering::SeqCst);
10969                let _ = stop.wait_timeout(Duration::from_millis(10));
10970            }
10971        }
10972
10973        let render_calls = Arc::new(AtomicUsize::new(0));
10974        let subscription_starts = Arc::new(AtomicUsize::new(0));
10975        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
10976            let mut program = headless_signal_program_with_config(
10977                SignalStopModel {
10978                    render_calls: Arc::clone(&render_calls),
10979                    subscription_starts: Arc::clone(&subscription_starts),
10980                },
10981                ProgramConfig::default().with_signal_interception(true),
10982            );
10983
10984            ftui_core::shutdown_signal::record_pending_termination_signal(15);
10985            let err = program.run().expect_err("signal should stop runtime");
10986
10987            assert_eq!(signal_termination_from_error(&err), Some(15));
10988            assert_eq!(render_calls.load(Ordering::SeqCst), 0);
10989            assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
10990            assert_eq!(check_termination_signal(), None);
10991        });
10992    }
10993
10994    #[test]
10995    fn headless_should_tick_and_timeout_behaviors() {
10996        let mut program =
10997            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10998        program.tick_rate = Some(Duration::from_millis(5));
10999        program.last_tick = Instant::now() - Duration::from_millis(10);
11000
11001        assert!(program.should_tick());
11002        assert!(!program.should_tick());
11003
11004        let timeout = program.effective_timeout();
11005        assert!(timeout <= Duration::from_millis(5));
11006
11007        program.tick_rate = None;
11008        program.poll_timeout = Duration::from_millis(33);
11009        assert_eq!(program.effective_timeout(), Duration::from_millis(33));
11010    }
11011
11012    #[test]
11013    fn headless_effective_timeout_respects_resize_coalescer() {
11014        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11015        config.resize_coalescer.steady_delay_ms = 0;
11016        config.resize_coalescer.burst_delay_ms = 0;
11017
11018        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11019        program.tick_rate = Some(Duration::from_millis(50));
11020
11021        program.resize_coalescer.handle_resize(120, 40);
11022        assert!(program.resize_coalescer.has_pending());
11023
11024        let timeout = program.effective_timeout();
11025        assert_eq!(timeout, Duration::ZERO);
11026    }
11027
11028    #[test]
11029    fn headless_ui_height_remeasure_clears_auto_height() {
11030        let mut config = ProgramConfig::inline_auto(2, 6);
11031        config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
11032
11033        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11034        program.dirty = false;
11035        program.writer.set_auto_ui_height(5);
11036
11037        assert_eq!(program.writer.auto_ui_height(), Some(5));
11038        program.request_ui_height_remeasure();
11039
11040        assert_eq!(program.writer.auto_ui_height(), None);
11041        assert!(program.dirty);
11042    }
11043
11044    #[test]
11045    fn headless_recording_lifecycle_and_locale_change() {
11046        let mut program =
11047            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11048        program.dirty = false;
11049
11050        program.start_recording("demo");
11051        assert!(program.is_recording());
11052        let recorded = program.stop_recording();
11053        assert!(recorded.is_some());
11054        assert!(!program.is_recording());
11055
11056        let prev_dirty = program.dirty;
11057        program.locale_context.set_locale("fr");
11058        program.check_locale_change();
11059        assert!(program.dirty || prev_dirty);
11060    }
11061
11062    #[test]
11063    fn headless_render_frame_marks_clean_and_sets_diff() {
11064        struct RenderModel;
11065
11066        #[derive(Debug)]
11067        enum RenderMsg {
11068            Noop,
11069        }
11070
11071        impl From<Event> for RenderMsg {
11072            fn from(_: Event) -> Self {
11073                RenderMsg::Noop
11074            }
11075        }
11076
11077        impl Model for RenderModel {
11078            type Message = RenderMsg;
11079
11080            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11081                Cmd::none()
11082            }
11083
11084            fn view(&self, frame: &mut Frame) {
11085                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
11086            }
11087        }
11088
11089        let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
11090        program.render_frame().expect("render frame");
11091
11092        assert!(!program.dirty);
11093        assert!(program.writer.last_diff_strategy().is_some());
11094        assert_eq!(program.frame_idx, 1);
11095    }
11096
11097    #[test]
11098    fn headless_render_frame_skips_when_budget_exhausted() {
11099        let config = ProgramConfig {
11100            budget: FrameBudgetConfig::with_total(Duration::ZERO),
11101            ..Default::default()
11102        };
11103
11104        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11105        program.dirty = true;
11106        program.render_frame().expect("render frame");
11107
11108        // Dirty state is preserved when frame is skipped — the UI update
11109        // was never presented and must be retried.
11110        assert!(program.dirty);
11111        assert_eq!(program.frame_idx, 1);
11112    }
11113
11114    #[test]
11115    fn headless_render_frame_emits_budget_evidence_with_controller() {
11116        use ftui_render::budget::BudgetControllerConfig;
11117
11118        struct RenderModel;
11119
11120        #[derive(Debug)]
11121        enum RenderMsg {
11122            Noop,
11123        }
11124
11125        impl From<Event> for RenderMsg {
11126            fn from(_: Event) -> Self {
11127                RenderMsg::Noop
11128            }
11129        }
11130
11131        impl Model for RenderModel {
11132            type Message = RenderMsg;
11133
11134            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11135                Cmd::none()
11136            }
11137
11138            fn view(&self, frame: &mut Frame) {
11139                frame.buffer.set_raw(0, 0, Cell::from_char('E'));
11140            }
11141        }
11142
11143        let config =
11144            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
11145        let mut program = headless_program_with_config(RenderModel, config);
11146        program.budget = program
11147            .budget
11148            .with_controller(BudgetControllerConfig::default());
11149
11150        program.render_frame().expect("render frame");
11151        assert!(program.budget.telemetry().is_some());
11152        assert_eq!(program.frame_idx, 1);
11153    }
11154
11155    #[test]
11156    fn headless_handle_event_updates_model() {
11157        struct EventModel {
11158            events: usize,
11159            last_resize: Option<(u16, u16)>,
11160        }
11161
11162        #[derive(Debug)]
11163        enum EventMsg {
11164            Resize(u16, u16),
11165            Other,
11166        }
11167
11168        impl From<Event> for EventMsg {
11169            fn from(event: Event) -> Self {
11170                match event {
11171                    Event::Resize { width, height } => EventMsg::Resize(width, height),
11172                    _ => EventMsg::Other,
11173                }
11174            }
11175        }
11176
11177        impl Model for EventModel {
11178            type Message = EventMsg;
11179
11180            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11181                self.events += 1;
11182                if let EventMsg::Resize(w, h) = msg {
11183                    self.last_resize = Some((w, h));
11184                }
11185                Cmd::none()
11186            }
11187
11188            fn view(&self, _frame: &mut Frame) {}
11189        }
11190
11191        let mut program = headless_program_with_config(
11192            EventModel {
11193                events: 0,
11194                last_resize: None,
11195            },
11196            ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
11197        );
11198
11199        program
11200            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
11201                ftui_core::event::KeyCode::Char('x'),
11202            )))
11203            .expect("handle key");
11204        assert_eq!(program.model().events, 1);
11205
11206        program
11207            .handle_event(Event::Resize {
11208                width: 10,
11209                height: 5,
11210            })
11211            .expect("handle resize");
11212        assert_eq!(program.model().events, 2);
11213        assert_eq!(program.model().last_resize, Some((10, 5)));
11214        assert_eq!(program.width, 10);
11215        assert_eq!(program.height, 5);
11216    }
11217
11218    #[test]
11219    fn headless_handle_event_quit_skips_subscription_reconcile() {
11220        use crate::subscription::{StopSignal, SubId, Subscription};
11221
11222        struct QuitSubModel {
11223            quitting: bool,
11224            subscription_starts: Arc<AtomicUsize>,
11225        }
11226
11227        #[derive(Debug)]
11228        enum QuitSubMsg {
11229            Quit,
11230            Other,
11231        }
11232
11233        impl From<Event> for QuitSubMsg {
11234            fn from(event: Event) -> Self {
11235                match event {
11236                    Event::Key(_) => Self::Quit,
11237                    _ => Self::Other,
11238                }
11239            }
11240        }
11241
11242        impl Model for QuitSubModel {
11243            type Message = QuitSubMsg;
11244
11245            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11246                match msg {
11247                    QuitSubMsg::Quit => {
11248                        self.quitting = true;
11249                        Cmd::quit()
11250                    }
11251                    QuitSubMsg::Other => Cmd::none(),
11252                }
11253            }
11254
11255            fn view(&self, _frame: &mut Frame) {}
11256
11257            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11258                if self.quitting {
11259                    vec![Box::new(QuitSubSubscription {
11260                        starts: Arc::clone(&self.subscription_starts),
11261                    })]
11262                } else {
11263                    vec![]
11264                }
11265            }
11266        }
11267
11268        struct QuitSubSubscription {
11269            starts: Arc<AtomicUsize>,
11270        }
11271
11272        impl Subscription<QuitSubMsg> for QuitSubSubscription {
11273            fn id(&self) -> SubId {
11274                7
11275            }
11276
11277            fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
11278                self.starts.fetch_add(1, Ordering::SeqCst);
11279                let _ = stop.wait_timeout(Duration::from_millis(10));
11280            }
11281        }
11282
11283        let subscription_starts = Arc::new(AtomicUsize::new(0));
11284        let mut program = headless_program_with_config(
11285            QuitSubModel {
11286                quitting: false,
11287                subscription_starts: Arc::clone(&subscription_starts),
11288            },
11289            ProgramConfig::default(),
11290        );
11291
11292        program
11293            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
11294                ftui_core::event::KeyCode::Char('q'),
11295            )))
11296            .expect("handle event");
11297
11298        assert!(!program.is_running());
11299        assert_eq!(program.subscriptions.active_count(), 0);
11300        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11301    }
11302
11303    #[test]
11304    fn headless_handle_resize_ignored_when_forced_size() {
11305        struct ResizeModel {
11306            resized: bool,
11307        }
11308
11309        #[derive(Debug)]
11310        enum ResizeMsg {
11311            Resize,
11312            Other,
11313        }
11314
11315        impl From<Event> for ResizeMsg {
11316            fn from(event: Event) -> Self {
11317                match event {
11318                    Event::Resize { .. } => ResizeMsg::Resize,
11319                    _ => ResizeMsg::Other,
11320                }
11321            }
11322        }
11323
11324        impl Model for ResizeModel {
11325            type Message = ResizeMsg;
11326
11327            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11328                if matches!(msg, ResizeMsg::Resize) {
11329                    self.resized = true;
11330                }
11331                Cmd::none()
11332            }
11333
11334            fn view(&self, _frame: &mut Frame) {}
11335        }
11336
11337        let config = ProgramConfig::default().with_forced_size(80, 24);
11338        let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
11339
11340        program
11341            .handle_event(Event::Resize {
11342                width: 120,
11343                height: 40,
11344            })
11345            .expect("handle resize");
11346
11347        assert_eq!(program.width, 80);
11348        assert_eq!(program.height, 24);
11349        assert!(!program.model().resized);
11350    }
11351
11352    #[test]
11353    fn headless_execute_cmd_batch_sequence_and_quit() {
11354        struct BatchModel {
11355            count: usize,
11356        }
11357
11358        #[derive(Debug)]
11359        enum BatchMsg {
11360            Inc,
11361        }
11362
11363        impl From<Event> for BatchMsg {
11364            fn from(_: Event) -> Self {
11365                BatchMsg::Inc
11366            }
11367        }
11368
11369        impl Model for BatchModel {
11370            type Message = BatchMsg;
11371
11372            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11373                match msg {
11374                    BatchMsg::Inc => {
11375                        self.count += 1;
11376                        Cmd::none()
11377                    }
11378                }
11379            }
11380
11381            fn view(&self, _frame: &mut Frame) {}
11382        }
11383
11384        let mut program =
11385            headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
11386
11387        program
11388            .execute_cmd(Cmd::Batch(vec![
11389                Cmd::msg(BatchMsg::Inc),
11390                Cmd::Sequence(vec![
11391                    Cmd::msg(BatchMsg::Inc),
11392                    Cmd::quit(),
11393                    Cmd::msg(BatchMsg::Inc),
11394                ]),
11395            ]))
11396            .expect("batch cmd");
11397
11398        assert_eq!(program.model().count, 2);
11399        assert!(!program.running);
11400    }
11401
11402    #[test]
11403    fn headless_process_subscription_messages_updates_model() {
11404        use crate::subscription::{StopSignal, SubId, Subscription};
11405
11406        struct SubModel {
11407            pings: usize,
11408            ready_tx: mpsc::Sender<()>,
11409        }
11410
11411        #[derive(Debug)]
11412        enum SubMsg {
11413            Ping,
11414            Other,
11415        }
11416
11417        impl From<Event> for SubMsg {
11418            fn from(_: Event) -> Self {
11419                SubMsg::Other
11420            }
11421        }
11422
11423        impl Model for SubModel {
11424            type Message = SubMsg;
11425
11426            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11427                if let SubMsg::Ping = msg {
11428                    self.pings += 1;
11429                }
11430                Cmd::none()
11431            }
11432
11433            fn view(&self, _frame: &mut Frame) {}
11434
11435            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11436                vec![Box::new(TestSubscription {
11437                    ready_tx: self.ready_tx.clone(),
11438                })]
11439            }
11440        }
11441
11442        struct TestSubscription {
11443            ready_tx: mpsc::Sender<()>,
11444        }
11445
11446        impl Subscription<SubMsg> for TestSubscription {
11447            fn id(&self) -> SubId {
11448                1
11449            }
11450
11451            fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
11452                let _ = sender.send(SubMsg::Ping);
11453                let _ = self.ready_tx.send(());
11454            }
11455        }
11456
11457        let (ready_tx, ready_rx) = mpsc::channel();
11458        let mut program =
11459            headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
11460
11461        program.reconcile_subscriptions();
11462        ready_rx
11463            .recv_timeout(Duration::from_millis(200))
11464            .expect("subscription started");
11465        program
11466            .process_subscription_messages()
11467            .expect("process subscriptions");
11468
11469        assert_eq!(program.model().pings, 1);
11470    }
11471
11472    #[test]
11473    fn headless_execute_cmd_task_spawns_and_reaps() {
11474        struct TaskModel {
11475            done: bool,
11476        }
11477
11478        #[derive(Debug)]
11479        enum TaskMsg {
11480            Done,
11481        }
11482
11483        impl From<Event> for TaskMsg {
11484            fn from(_: Event) -> Self {
11485                TaskMsg::Done
11486            }
11487        }
11488
11489        impl Model for TaskModel {
11490            type Message = TaskMsg;
11491
11492            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11493                match msg {
11494                    TaskMsg::Done => {
11495                        self.done = true;
11496                        Cmd::none()
11497                    }
11498                }
11499            }
11500
11501            fn view(&self, _frame: &mut Frame) {}
11502        }
11503
11504        let mut program =
11505            headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
11506        program
11507            .execute_cmd(Cmd::task(|| TaskMsg::Done))
11508            .expect("task cmd");
11509
11510        let deadline = Instant::now() + Duration::from_millis(200);
11511        while !program.model().done && Instant::now() <= deadline {
11512            program
11513                .process_task_results()
11514                .expect("process task results");
11515            program.reap_finished_tasks();
11516        }
11517
11518        assert!(program.model().done, "task result did not arrive in time");
11519    }
11520
11521    #[test]
11522    fn headless_default_task_executor_is_spawned_for_structured_lane() {
11523        // Input-lag regression fix (#78): the default Structured lane must use
11524        // per-task `Spawned` execution, NOT the single-worker effect queue.
11525        // Routing every `Cmd::Task` through one serialized `effect_queue_loop`
11526        // worker added per-keystroke head-of-line latency for apps that forward
11527        // PTY output via per-pane polling tasks. Structured cancellation does
11528        // not require the effect queue, so the default backend is `Spawned`
11529        // ("spawned") here, matching v0.2.1 task concurrency.
11530        let program =
11531            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11532        assert_eq!(program.task_executor.kind_name(), "spawned");
11533    }
11534
11535    #[test]
11536    fn headless_structured_lane_task_executor_writes_spawned_backend_evidence() {
11537        // Input-lag regression fix (#78): the default Structured lane emits the
11538        // "spawned" backend in startup evidence (was "queued"), reflecting the
11539        // restored per-task-thread execution.
11540        let evidence_path = temp_evidence_path("task_executor_spawned_backend");
11541        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11542        let config = ProgramConfig::default().with_evidence_sink(sink_config);
11543        let _program = headless_program_with_config(TestModel { value: 0 }, config);
11544
11545        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
11546        assert_eq!(backend_line["backend"], "spawned");
11547    }
11548
11549    #[test]
11550    fn headless_legacy_lane_task_executor_is_spawned() {
11551        let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
11552        let program = headless_program_with_config(TestModel { value: 0 }, config);
11553        assert_eq!(program.task_executor.kind_name(), "spawned");
11554    }
11555
11556    #[test]
11557    fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
11558        let config = ProgramConfig::default().with_effect_queue(
11559            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
11560        );
11561        let program = headless_program_with_config(TestModel { value: 0 }, config);
11562        assert_eq!(program.task_executor.kind_name(), "spawned");
11563    }
11564
11565    #[cfg(feature = "asupersync-executor")]
11566    #[test]
11567    fn headless_asupersync_task_executor_is_selected() {
11568        let config = ProgramConfig::default().with_effect_queue(
11569            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
11570        );
11571        let program = headless_program_with_config(TestModel { value: 0 }, config);
11572        assert_eq!(program.task_executor.kind_name(), "asupersync");
11573    }
11574
11575    #[test]
11576    fn headless_persistence_commands_with_registry() {
11577        use crate::state_persistence::{MemoryStorage, StateRegistry};
11578        use std::sync::Arc;
11579
11580        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11581        let config = ProgramConfig::default().with_registry(registry.clone());
11582        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11583
11584        assert!(program.has_persistence());
11585        assert!(program.state_registry().is_some());
11586
11587        program.execute_cmd(Cmd::save_state()).expect("save");
11588        program.execute_cmd(Cmd::restore_state()).expect("restore");
11589
11590        let saved = program.trigger_save().expect("trigger save");
11591        let loaded = program.trigger_load().expect("trigger load");
11592        assert!(!saved);
11593        assert_eq!(loaded, 0);
11594    }
11595
11596    #[test]
11597    fn headless_process_resize_coalescer_applies_pending_resize() {
11598        struct ResizeModel {
11599            last_size: Option<(u16, u16)>,
11600        }
11601
11602        #[derive(Debug)]
11603        enum ResizeMsg {
11604            Resize(u16, u16),
11605            Other,
11606        }
11607
11608        impl From<Event> for ResizeMsg {
11609            fn from(event: Event) -> Self {
11610                match event {
11611                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11612                    _ => ResizeMsg::Other,
11613                }
11614            }
11615        }
11616
11617        impl Model for ResizeModel {
11618            type Message = ResizeMsg;
11619
11620            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11621                if let ResizeMsg::Resize(w, h) = msg {
11622                    self.last_size = Some((w, h));
11623                }
11624                Cmd::none()
11625            }
11626
11627            fn view(&self, _frame: &mut Frame) {}
11628        }
11629
11630        let evidence_path = temp_evidence_path("fairness_allow");
11631        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11632        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11633        config.resize_coalescer.steady_delay_ms = 0;
11634        config.resize_coalescer.burst_delay_ms = 0;
11635        config.resize_coalescer.hard_deadline_ms = 1_000;
11636        config.evidence_sink = sink_config.clone();
11637
11638        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
11639        let sink = EvidenceSink::from_config(&sink_config)
11640            .expect("evidence sink config")
11641            .expect("evidence sink enabled");
11642        program.evidence_sink = Some(sink);
11643
11644        program.resize_coalescer.handle_resize(120, 40);
11645        assert!(program.resize_coalescer.has_pending());
11646
11647        program
11648            .process_resize_coalescer()
11649            .expect("process resize coalescer");
11650
11651        assert_eq!(program.width, 120);
11652        assert_eq!(program.height, 40);
11653        assert_eq!(program.model().last_size, Some((120, 40)));
11654
11655        let config_line = read_evidence_event(&evidence_path, "fairness_config");
11656        assert_eq!(config_line["event"], "fairness_config");
11657        assert!(config_line["enabled"].is_boolean());
11658        assert!(config_line["input_priority_threshold_ms"].is_number());
11659        assert!(config_line["dominance_threshold"].is_number());
11660        assert!(config_line["fairness_threshold"].is_number());
11661
11662        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
11663        assert_eq!(decision_line["event"], "fairness_decision");
11664        assert_eq!(decision_line["decision"], "allow");
11665        assert_eq!(decision_line["reason"], "none");
11666        assert!(decision_line["pending_input_latency_ms"].is_null());
11667        assert!(decision_line["jain_index"].is_number());
11668        assert!(decision_line["resize_dominance_count"].is_number());
11669        assert!(decision_line["dominance_threshold"].is_number());
11670        assert!(decision_line["fairness_threshold"].is_number());
11671        assert!(decision_line["input_priority_threshold_ms"].is_number());
11672    }
11673
11674    #[test]
11675    fn headless_process_resize_coalescer_yields_to_input() {
11676        struct ResizeModel {
11677            last_size: Option<(u16, u16)>,
11678        }
11679
11680        #[derive(Debug)]
11681        enum ResizeMsg {
11682            Resize(u16, u16),
11683            Other,
11684        }
11685
11686        impl From<Event> for ResizeMsg {
11687            fn from(event: Event) -> Self {
11688                match event {
11689                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11690                    _ => ResizeMsg::Other,
11691                }
11692            }
11693        }
11694
11695        impl Model for ResizeModel {
11696            type Message = ResizeMsg;
11697
11698            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11699                if let ResizeMsg::Resize(w, h) = msg {
11700                    self.last_size = Some((w, h));
11701                }
11702                Cmd::none()
11703            }
11704
11705            fn view(&self, _frame: &mut Frame) {}
11706        }
11707
11708        let evidence_path = temp_evidence_path("fairness_yield");
11709        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11710        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11711        config.resize_coalescer.steady_delay_ms = 0;
11712        config.resize_coalescer.burst_delay_ms = 0;
11713        // Use a large hard deadline so elapsed wall-clock time between coalescer
11714        // construction and `handle_resize` never triggers an immediate apply.
11715        config.resize_coalescer.hard_deadline_ms = 10_000;
11716        config.evidence_sink = sink_config.clone();
11717
11718        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
11719        let sink = EvidenceSink::from_config(&sink_config)
11720            .expect("evidence sink config")
11721            .expect("evidence sink enabled");
11722        program.evidence_sink = Some(sink);
11723
11724        program.fairness_guard = InputFairnessGuard::with_config(
11725            crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
11726        );
11727        program
11728            .fairness_guard
11729            .input_arrived(Instant::now() - Duration::from_millis(1));
11730
11731        program.resize_coalescer.handle_resize(120, 40);
11732        assert!(program.resize_coalescer.has_pending());
11733
11734        program
11735            .process_resize_coalescer()
11736            .expect("process resize coalescer");
11737
11738        assert_eq!(program.width, 80);
11739        assert_eq!(program.height, 24);
11740        assert_eq!(program.model().last_size, None);
11741        assert!(program.resize_coalescer.has_pending());
11742
11743        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
11744        assert_eq!(decision_line["event"], "fairness_decision");
11745        assert_eq!(decision_line["decision"], "yield");
11746        assert_eq!(decision_line["reason"], "input_latency");
11747        assert!(decision_line["pending_input_latency_ms"].is_number());
11748        assert!(decision_line["jain_index"].is_number());
11749        assert!(decision_line["resize_dominance_count"].is_number());
11750        assert!(decision_line["dominance_threshold"].is_number());
11751        assert!(decision_line["fairness_threshold"].is_number());
11752        assert!(decision_line["input_priority_threshold_ms"].is_number());
11753    }
11754
11755    #[test]
11756    fn headless_execute_cmd_task_with_effect_queue() {
11757        struct TaskModel {
11758            done: bool,
11759        }
11760
11761        #[derive(Debug)]
11762        enum TaskMsg {
11763            Done,
11764        }
11765
11766        impl From<Event> for TaskMsg {
11767            fn from(_: Event) -> Self {
11768                TaskMsg::Done
11769            }
11770        }
11771
11772        impl Model for TaskModel {
11773            type Message = TaskMsg;
11774
11775            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11776                match msg {
11777                    TaskMsg::Done => {
11778                        self.done = true;
11779                        Cmd::none()
11780                    }
11781                }
11782            }
11783
11784            fn view(&self, _frame: &mut Frame) {}
11785        }
11786
11787        let effect_queue = EffectQueueConfig {
11788            enabled: true,
11789            backend: TaskExecutorBackend::EffectQueue,
11790            scheduler: SchedulerConfig {
11791                max_queue_size: 0,
11792                ..Default::default()
11793            },
11794            explicit_backend: true,
11795            ..Default::default()
11796        };
11797        let config = ProgramConfig::default().with_effect_queue(effect_queue);
11798        let mut program = headless_program_with_config(TaskModel { done: false }, config);
11799
11800        program
11801            .execute_cmd(Cmd::task(|| TaskMsg::Done))
11802            .expect("task cmd");
11803
11804        let deadline = Instant::now() + Duration::from_millis(200);
11805        while !program.model().done && Instant::now() <= deadline {
11806            program
11807                .process_task_results()
11808                .expect("process task results");
11809        }
11810
11811        assert!(
11812            program.model().done,
11813            "effect queue task result did not arrive in time"
11814        );
11815        assert_eq!(program.task_executor.kind_name(), "queued");
11816    }
11817
11818    #[test]
11819    fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
11820        struct TaskModel {
11821            done: bool,
11822        }
11823
11824        #[derive(Debug)]
11825        enum TaskMsg {
11826            Done,
11827        }
11828
11829        impl From<Event> for TaskMsg {
11830            fn from(_: Event) -> Self {
11831                TaskMsg::Done
11832            }
11833        }
11834
11835        impl Model for TaskModel {
11836            type Message = TaskMsg;
11837
11838            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11839                match msg {
11840                    TaskMsg::Done => {
11841                        self.done = true;
11842                        Cmd::none()
11843                    }
11844                }
11845            }
11846
11847            fn view(&self, _frame: &mut Frame) {}
11848        }
11849
11850        let evidence_path = temp_evidence_path("task_executor_spawned_complete");
11851        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11852        let config = ProgramConfig::default()
11853            .with_lane(RuntimeLane::Legacy)
11854            .with_evidence_sink(sink_config);
11855        let mut program = headless_program_with_config(TaskModel { done: false }, config);
11856
11857        program
11858            .execute_cmd(Cmd::task(|| TaskMsg::Done))
11859            .expect("task cmd");
11860
11861        let deadline = Instant::now() + Duration::from_millis(200);
11862        while !program.model().done && Instant::now() <= deadline {
11863            program
11864                .process_task_results()
11865                .expect("process task results");
11866            program.reap_finished_tasks();
11867        }
11868
11869        assert!(
11870            program.model().done,
11871            "spawned task result did not arrive in time"
11872        );
11873
11874        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
11875        assert_eq!(completion_line["backend"], "spawned");
11876        assert!(completion_line["duration_us"].is_number());
11877    }
11878
11879    #[test]
11880    fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
11881        struct TaskModel {
11882            done: bool,
11883        }
11884
11885        #[derive(Debug)]
11886        enum TaskMsg {
11887            Done,
11888        }
11889
11890        impl From<Event> for TaskMsg {
11891            fn from(_: Event) -> Self {
11892                TaskMsg::Done
11893            }
11894        }
11895
11896        impl Model for TaskModel {
11897            type Message = TaskMsg;
11898
11899            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11900                match msg {
11901                    TaskMsg::Done => {
11902                        self.done = true;
11903                        Cmd::none()
11904                    }
11905                }
11906            }
11907
11908            fn view(&self, _frame: &mut Frame) {}
11909        }
11910
11911        let evidence_path = temp_evidence_path("task_executor_queued_panic");
11912        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11913        let config = ProgramConfig::default()
11914            .with_evidence_sink(sink_config)
11915            .with_effect_queue(
11916                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
11917            );
11918        let mut program = headless_program_with_config(TaskModel { done: false }, config);
11919
11920        program
11921            .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
11922            .expect("panic task cmd");
11923        program
11924            .execute_cmd(Cmd::task(|| TaskMsg::Done))
11925            .expect("follow-up task cmd");
11926
11927        let deadline = Instant::now() + Duration::from_millis(500);
11928        while !program.model().done && Instant::now() <= deadline {
11929            program
11930                .process_task_results()
11931                .expect("process task results");
11932        }
11933
11934        assert!(
11935            program.model().done,
11936            "effect queue should continue after a panicking task"
11937        );
11938
11939        let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
11940        assert_eq!(panic_line["backend"], "queued");
11941        assert_eq!(panic_line["panic_msg"], "queued panic evidence");
11942    }
11943
11944    #[cfg(feature = "asupersync-executor")]
11945    #[test]
11946    fn headless_execute_cmd_task_with_asupersync_backend() {
11947        struct TaskModel {
11948            done: bool,
11949        }
11950
11951        #[derive(Debug)]
11952        enum TaskMsg {
11953            Done,
11954        }
11955
11956        impl From<Event> for TaskMsg {
11957            fn from(_: Event) -> Self {
11958                TaskMsg::Done
11959            }
11960        }
11961
11962        impl Model for TaskModel {
11963            type Message = TaskMsg;
11964
11965            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11966                match msg {
11967                    TaskMsg::Done => {
11968                        self.done = true;
11969                        Cmd::none()
11970                    }
11971                }
11972            }
11973
11974            fn view(&self, _frame: &mut Frame) {}
11975        }
11976
11977        let config = ProgramConfig::default().with_effect_queue(
11978            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
11979        );
11980        let mut program = headless_program_with_config(TaskModel { done: false }, config);
11981
11982        program
11983            .execute_cmd(Cmd::task(|| TaskMsg::Done))
11984            .expect("task cmd");
11985
11986        let deadline = Instant::now() + Duration::from_millis(200);
11987        while !program.model().done && Instant::now() <= deadline {
11988            program
11989                .process_task_results()
11990                .expect("process task results");
11991            program.reap_finished_tasks();
11992        }
11993
11994        assert!(
11995            program.model().done,
11996            "asupersync task result did not arrive in time"
11997        );
11998        assert_eq!(program.task_executor.kind_name(), "asupersync");
11999    }
12000
12001    #[cfg(feature = "asupersync-executor")]
12002    #[test]
12003    fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
12004        struct TaskModel {
12005            done: bool,
12006        }
12007
12008        #[derive(Debug)]
12009        enum TaskMsg {
12010            Done,
12011        }
12012
12013        impl From<Event> for TaskMsg {
12014            fn from(_: Event) -> Self {
12015                TaskMsg::Done
12016            }
12017        }
12018
12019        impl Model for TaskModel {
12020            type Message = TaskMsg;
12021
12022            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12023                match msg {
12024                    TaskMsg::Done => {
12025                        self.done = true;
12026                        Cmd::none()
12027                    }
12028                }
12029            }
12030
12031            fn view(&self, _frame: &mut Frame) {}
12032        }
12033
12034        let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
12035        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12036        let config = ProgramConfig::default()
12037            .with_evidence_sink(sink_config)
12038            .with_effect_queue(
12039                EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12040            );
12041        let mut program = headless_program_with_config(TaskModel { done: false }, config);
12042
12043        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12044        assert_eq!(backend_line["backend"], "asupersync");
12045
12046        program
12047            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12048            .expect("task cmd");
12049
12050        let deadline = Instant::now() + Duration::from_millis(200);
12051        while !program.model().done && Instant::now() <= deadline {
12052            program
12053                .process_task_results()
12054                .expect("process task results");
12055            program.reap_finished_tasks();
12056        }
12057
12058        assert!(
12059            program.model().done,
12060            "asupersync task result did not arrive in time"
12061        );
12062
12063        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
12064        assert_eq!(completion_line["backend"], "asupersync");
12065        assert!(completion_line["duration_us"].is_number());
12066    }
12067
12068    // =========================================================================
12069    // BatchController Tests (bd-4kq0.8.1)
12070    // =========================================================================
12071
12072    #[test]
12073    fn unit_tau_monotone() {
12074        // τ should decrease (or stay constant) as service time decreases,
12075        // since τ = E[S] × headroom.
12076        let mut bc = BatchController::new();
12077
12078        // High service time → high τ
12079        bc.observe_service(Duration::from_millis(20));
12080        bc.observe_service(Duration::from_millis(20));
12081        bc.observe_service(Duration::from_millis(20));
12082        let tau_high = bc.tau_s();
12083
12084        // Low service time → lower τ
12085        for _ in 0..20 {
12086            bc.observe_service(Duration::from_millis(1));
12087        }
12088        let tau_low = bc.tau_s();
12089
12090        assert!(
12091            tau_low <= tau_high,
12092            "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
12093        );
12094    }
12095
12096    #[test]
12097    fn unit_tau_monotone_lambda() {
12098        // As arrival rate λ decreases (longer inter-arrival times),
12099        // τ should not increase (it's based on service time, not λ).
12100        // But ρ should decrease.
12101        let mut bc = BatchController::new();
12102        let base = Instant::now();
12103
12104        // Fast arrivals (λ high)
12105        for i in 0..10 {
12106            bc.observe_arrival(base + Duration::from_millis(i * 10));
12107        }
12108        let rho_fast = bc.rho_est();
12109
12110        // Slow arrivals (λ low)
12111        for i in 10..20 {
12112            bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
12113        }
12114        let rho_slow = bc.rho_est();
12115
12116        assert!(
12117            rho_slow < rho_fast,
12118            "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
12119        );
12120    }
12121
12122    #[test]
12123    fn unit_stability() {
12124        // With reasonable service times, the controller should keep ρ < 1.
12125        let mut bc = BatchController::new();
12126        let base = Instant::now();
12127
12128        // Moderate arrival rate: 30 events/sec
12129        for i in 0..30 {
12130            bc.observe_arrival(base + Duration::from_millis(i * 33));
12131            bc.observe_service(Duration::from_millis(5)); // 5ms render
12132        }
12133
12134        assert!(
12135            bc.is_stable(),
12136            "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
12137            bc.rho_est()
12138        );
12139        assert!(
12140            bc.rho_est() < 1.0,
12141            "utilization should be < 1: ρ={:.4}",
12142            bc.rho_est()
12143        );
12144
12145        // τ must be > E[S] (stability requirement)
12146        assert!(
12147            bc.tau_s() > bc.service_est_s(),
12148            "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
12149            bc.tau_s(),
12150            bc.service_est_s()
12151        );
12152    }
12153
12154    #[test]
12155    fn unit_stability_high_load() {
12156        // Even under high load, τ keeps the system stable.
12157        let mut bc = BatchController::new();
12158        let base = Instant::now();
12159
12160        // 100 events/sec with 8ms render
12161        for i in 0..50 {
12162            bc.observe_arrival(base + Duration::from_millis(i * 10));
12163            bc.observe_service(Duration::from_millis(8));
12164        }
12165
12166        // τ × ρ_eff = E[S]/τ should be < 1
12167        let tau = bc.tau_s();
12168        let rho_eff = bc.service_est_s() / tau;
12169        assert!(
12170            rho_eff < 1.0,
12171            "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
12172            bc.service_est_s()
12173        );
12174    }
12175
12176    #[test]
12177    fn batch_controller_defaults() {
12178        let bc = BatchController::new();
12179        assert!(bc.tau_s() >= bc.tau_min_s);
12180        assert!(bc.tau_s() <= bc.tau_max_s);
12181        assert_eq!(bc.observations(), 0);
12182        assert!(bc.is_stable());
12183    }
12184
12185    #[test]
12186    fn batch_controller_tau_clamped() {
12187        let mut bc = BatchController::new();
12188
12189        // Very fast service → τ clamped to tau_min
12190        for _ in 0..20 {
12191            bc.observe_service(Duration::from_micros(10));
12192        }
12193        assert!(
12194            bc.tau_s() >= bc.tau_min_s,
12195            "τ should be >= tau_min: τ={:.6}, min={:.6}",
12196            bc.tau_s(),
12197            bc.tau_min_s
12198        );
12199
12200        // Very slow service → τ clamped to tau_max
12201        for _ in 0..20 {
12202            bc.observe_service(Duration::from_millis(100));
12203        }
12204        assert!(
12205            bc.tau_s() <= bc.tau_max_s,
12206            "τ should be <= tau_max: τ={:.6}, max={:.6}",
12207            bc.tau_s(),
12208            bc.tau_max_s
12209        );
12210    }
12211
12212    #[test]
12213    fn batch_controller_duration_conversion() {
12214        let bc = BatchController::new();
12215        let tau = bc.tau();
12216        let tau_s = bc.tau_s();
12217        // Duration should match f64 representation
12218        let diff = (tau.as_secs_f64() - tau_s).abs();
12219        assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
12220    }
12221
12222    #[test]
12223    fn batch_controller_lambda_estimation() {
12224        let mut bc = BatchController::new();
12225        let base = Instant::now();
12226
12227        // 50 events/sec (20ms apart)
12228        for i in 0..20 {
12229            bc.observe_arrival(base + Duration::from_millis(i * 20));
12230        }
12231
12232        // λ should converge near 50
12233        let lambda = bc.lambda_est();
12234        assert!(
12235            lambda > 20.0 && lambda < 100.0,
12236            "λ should be near 50: got {lambda:.1}"
12237        );
12238    }
12239
12240    // ─────────────────────────────────────────────────────────────────────────────
12241    // Persistence Config Tests
12242    // ─────────────────────────────────────────────────────────────────────────────
12243
12244    #[test]
12245    fn cmd_save_state() {
12246        let cmd: Cmd<TestMsg> = Cmd::save_state();
12247        assert!(matches!(cmd, Cmd::SaveState));
12248    }
12249
12250    #[test]
12251    fn cmd_restore_state() {
12252        let cmd: Cmd<TestMsg> = Cmd::restore_state();
12253        assert!(matches!(cmd, Cmd::RestoreState));
12254    }
12255
12256    #[test]
12257    fn persistence_config_default() {
12258        let config = PersistenceConfig::default();
12259        assert!(config.registry.is_none());
12260        assert!(config.checkpoint_interval.is_none());
12261        assert!(config.auto_load);
12262        assert!(config.auto_save);
12263    }
12264
12265    #[test]
12266    fn persistence_config_disabled() {
12267        let config = PersistenceConfig::disabled();
12268        assert!(config.registry.is_none());
12269    }
12270
12271    #[test]
12272    fn persistence_config_with_registry() {
12273        use crate::state_persistence::{MemoryStorage, StateRegistry};
12274        use std::sync::Arc;
12275
12276        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12277        let config = PersistenceConfig::with_registry(registry.clone());
12278
12279        assert!(config.registry.is_some());
12280        assert!(config.auto_load);
12281        assert!(config.auto_save);
12282    }
12283
12284    #[test]
12285    fn persistence_config_checkpoint_interval() {
12286        use crate::state_persistence::{MemoryStorage, StateRegistry};
12287        use std::sync::Arc;
12288
12289        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12290        let config = PersistenceConfig::with_registry(registry)
12291            .checkpoint_every(Duration::from_secs(30))
12292            .auto_load(false)
12293            .auto_save(true);
12294
12295        assert!(config.checkpoint_interval.is_some());
12296        assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
12297        assert!(!config.auto_load);
12298        assert!(config.auto_save);
12299    }
12300
12301    #[test]
12302    fn program_config_with_persistence() {
12303        use crate::state_persistence::{MemoryStorage, StateRegistry};
12304        use std::sync::Arc;
12305
12306        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12307        let config = ProgramConfig::default().with_registry(registry);
12308
12309        assert!(config.persistence.registry.is_some());
12310    }
12311
12312    // =========================================================================
12313    // TaskSpec tests (bd-2yjus)
12314    // =========================================================================
12315
12316    #[test]
12317    fn task_spec_default() {
12318        let spec = TaskSpec::default();
12319        assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
12320        assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
12321        assert!(spec.name.is_none());
12322    }
12323
12324    #[test]
12325    fn task_spec_new() {
12326        let spec = TaskSpec::new(5.0, 20.0);
12327        assert_eq!(spec.weight, 5.0);
12328        assert_eq!(spec.estimate_ms, 20.0);
12329        assert!(spec.name.is_none());
12330    }
12331
12332    #[test]
12333    fn task_spec_with_name() {
12334        let spec = TaskSpec::default().with_name("fetch_data");
12335        assert_eq!(spec.name.as_deref(), Some("fetch_data"));
12336    }
12337
12338    #[test]
12339    fn task_spec_debug() {
12340        let spec = TaskSpec::new(2.0, 15.0).with_name("test");
12341        let debug = format!("{spec:?}");
12342        assert!(debug.contains("2.0"));
12343        assert!(debug.contains("15.0"));
12344        assert!(debug.contains("test"));
12345    }
12346
12347    // =========================================================================
12348    // Cmd::count() tests (bd-2yjus)
12349    // =========================================================================
12350
12351    #[test]
12352    fn cmd_count_none() {
12353        let cmd: Cmd<TestMsg> = Cmd::none();
12354        assert_eq!(cmd.count(), 0);
12355    }
12356
12357    #[test]
12358    fn cmd_count_atomic() {
12359        assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
12360        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
12361        assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
12362        assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
12363        assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
12364        assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
12365        assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
12366    }
12367
12368    #[test]
12369    fn cmd_count_batch() {
12370        let cmd: Cmd<TestMsg> =
12371            Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
12372        assert_eq!(cmd.count(), 2); // quit + msg, none counts 0
12373    }
12374
12375    #[test]
12376    fn cmd_count_nested() {
12377        let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
12378            Cmd::msg(TestMsg::Increment),
12379            Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
12380        ]);
12381        assert_eq!(cmd.count(), 3);
12382    }
12383
12384    // =========================================================================
12385    // Cmd::type_name() tests (bd-2yjus)
12386    // =========================================================================
12387
12388    #[test]
12389    fn cmd_type_name_all_variants() {
12390        assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
12391        assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
12392        assert_eq!(
12393            Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
12394            "Batch"
12395        );
12396        assert_eq!(
12397            Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
12398            "Sequence"
12399        );
12400        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
12401        assert_eq!(
12402            Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
12403            "Tick"
12404        );
12405        assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
12406        assert_eq!(
12407            Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
12408            "Task"
12409        );
12410        assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
12411        assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
12412        assert_eq!(
12413            Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
12414            "SetMouseCapture"
12415        );
12416    }
12417
12418    // =========================================================================
12419    // Cmd::batch() / Cmd::sequence() edge-case tests (bd-2yjus)
12420    // =========================================================================
12421
12422    #[test]
12423    fn cmd_batch_empty_returns_none() {
12424        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
12425        assert!(matches!(cmd, Cmd::None));
12426    }
12427
12428    #[test]
12429    fn cmd_batch_single_unwraps() {
12430        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
12431        assert!(matches!(cmd, Cmd::Quit));
12432    }
12433
12434    #[test]
12435    fn cmd_batch_multiple_stays_batch() {
12436        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
12437        assert!(matches!(cmd, Cmd::Batch(_)));
12438    }
12439
12440    #[test]
12441    fn cmd_sequence_empty_returns_none() {
12442        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
12443        assert!(matches!(cmd, Cmd::None));
12444    }
12445
12446    #[test]
12447    fn cmd_sequence_single_unwraps_to_inner() {
12448        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
12449        assert!(matches!(cmd, Cmd::Quit));
12450    }
12451
12452    #[test]
12453    fn cmd_sequence_multiple_stays_sequence() {
12454        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
12455        assert!(matches!(cmd, Cmd::Sequence(_)));
12456    }
12457
12458    // =========================================================================
12459    // Cmd task constructor variants (bd-2yjus)
12460    // =========================================================================
12461
12462    #[test]
12463    fn cmd_task_with_spec() {
12464        let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
12465        let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
12466        match cmd {
12467            Cmd::Task(s, _) => {
12468                assert_eq!(s.weight, 3.0);
12469                assert_eq!(s.estimate_ms, 25.0);
12470                assert_eq!(s.name.as_deref(), Some("my_task"));
12471            }
12472            _ => panic!("expected Task variant"),
12473        }
12474    }
12475
12476    #[test]
12477    fn cmd_task_weighted() {
12478        let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
12479        match cmd {
12480            Cmd::Task(s, _) => {
12481                assert_eq!(s.weight, 2.0);
12482                assert_eq!(s.estimate_ms, 50.0);
12483                assert!(s.name.is_none());
12484            }
12485            _ => panic!("expected Task variant"),
12486        }
12487    }
12488
12489    #[test]
12490    fn cmd_task_named() {
12491        let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
12492        match cmd {
12493            Cmd::Task(s, _) => {
12494                assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
12495                assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
12496                assert_eq!(s.name.as_deref(), Some("background_fetch"));
12497            }
12498            _ => panic!("expected Task variant"),
12499        }
12500    }
12501
12502    // =========================================================================
12503    // Cmd Debug formatting (bd-2yjus)
12504    // =========================================================================
12505
12506    #[test]
12507    fn cmd_debug_all_variant_strings() {
12508        assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
12509        assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
12510        assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
12511        assert!(
12512            format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
12513        );
12514        assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
12515        assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
12516        assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
12517        assert_eq!(
12518            format!("{:?}", Cmd::<TestMsg>::restore_state()),
12519            "RestoreState"
12520        );
12521        assert_eq!(
12522            format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
12523            "SetMouseCapture(true)"
12524        );
12525    }
12526
12527    // =========================================================================
12528    // Cmd::set_mouse_capture headless execution (bd-2yjus)
12529    // =========================================================================
12530
12531    #[test]
12532    fn headless_execute_cmd_set_mouse_capture() {
12533        let mut program =
12534            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12535        assert!(!program.backend_features.mouse_capture);
12536
12537        program
12538            .execute_cmd(Cmd::set_mouse_capture(true))
12539            .expect("set mouse capture true");
12540        assert!(program.backend_features.mouse_capture);
12541
12542        program
12543            .execute_cmd(Cmd::set_mouse_capture(false))
12544            .expect("set mouse capture false");
12545        assert!(!program.backend_features.mouse_capture);
12546    }
12547
12548    // =========================================================================
12549    // ResizeBehavior tests (bd-2yjus)
12550    // =========================================================================
12551
12552    #[test]
12553    fn resize_behavior_uses_coalescer() {
12554        assert!(ResizeBehavior::Throttled.uses_coalescer());
12555        assert!(!ResizeBehavior::Immediate.uses_coalescer());
12556    }
12557
12558    #[test]
12559    fn resize_behavior_eq_and_debug() {
12560        assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
12561        assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
12562        let debug = format!("{:?}", ResizeBehavior::Throttled);
12563        assert_eq!(debug, "Throttled");
12564    }
12565
12566    // =========================================================================
12567    // WidgetRefreshConfig default values (bd-2yjus)
12568    // =========================================================================
12569
12570    #[test]
12571    fn widget_refresh_config_defaults() {
12572        let config = WidgetRefreshConfig::default();
12573        assert!(config.enabled);
12574        assert_eq!(config.staleness_window_ms, 1_000);
12575        assert_eq!(config.starve_ms, 3_000);
12576        assert_eq!(config.max_starved_per_frame, 2);
12577        assert_eq!(config.max_drop_fraction, 1.0);
12578        assert_eq!(config.weight_priority, 1.0);
12579        assert_eq!(config.weight_staleness, 0.5);
12580        assert_eq!(config.weight_focus, 0.75);
12581        assert_eq!(config.weight_interaction, 0.5);
12582        assert_eq!(config.starve_boost, 1.5);
12583        assert_eq!(config.min_cost_us, 1.0);
12584    }
12585
12586    // =========================================================================
12587    // EffectQueueConfig tests (bd-2yjus)
12588    // =========================================================================
12589
12590    #[test]
12591    fn effect_queue_config_default() {
12592        let config = EffectQueueConfig::default();
12593        assert!(!config.enabled);
12594        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
12595        assert!(!config.explicit_backend);
12596        assert!(config.scheduler.smith_enabled);
12597        assert!(!config.scheduler.force_fifo);
12598        assert!(!config.scheduler.preemptive);
12599    }
12600
12601    #[test]
12602    fn effect_queue_config_with_enabled() {
12603        let config = EffectQueueConfig::default().with_enabled(true);
12604        assert!(config.enabled);
12605        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
12606        assert!(config.explicit_backend);
12607    }
12608
12609    #[test]
12610    fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
12611        let config = EffectQueueConfig::default().with_enabled(false);
12612        assert!(!config.enabled);
12613        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
12614        assert!(config.explicit_backend);
12615    }
12616
12617    #[test]
12618    fn effect_queue_config_with_backend() {
12619        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
12620        assert!(config.enabled);
12621        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
12622        assert!(config.explicit_backend);
12623    }
12624
12625    #[cfg(feature = "asupersync-executor")]
12626    #[test]
12627    fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
12628        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
12629        assert!(!config.enabled);
12630        assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
12631    }
12632
12633    #[test]
12634    fn effect_queue_config_with_scheduler() {
12635        let sched = SchedulerConfig {
12636            force_fifo: true,
12637            ..Default::default()
12638        };
12639        let config = EffectQueueConfig::default().with_scheduler(sched);
12640        assert!(config.scheduler.force_fifo);
12641    }
12642
12643    // =========================================================================
12644    // InlineAutoRemeasureConfig defaults (bd-2yjus)
12645    // =========================================================================
12646
12647    #[test]
12648    fn inline_auto_remeasure_config_defaults() {
12649        let config = InlineAutoRemeasureConfig::default();
12650        assert_eq!(config.change_threshold_rows, 1);
12651        assert_eq!(config.voi.prior_alpha, 1.0);
12652        assert_eq!(config.voi.prior_beta, 9.0);
12653        assert_eq!(config.voi.max_interval_ms, 1000);
12654        assert_eq!(config.voi.min_interval_ms, 100);
12655        assert_eq!(config.voi.sample_cost, 0.08);
12656    }
12657
12658    // =========================================================================
12659    // HeadlessEventSource direct tests (bd-2yjus)
12660    // =========================================================================
12661
12662    #[test]
12663    fn headless_event_source_size() {
12664        let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
12665        assert_eq!(source.size().unwrap(), (120, 40));
12666    }
12667
12668    #[test]
12669    fn headless_event_source_poll_always_false() {
12670        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
12671        assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
12672    }
12673
12674    #[test]
12675    fn headless_event_source_read_always_none() {
12676        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
12677        assert!(source.read_event().unwrap().is_none());
12678    }
12679
12680    #[test]
12681    fn headless_event_source_set_features() {
12682        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
12683        let features = BackendFeatures {
12684            mouse_capture: true,
12685            bracketed_paste: true,
12686            focus_events: true,
12687            kitty_keyboard: true,
12688        };
12689        source.set_features(features).unwrap();
12690        assert_eq!(source.features, features);
12691    }
12692
12693    #[test]
12694    fn immediate_drain_budget_adds_backoff_poll_under_burst() {
12695        use ftui_core::event::{KeyCode, KeyEvent};
12696
12697        struct DrainBurstModel {
12698            processed: usize,
12699            quit_after: usize,
12700        }
12701
12702        #[derive(Debug)]
12703        #[allow(dead_code)]
12704        enum DrainBurstMsg {
12705            Event(Event),
12706        }
12707
12708        impl From<Event> for DrainBurstMsg {
12709            fn from(event: Event) -> Self {
12710                DrainBurstMsg::Event(event)
12711            }
12712        }
12713
12714        impl Model for DrainBurstModel {
12715            type Message = DrainBurstMsg;
12716
12717            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12718                match msg {
12719                    DrainBurstMsg::Event(_) => {
12720                        self.processed = self.processed.saturating_add(1);
12721                        if self.processed >= self.quit_after {
12722                            Cmd::quit()
12723                        } else {
12724                            Cmd::none()
12725                        }
12726                    }
12727                }
12728            }
12729
12730            fn view(&self, _frame: &mut Frame) {}
12731        }
12732
12733        struct DrainBurstEventSource {
12734            queue: VecDeque<Event>,
12735            poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
12736            size: (u16, u16),
12737        }
12738
12739        impl BackendEventSource for DrainBurstEventSource {
12740            type Error = io::Error;
12741
12742            fn size(&self) -> Result<(u16, u16), Self::Error> {
12743                Ok(self.size)
12744            }
12745
12746            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
12747                Ok(())
12748            }
12749
12750            fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
12751                self.poll_timeouts.lock().unwrap().push(timeout);
12752                Ok(!self.queue.is_empty())
12753            }
12754
12755            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
12756                Ok(self.queue.pop_front())
12757            }
12758        }
12759
12760        let burst_events = 24usize;
12761        let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
12762        let mut queue = VecDeque::new();
12763        for _ in 0..burst_events {
12764            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
12765        }
12766
12767        let events = DrainBurstEventSource {
12768            queue,
12769            poll_timeouts: poll_timeouts.clone(),
12770            size: (80, 24),
12771        };
12772        let writer = TerminalWriter::new(
12773            Vec::<u8>::new(),
12774            ScreenMode::AltScreen,
12775            UiAnchor::Bottom,
12776            TerminalCapabilities::dumb(),
12777        );
12778        let config = ProgramConfig::default()
12779            .with_forced_size(80, 24)
12780            .with_signal_interception(false)
12781            .with_immediate_drain(ImmediateDrainConfig {
12782                max_zero_timeout_polls_per_burst: 3,
12783                max_burst_duration: Duration::from_secs(1),
12784                backoff_timeout: Duration::from_millis(1),
12785            });
12786
12787        let model = DrainBurstModel {
12788            processed: 0,
12789            quit_after: burst_events,
12790        };
12791        let mut program =
12792            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
12793                .expect("program creation");
12794        program.run().expect("run burst");
12795
12796        assert_eq!(program.model().processed, burst_events);
12797
12798        let stats = program.immediate_drain_stats();
12799        assert_eq!(stats.bursts, 1);
12800        assert!(stats.capped_bursts >= 1);
12801        assert!(stats.backoff_polls >= 1);
12802        assert!(stats.zero_timeout_polls >= 1);
12803        assert!(stats.max_zero_timeout_polls_in_burst <= 3);
12804
12805        let timeouts = poll_timeouts.lock().unwrap();
12806        assert!(timeouts.contains(&Duration::ZERO));
12807        assert!(timeouts.contains(&Duration::from_millis(1)));
12808    }
12809
12810    #[test]
12811    fn immediate_drain_zero_poll_limit_is_clamped() {
12812        use ftui_core::event::{KeyCode, KeyEvent};
12813
12814        struct ClampModel {
12815            processed: usize,
12816            quit_after: usize,
12817        }
12818
12819        #[derive(Debug)]
12820        #[allow(dead_code)]
12821        enum ClampMsg {
12822            Event(Event),
12823        }
12824
12825        impl From<Event> for ClampMsg {
12826            fn from(event: Event) -> Self {
12827                ClampMsg::Event(event)
12828            }
12829        }
12830
12831        impl Model for ClampModel {
12832            type Message = ClampMsg;
12833
12834            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12835                match msg {
12836                    ClampMsg::Event(_) => {
12837                        self.processed = self.processed.saturating_add(1);
12838                        if self.processed >= self.quit_after {
12839                            Cmd::quit()
12840                        } else {
12841                            Cmd::none()
12842                        }
12843                    }
12844                }
12845            }
12846
12847            fn view(&self, _frame: &mut Frame) {}
12848        }
12849
12850        struct ClampSource {
12851            queue: VecDeque<Event>,
12852        }
12853
12854        impl BackendEventSource for ClampSource {
12855            type Error = io::Error;
12856
12857            fn size(&self) -> Result<(u16, u16), Self::Error> {
12858                Ok((80, 24))
12859            }
12860
12861            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
12862                Ok(())
12863            }
12864
12865            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
12866                Ok(!self.queue.is_empty())
12867            }
12868
12869            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
12870                Ok(self.queue.pop_front())
12871            }
12872        }
12873
12874        let burst_events = 8usize;
12875        let mut queue = VecDeque::new();
12876        for _ in 0..burst_events {
12877            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
12878        }
12879        let events = ClampSource { queue };
12880
12881        let writer = TerminalWriter::new(
12882            Vec::<u8>::new(),
12883            ScreenMode::AltScreen,
12884            UiAnchor::Bottom,
12885            TerminalCapabilities::dumb(),
12886        );
12887        let config = ProgramConfig::default()
12888            .with_forced_size(80, 24)
12889            .with_signal_interception(false)
12890            .with_immediate_drain(ImmediateDrainConfig {
12891                max_zero_timeout_polls_per_burst: 0,
12892                max_burst_duration: Duration::from_secs(1),
12893                backoff_timeout: Duration::from_millis(1),
12894            });
12895        let model = ClampModel {
12896            processed: 0,
12897            quit_after: burst_events,
12898        };
12899
12900        let mut program =
12901            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
12902                .expect("program creation");
12903        program.run().expect("run clamp");
12904
12905        let stats = program.immediate_drain_stats();
12906        assert!(stats.max_zero_timeout_polls_in_burst <= 1);
12907    }
12908
12909    #[test]
12910    fn quit_stops_draining_remaining_burst_events() {
12911        use ftui_core::event::{KeyCode, KeyEvent};
12912
12913        struct QuitBurstModel {
12914            processed: usize,
12915            quit_after: usize,
12916        }
12917
12918        #[derive(Debug)]
12919        #[allow(dead_code)]
12920        enum QuitBurstMsg {
12921            Event(Event),
12922        }
12923
12924        impl From<Event> for QuitBurstMsg {
12925            fn from(event: Event) -> Self {
12926                Self::Event(event)
12927            }
12928        }
12929
12930        impl Model for QuitBurstModel {
12931            type Message = QuitBurstMsg;
12932
12933            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12934                match msg {
12935                    QuitBurstMsg::Event(_) => {
12936                        self.processed = self.processed.saturating_add(1);
12937                        if self.processed >= self.quit_after {
12938                            Cmd::quit()
12939                        } else {
12940                            Cmd::none()
12941                        }
12942                    }
12943                }
12944            }
12945
12946            fn view(&self, _frame: &mut Frame) {}
12947        }
12948
12949        struct QuitBurstSource {
12950            queue: VecDeque<Event>,
12951        }
12952
12953        impl BackendEventSource for QuitBurstSource {
12954            type Error = io::Error;
12955
12956            fn size(&self) -> Result<(u16, u16), Self::Error> {
12957                Ok((80, 24))
12958            }
12959
12960            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
12961                Ok(())
12962            }
12963
12964            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
12965                Ok(!self.queue.is_empty())
12966            }
12967
12968            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
12969                Ok(self.queue.pop_front())
12970            }
12971        }
12972
12973        let total_events = 8usize;
12974        let quit_after = 3usize;
12975        let mut queue = VecDeque::new();
12976        for _ in 0..total_events {
12977            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
12978        }
12979
12980        let writer = TerminalWriter::new(
12981            Vec::<u8>::new(),
12982            ScreenMode::AltScreen,
12983            UiAnchor::Bottom,
12984            TerminalCapabilities::dumb(),
12985        );
12986        let config = ProgramConfig::default()
12987            .with_forced_size(80, 24)
12988            .with_signal_interception(false)
12989            .with_immediate_drain(ImmediateDrainConfig {
12990                max_zero_timeout_polls_per_burst: 64,
12991                max_burst_duration: Duration::from_secs(1),
12992                backoff_timeout: Duration::from_millis(1),
12993            });
12994        let model = QuitBurstModel {
12995            processed: 0,
12996            quit_after,
12997        };
12998        let events = QuitBurstSource { queue };
12999
13000        let mut program =
13001            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13002                .expect("program creation");
13003        program.run().expect("run burst quit");
13004
13005        assert_eq!(program.model().processed, quit_after);
13006    }
13007
13008    // =========================================================================
13009    // Program helper methods (bd-2yjus)
13010    // =========================================================================
13011
13012    #[test]
13013    fn headless_program_quit_and_is_running() {
13014        let mut program =
13015            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13016        assert!(program.is_running());
13017
13018        program.quit();
13019        assert!(!program.is_running());
13020    }
13021
13022    #[test]
13023    fn headless_program_model_mut() {
13024        let mut program =
13025            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13026        assert_eq!(program.model().value, 0);
13027
13028        program.model_mut().value = 42;
13029        assert_eq!(program.model().value, 42);
13030    }
13031
13032    #[test]
13033    fn headless_program_request_redraw() {
13034        let mut program =
13035            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13036        program.dirty = false;
13037
13038        program.request_redraw();
13039        assert!(program.dirty);
13040    }
13041
13042    #[test]
13043    fn headless_program_last_widget_signals_initially_empty() {
13044        let program =
13045            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13046        assert!(program.last_widget_signals().is_empty());
13047    }
13048
13049    #[test]
13050    fn headless_program_no_persistence_by_default() {
13051        let program =
13052            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13053        assert!(!program.has_persistence());
13054        assert!(program.state_registry().is_none());
13055    }
13056
13057    // =========================================================================
13058    // classify_event_for_fairness (bd-2yjus)
13059    // =========================================================================
13060
13061    #[test]
13062    fn classify_event_fairness_key_is_input() {
13063        let event = Event::Key(ftui_core::event::KeyEvent::new(
13064            ftui_core::event::KeyCode::Char('a'),
13065        ));
13066        let classification =
13067            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13068        assert_eq!(classification, FairnessEventType::Input);
13069    }
13070
13071    #[test]
13072    fn classify_event_fairness_resize_is_resize() {
13073        let event = Event::Resize {
13074            width: 80,
13075            height: 24,
13076        };
13077        let classification =
13078            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13079        assert_eq!(classification, FairnessEventType::Resize);
13080    }
13081
13082    #[test]
13083    fn classify_event_fairness_tick_is_tick() {
13084        let event = Event::Tick;
13085        let classification =
13086            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13087        assert_eq!(classification, FairnessEventType::Tick);
13088    }
13089
13090    #[test]
13091    fn classify_event_fairness_paste_is_input() {
13092        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
13093        let classification =
13094            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13095        assert_eq!(classification, FairnessEventType::Input);
13096    }
13097
13098    #[test]
13099    fn classify_event_fairness_focus_is_input() {
13100        let event = Event::Focus(true);
13101        let classification =
13102            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13103        assert_eq!(classification, FairnessEventType::Input);
13104    }
13105
13106    // =========================================================================
13107    // ProgramConfig builder methods (bd-2yjus)
13108    // =========================================================================
13109
13110    #[test]
13111    fn program_config_with_diff_config() {
13112        let diff = RuntimeDiffConfig::default();
13113        let config = ProgramConfig::default().with_diff_config(diff.clone());
13114        // Just verify it doesn't panic and the field is set
13115        let _ = format!("{:?}", config);
13116    }
13117
13118    #[test]
13119    fn program_config_with_evidence_sink() {
13120        let config =
13121            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
13122        let _ = format!("{:?}", config);
13123    }
13124
13125    #[test]
13126    fn program_config_with_render_trace() {
13127        let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
13128        let _ = format!("{:?}", config);
13129    }
13130
13131    #[test]
13132    fn program_config_with_locale() {
13133        let config = ProgramConfig::default().with_locale("fr");
13134        let _ = format!("{:?}", config);
13135    }
13136
13137    #[test]
13138    fn program_config_with_locale_context() {
13139        let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
13140        let _ = format!("{:?}", config);
13141    }
13142
13143    #[test]
13144    fn program_config_without_forced_size() {
13145        let config = ProgramConfig::default()
13146            .with_forced_size(80, 24)
13147            .without_forced_size();
13148        assert!(config.forced_size.is_none());
13149    }
13150
13151    #[test]
13152    fn program_config_forced_size_clamps_min() {
13153        let config = ProgramConfig::default().with_forced_size(0, 0);
13154        assert_eq!(config.forced_size, Some((1, 1)));
13155    }
13156
13157    #[test]
13158    fn program_config_with_widget_refresh() {
13159        let wrc = WidgetRefreshConfig {
13160            enabled: false,
13161            ..Default::default()
13162        };
13163        let config = ProgramConfig::default().with_widget_refresh(wrc);
13164        assert!(!config.widget_refresh.enabled);
13165    }
13166
13167    #[test]
13168    fn program_config_with_effect_queue() {
13169        let eqc = EffectQueueConfig::default().with_enabled(true);
13170        let config = ProgramConfig::default().with_effect_queue(eqc);
13171        assert!(config.effect_queue.enabled);
13172        assert_eq!(
13173            config.effect_queue.backend,
13174            TaskExecutorBackend::EffectQueue
13175        );
13176    }
13177
13178    #[test]
13179    fn program_config_with_resize_coalescer_custom() {
13180        let cc = CoalescerConfig {
13181            steady_delay_ms: 42,
13182            ..Default::default()
13183        };
13184        let config = ProgramConfig::default().with_resize_coalescer(cc);
13185        assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
13186    }
13187
13188    #[test]
13189    fn program_config_with_inline_auto_remeasure() {
13190        let config = ProgramConfig::default()
13191            .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
13192        assert!(config.inline_auto_remeasure.is_some());
13193
13194        let config = config.without_inline_auto_remeasure();
13195        assert!(config.inline_auto_remeasure.is_none());
13196    }
13197
13198    #[test]
13199    fn program_config_with_persistence_full() {
13200        let pc = PersistenceConfig::disabled();
13201        let config = ProgramConfig::default().with_persistence(pc);
13202        assert!(config.persistence.registry.is_none());
13203    }
13204
13205    #[test]
13206    fn program_config_with_conformal_config() {
13207        let config = ProgramConfig::default()
13208            .with_conformal_config(ConformalConfig::default())
13209            .without_conformal();
13210        assert!(config.conformal_config.is_none());
13211    }
13212
13213    // =========================================================================
13214    // Rollout config builder methods (bd-2crbt)
13215    // =========================================================================
13216
13217    #[test]
13218    fn program_config_with_lane() {
13219        let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
13220        assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
13221    }
13222
13223    #[test]
13224    fn program_config_default_lane_resolves_to_spawned_backend() {
13225        // Input-lag regression fix (#78): the default Structured lane now
13226        // resolves to per-task `Spawned` execution instead of the single-worker
13227        // `EffectQueue`. Structured cancellation is independent of the task
13228        // executor backend, so the lane no longer serializes `Cmd::Task` through
13229        // one `effect_queue_loop` worker (which caused per-keystroke head-of-line
13230        // latency for PTY-forwarding apps). `enabled` is the legacy convenience
13231        // flag mirroring the backend, so it is now false for the default lane.
13232        let resolved = ProgramConfig::default().resolved_effect_queue_config();
13233        assert!(!resolved.enabled);
13234        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
13235    }
13236
13237    #[test]
13238    fn program_config_legacy_lane_resolves_to_spawned_backend() {
13239        let resolved = ProgramConfig::default()
13240            .with_lane(RuntimeLane::Legacy)
13241            .resolved_effect_queue_config();
13242        assert!(!resolved.enabled);
13243        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
13244    }
13245
13246    #[test]
13247    fn program_config_explicit_spawned_backend_is_preserved() {
13248        let resolved = ProgramConfig::default()
13249            .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
13250            .resolved_effect_queue_config();
13251        assert!(!resolved.enabled);
13252        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
13253    }
13254
13255    #[test]
13256    fn program_config_with_rollout_policy() {
13257        let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
13258        assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
13259    }
13260
13261    #[test]
13262    fn rollout_policy_labels() {
13263        assert_eq!(RolloutPolicy::Off.label(), "off");
13264        assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
13265        assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
13266        assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
13267    }
13268
13269    #[test]
13270    fn rollout_policy_is_shadow() {
13271        assert!(!RolloutPolicy::Off.is_shadow());
13272        assert!(RolloutPolicy::Shadow.is_shadow());
13273        assert!(!RolloutPolicy::Enabled.is_shadow());
13274    }
13275
13276    #[test]
13277    fn rollout_policy_default_is_off() {
13278        assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
13279    }
13280
13281    #[test]
13282    fn runtime_lane_parse_legacy() {
13283        assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
13284    }
13285
13286    #[test]
13287    fn runtime_lane_parse_structured_case_insensitive() {
13288        assert_eq!(
13289            RuntimeLane::parse("Structured"),
13290            Some(RuntimeLane::Structured)
13291        );
13292    }
13293
13294    #[test]
13295    fn runtime_lane_parse_asupersync_uppercase() {
13296        assert_eq!(
13297            RuntimeLane::parse("ASUPERSYNC"),
13298            Some(RuntimeLane::Asupersync)
13299        );
13300    }
13301
13302    #[test]
13303    fn runtime_lane_parse_unrecognized() {
13304        assert_eq!(RuntimeLane::parse("bogus"), None);
13305    }
13306
13307    #[test]
13308    fn rollout_policy_parse_shadow() {
13309        assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
13310    }
13311
13312    #[test]
13313    fn rollout_policy_parse_enabled() {
13314        assert_eq!(
13315            RolloutPolicy::parse("enabled"),
13316            Some(RolloutPolicy::Enabled)
13317        );
13318    }
13319
13320    #[test]
13321    fn rollout_policy_parse_off() {
13322        assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
13323    }
13324
13325    #[test]
13326    fn rollout_policy_parse_unrecognized() {
13327        assert_eq!(RolloutPolicy::parse("bogus"), None);
13328    }
13329
13330    // =========================================================================
13331    // PersistenceConfig Debug (bd-2yjus)
13332    // =========================================================================
13333
13334    #[test]
13335    fn persistence_config_debug() {
13336        let config = PersistenceConfig::default();
13337        let debug = format!("{config:?}");
13338        assert!(debug.contains("PersistenceConfig"));
13339        assert!(debug.contains("auto_load"));
13340        assert!(debug.contains("auto_save"));
13341    }
13342
13343    // =========================================================================
13344    // FrameTimingConfig (bd-2yjus)
13345    // =========================================================================
13346
13347    #[test]
13348    fn frame_timing_config_debug() {
13349        use std::sync::Arc;
13350
13351        struct DummySink;
13352        impl FrameTimingSink for DummySink {
13353            fn record_frame(&self, _timing: &FrameTiming) {}
13354        }
13355
13356        let config = FrameTimingConfig::new(Arc::new(DummySink));
13357        let debug = format!("{config:?}");
13358        assert!(debug.contains("FrameTimingConfig"));
13359    }
13360
13361    #[test]
13362    fn program_config_with_frame_timing() {
13363        use std::sync::Arc;
13364
13365        struct DummySink;
13366        impl FrameTimingSink for DummySink {
13367            fn record_frame(&self, _timing: &FrameTiming) {}
13368        }
13369
13370        let config =
13371            ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
13372        assert!(config.frame_timing.is_some());
13373    }
13374
13375    // =========================================================================
13376    // BudgetDecisionEvidence helper functions (bd-2yjus)
13377    // =========================================================================
13378
13379    #[test]
13380    fn budget_decision_evidence_decision_from_levels() {
13381        use ftui_render::budget::DegradationLevel;
13382        // Degrade: after > before
13383        assert_eq!(
13384            BudgetDecisionEvidence::decision_from_levels(
13385                DegradationLevel::Full,
13386                DegradationLevel::SimpleBorders
13387            ),
13388            BudgetDecision::Degrade
13389        );
13390        // Upgrade: after < before
13391        assert_eq!(
13392            BudgetDecisionEvidence::decision_from_levels(
13393                DegradationLevel::SimpleBorders,
13394                DegradationLevel::Full
13395            ),
13396            BudgetDecision::Upgrade
13397        );
13398        // Hold: same
13399        assert_eq!(
13400            BudgetDecisionEvidence::decision_from_levels(
13401                DegradationLevel::Full,
13402                DegradationLevel::Full
13403            ),
13404            BudgetDecision::Hold
13405        );
13406    }
13407
13408    // =========================================================================
13409    // WidgetRefreshPlan (bd-2yjus)
13410    // =========================================================================
13411
13412    #[test]
13413    fn widget_refresh_plan_clear() {
13414        let mut plan = WidgetRefreshPlan::new();
13415        plan.frame_idx = 5;
13416        plan.budget_us = 100.0;
13417        plan.signal_count = 3;
13418        plan.over_budget = true;
13419        plan.clear();
13420        assert_eq!(plan.frame_idx, 0);
13421        assert_eq!(plan.budget_us, 0.0);
13422        assert_eq!(plan.signal_count, 0);
13423        assert!(!plan.over_budget);
13424    }
13425
13426    #[test]
13427    fn widget_refresh_plan_as_budget_empty_signals() {
13428        let plan = WidgetRefreshPlan::new();
13429        let budget = plan.as_budget();
13430        // With signal_count == 0, should be allow_all (allows any widget)
13431        assert!(budget.allows(0, false));
13432        assert!(budget.allows(999, false));
13433    }
13434
13435    #[test]
13436    fn widget_refresh_plan_to_jsonl_structure() {
13437        let plan = WidgetRefreshPlan::new();
13438        let jsonl = plan.to_jsonl();
13439        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
13440        assert!(jsonl.contains("\"frame_idx\":0"));
13441        assert!(jsonl.contains("\"selected\":[]"));
13442    }
13443
13444    // =========================================================================
13445    // BatchController Default trait (bd-2yjus)
13446    // =========================================================================
13447
13448    #[test]
13449    fn batch_controller_default_trait() {
13450        let bc = BatchController::default();
13451        let bc2 = BatchController::new();
13452        // Should be equivalent
13453        assert_eq!(bc.tau_s(), bc2.tau_s());
13454        assert_eq!(bc.observations(), bc2.observations());
13455    }
13456
13457    #[test]
13458    fn batch_controller_observe_arrival_stale_gap_ignored() {
13459        let mut bc = BatchController::new();
13460        let base = Instant::now();
13461        // First arrival
13462        bc.observe_arrival(base);
13463        // Stale gap > 10s should be ignored
13464        bc.observe_arrival(base + Duration::from_secs(15));
13465        assert_eq!(bc.observations(), 0);
13466    }
13467
13468    #[test]
13469    fn batch_controller_observe_service_out_of_range() {
13470        let mut bc = BatchController::new();
13471        let original_service = bc.service_est_s();
13472        // Out-of-range (>= 10s) should be ignored
13473        bc.observe_service(Duration::from_secs(15));
13474        assert_eq!(bc.service_est_s(), original_service);
13475    }
13476
13477    #[test]
13478    fn batch_controller_lambda_zero_inter_arrival() {
13479        // When ema_inter_arrival_s is effectively 0, lambda should be 0
13480        let bc = BatchController {
13481            ema_inter_arrival_s: 0.0,
13482            ..BatchController::new()
13483        };
13484        assert_eq!(bc.lambda_est(), 0.0);
13485    }
13486
13487    // =========================================================================
13488    // Headless program: Cmd::Log with and without trailing newline (bd-2yjus)
13489    // =========================================================================
13490
13491    #[test]
13492    fn headless_execute_cmd_log_appends_newline_if_missing() {
13493        let mut program =
13494            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13495        program.execute_cmd(Cmd::log("no newline")).expect("log");
13496
13497        let bytes = program.writer.into_inner().expect("writer output");
13498        let output = String::from_utf8_lossy(&bytes);
13499        // The sanitized output should end with a newline
13500        assert!(output.contains("no newline"));
13501    }
13502
13503    #[test]
13504    fn headless_execute_cmd_log_preserves_trailing_newline() {
13505        let mut program =
13506            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13507        program
13508            .execute_cmd(Cmd::log("with newline\n"))
13509            .expect("log");
13510
13511        let bytes = program.writer.into_inner().expect("writer output");
13512        let output = String::from_utf8_lossy(&bytes);
13513        assert!(output.contains("with newline"));
13514    }
13515
13516    // =========================================================================
13517    // Headless program: immediate resize behavior (bd-2yjus)
13518    // =========================================================================
13519
13520    #[test]
13521    fn headless_handle_event_immediate_resize() {
13522        struct ResizeModel {
13523            last_size: Option<(u16, u16)>,
13524        }
13525
13526        #[derive(Debug)]
13527        enum ResizeMsg {
13528            Resize(u16, u16),
13529            Other,
13530        }
13531
13532        impl From<Event> for ResizeMsg {
13533            fn from(event: Event) -> Self {
13534                match event {
13535                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
13536                    _ => ResizeMsg::Other,
13537                }
13538            }
13539        }
13540
13541        impl Model for ResizeModel {
13542            type Message = ResizeMsg;
13543
13544            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13545                if let ResizeMsg::Resize(w, h) = msg {
13546                    self.last_size = Some((w, h));
13547                }
13548                Cmd::none()
13549            }
13550
13551            fn view(&self, _frame: &mut Frame) {}
13552        }
13553
13554        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
13555        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
13556
13557        program
13558            .handle_event(Event::Resize {
13559                width: 120,
13560                height: 40,
13561            })
13562            .expect("handle resize");
13563
13564        assert_eq!(program.width, 120);
13565        assert_eq!(program.height, 40);
13566        assert_eq!(program.model().last_size, Some((120, 40)));
13567    }
13568
13569    // =========================================================================
13570    // Headless program: resize clamps zero dimensions (bd-2yjus)
13571    // =========================================================================
13572
13573    #[test]
13574    fn headless_apply_resize_clamps_zero_to_one() {
13575        struct SimpleModel;
13576
13577        #[derive(Debug)]
13578        enum SimpleMsg {
13579            Noop,
13580        }
13581
13582        impl From<Event> for SimpleMsg {
13583            fn from(_: Event) -> Self {
13584                SimpleMsg::Noop
13585            }
13586        }
13587
13588        impl Model for SimpleModel {
13589            type Message = SimpleMsg;
13590
13591            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
13592                Cmd::none()
13593            }
13594
13595            fn view(&self, _frame: &mut Frame) {}
13596        }
13597
13598        let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
13599        program
13600            .apply_resize(0, 0, Duration::ZERO, false)
13601            .expect("resize");
13602
13603        // Zero dimensions should be clamped to 1
13604        assert_eq!(program.width, 1);
13605        assert_eq!(program.height, 1);
13606    }
13607
13608    // =========================================================================
13609    // PaneTerminalAdapter::force_cancel_all (bd-24v9m)
13610    // =========================================================================
13611
13612    #[test]
13613    fn force_cancel_all_idle_returns_none() {
13614        let mut adapter =
13615            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13616        assert!(adapter.force_cancel_all().is_none());
13617    }
13618
13619    #[test]
13620    fn force_cancel_all_after_pointer_down_returns_diagnostics() {
13621        let mut adapter =
13622            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13623        let target = pane_target(SplitAxis::Horizontal);
13624
13625        let down = Event::Mouse(MouseEvent::new(
13626            MouseEventKind::Down(MouseButton::Left),
13627            5,
13628            5,
13629        ));
13630        let _ = adapter.translate(&down, Some(target));
13631        assert!(adapter.active_pointer_id().is_some());
13632
13633        let diag = adapter
13634            .force_cancel_all()
13635            .expect("should produce diagnostics");
13636        assert!(diag.had_active_pointer);
13637        assert_eq!(diag.active_pointer_id, Some(1));
13638        assert!(diag.machine_transition.is_some());
13639
13640        // Adapter should be fully idle afterwards
13641        assert_eq!(adapter.active_pointer_id(), None);
13642        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
13643    }
13644
13645    #[test]
13646    fn force_cancel_all_during_drag_returns_diagnostics() {
13647        let mut adapter =
13648            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13649        let target = pane_target(SplitAxis::Vertical);
13650
13651        // Down → arm
13652        let down = Event::Mouse(MouseEvent::new(
13653            MouseEventKind::Down(MouseButton::Left),
13654            3,
13655            3,
13656        ));
13657        let _ = adapter.translate(&down, Some(target));
13658
13659        // Drag → transition to Dragging
13660        let drag = Event::Mouse(MouseEvent::new(
13661            MouseEventKind::Drag(MouseButton::Left),
13662            8,
13663            3,
13664        ));
13665        let _ = adapter.translate(&drag, None);
13666
13667        let diag = adapter
13668            .force_cancel_all()
13669            .expect("should produce diagnostics");
13670        assert!(diag.had_active_pointer);
13671        assert!(diag.machine_transition.is_some());
13672        let transition = diag.machine_transition.unwrap();
13673        assert!(matches!(
13674            transition.effect,
13675            PaneDragResizeEffect::Canceled {
13676                reason: PaneCancelReason::Programmatic,
13677                ..
13678            }
13679        ));
13680    }
13681
13682    #[test]
13683    fn force_cancel_all_is_idempotent() {
13684        let mut adapter =
13685            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13686        let target = pane_target(SplitAxis::Horizontal);
13687
13688        let down = Event::Mouse(MouseEvent::new(
13689            MouseEventKind::Down(MouseButton::Left),
13690            5,
13691            5,
13692        ));
13693        let _ = adapter.translate(&down, Some(target));
13694
13695        let first = adapter.force_cancel_all();
13696        assert!(first.is_some());
13697
13698        let second = adapter.force_cancel_all();
13699        assert!(second.is_none());
13700    }
13701
13702    // =========================================================================
13703    // PaneInteractionGuard (bd-24v9m)
13704    // =========================================================================
13705
13706    #[test]
13707    fn pane_interaction_guard_finish_when_idle() {
13708        let mut adapter =
13709            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13710        let guard = PaneInteractionGuard::new(&mut adapter);
13711        let diag = guard.finish();
13712        assert!(diag.is_none());
13713    }
13714
13715    #[test]
13716    fn pane_interaction_guard_finish_returns_diagnostics() {
13717        let mut adapter =
13718            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13719        let target = pane_target(SplitAxis::Horizontal);
13720
13721        // Start a drag interaction through the adapter directly
13722        let down = Event::Mouse(MouseEvent::new(
13723            MouseEventKind::Down(MouseButton::Left),
13724            5,
13725            5,
13726        ));
13727        let _ = adapter.translate(&down, Some(target));
13728
13729        let guard = PaneInteractionGuard::new(&mut adapter);
13730        let diag = guard.finish().expect("should produce diagnostics");
13731        assert!(diag.had_active_pointer);
13732        assert_eq!(diag.active_pointer_id, Some(1));
13733    }
13734
13735    #[test]
13736    fn pane_interaction_guard_drop_cancels_active_interaction() {
13737        let mut adapter =
13738            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13739        let target = pane_target(SplitAxis::Vertical);
13740
13741        let down = Event::Mouse(MouseEvent::new(
13742            MouseEventKind::Down(MouseButton::Left),
13743            7,
13744            7,
13745        ));
13746        let _ = adapter.translate(&down, Some(target));
13747        assert!(adapter.active_pointer_id().is_some());
13748
13749        {
13750            let _guard = PaneInteractionGuard::new(&mut adapter);
13751            // guard drops here without finish()
13752        }
13753
13754        // After guard drop, adapter should be idle
13755        assert_eq!(adapter.active_pointer_id(), None);
13756        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
13757    }
13758
13759    #[test]
13760    fn pane_interaction_guard_adapter_access_works() {
13761        let mut adapter =
13762            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13763        let target = pane_target(SplitAxis::Horizontal);
13764
13765        let mut guard = PaneInteractionGuard::new(&mut adapter);
13766
13767        // Use the adapter through the guard
13768        let down = Event::Mouse(MouseEvent::new(
13769            MouseEventKind::Down(MouseButton::Left),
13770            5,
13771            5,
13772        ));
13773        let dispatch = guard.adapter().translate(&down, Some(target));
13774        assert!(dispatch.primary_event.is_some());
13775
13776        // finish should clean up the interaction started through the guard
13777        let diag = guard.finish().expect("should produce diagnostics");
13778        assert!(diag.had_active_pointer);
13779    }
13780
13781    #[test]
13782    fn pane_interaction_guard_finish_then_drop_is_safe() {
13783        let mut adapter =
13784            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13785        let target = pane_target(SplitAxis::Horizontal);
13786
13787        let down = Event::Mouse(MouseEvent::new(
13788            MouseEventKind::Down(MouseButton::Left),
13789            5,
13790            5,
13791        ));
13792        let _ = adapter.translate(&down, Some(target));
13793
13794        let guard = PaneInteractionGuard::new(&mut adapter);
13795        let _diag = guard.finish();
13796        // guard is consumed by finish(), so drop doesn't double-cancel
13797        // This test proves the API is safe: finish() takes `self` not `&mut self`
13798        assert_eq!(adapter.active_pointer_id(), None);
13799    }
13800
13801    // =========================================================================
13802    // PaneCapabilityMatrix (bd-6u66i)
13803    // =========================================================================
13804
13805    fn caps_modern() -> TerminalCapabilities {
13806        TerminalCapabilities::modern()
13807    }
13808
13809    fn caps_with_mux(
13810        mux: PaneMuxEnvironment,
13811    ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
13812        let mut caps = TerminalCapabilities::modern();
13813        match mux {
13814            PaneMuxEnvironment::Tmux => caps.in_tmux = true,
13815            PaneMuxEnvironment::Screen => caps.in_screen = true,
13816            PaneMuxEnvironment::Zellij => caps.in_zellij = true,
13817            PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
13818            PaneMuxEnvironment::None => {}
13819        }
13820        caps
13821    }
13822
13823    #[test]
13824    fn capability_matrix_bare_terminal_modern() {
13825        let caps = caps_modern();
13826        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13827
13828        assert_eq!(mat.mux, PaneMuxEnvironment::None);
13829        assert!(mat.mouse_sgr);
13830        assert!(mat.mouse_drag_reliable);
13831        assert!(mat.mouse_button_discrimination);
13832        assert!(mat.focus_events);
13833        assert!(mat.unicode_box_drawing);
13834        assert!(mat.true_color);
13835        assert!(!mat.degraded);
13836        assert!(mat.drag_enabled());
13837        assert!(mat.focus_cancel_effective());
13838        assert!(mat.limitations().is_empty());
13839    }
13840
13841    #[test]
13842    fn capability_matrix_tmux() {
13843        let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
13844        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13845
13846        assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
13847        // Focus cancel path is conservatively disabled in all muxes.
13848        assert!(mat.mouse_drag_reliable);
13849        assert!(!mat.focus_events);
13850        assert!(mat.drag_enabled());
13851        assert!(!mat.focus_cancel_effective());
13852        assert!(mat.degraded);
13853    }
13854
13855    #[test]
13856    fn capability_matrix_screen_degrades_drag() {
13857        let caps = caps_with_mux(PaneMuxEnvironment::Screen);
13858        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13859
13860        assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
13861        assert!(!mat.mouse_drag_reliable);
13862        assert!(!mat.focus_events);
13863        assert!(!mat.drag_enabled());
13864        assert!(!mat.focus_cancel_effective());
13865        assert!(mat.degraded);
13866
13867        let lims = mat.limitations();
13868        assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
13869        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
13870    }
13871
13872    #[test]
13873    fn capability_matrix_zellij() {
13874        let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
13875        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13876
13877        assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
13878        assert!(mat.mouse_drag_reliable);
13879        assert!(!mat.focus_events);
13880        assert!(mat.drag_enabled());
13881        assert!(!mat.focus_cancel_effective());
13882        assert!(mat.degraded);
13883    }
13884
13885    #[test]
13886    fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
13887        let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
13888        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13889
13890        assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
13891        assert!(mat.mouse_drag_reliable);
13892        assert!(!mat.focus_events);
13893        assert!(mat.drag_enabled());
13894        assert!(!mat.focus_cancel_effective());
13895        assert!(mat.degraded);
13896    }
13897
13898    #[test]
13899    fn capability_matrix_no_sgr_mouse() {
13900        let mut caps = caps_modern();
13901        caps.mouse_sgr = false;
13902        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13903
13904        assert!(!mat.mouse_sgr);
13905        assert!(!mat.mouse_button_discrimination);
13906        assert!(mat.degraded);
13907
13908        let lims = mat.limitations();
13909        assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
13910        assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
13911    }
13912
13913    #[test]
13914    fn capability_matrix_no_focus_events() {
13915        let mut caps = caps_modern();
13916        caps.focus_events = false;
13917        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13918
13919        assert!(!mat.focus_events);
13920        assert!(!mat.focus_cancel_effective());
13921        assert!(mat.degraded);
13922
13923        let lims = mat.limitations();
13924        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
13925    }
13926
13927    #[test]
13928    fn capability_matrix_dumb_terminal() {
13929        let caps = TerminalCapabilities::dumb();
13930        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13931
13932        assert_eq!(mat.mux, PaneMuxEnvironment::None);
13933        assert!(!mat.mouse_sgr);
13934        assert!(!mat.focus_events);
13935        assert!(!mat.unicode_box_drawing);
13936        assert!(!mat.true_color);
13937        assert!(mat.degraded);
13938        assert!(mat.limitations().len() >= 3);
13939    }
13940
13941    #[test]
13942    fn capability_matrix_limitations_have_fallbacks() {
13943        let caps = TerminalCapabilities::dumb();
13944        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13945
13946        for lim in mat.limitations() {
13947            assert!(!lim.id.is_empty());
13948            assert!(!lim.description.is_empty());
13949            assert!(!lim.fallback.is_empty());
13950        }
13951    }
13952
13953    // ========================================================================
13954    // Screen transition detection tests (A.2 + D.3)
13955    // ========================================================================
13956
13957    /// A multi-screen model that implements ScreenTickDispatch, for testing
13958    /// the `check_screen_transition` logic.
13959    struct MultiScreenModel {
13960        active: String,
13961        screens: Vec<String>,
13962        ticked_screens: Vec<(String, u64)>,
13963    }
13964
13965    #[derive(Debug)]
13966    enum MultiScreenMsg {
13967        #[expect(dead_code)]
13968        Event(Event),
13969    }
13970
13971    impl From<Event> for MultiScreenMsg {
13972        fn from(event: Event) -> Self {
13973            MultiScreenMsg::Event(event)
13974        }
13975    }
13976
13977    impl Model for MultiScreenModel {
13978        type Message = MultiScreenMsg;
13979
13980        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13981            match msg {
13982                MultiScreenMsg::Event(_) => Cmd::none(),
13983            }
13984        }
13985
13986        fn view(&self, _frame: &mut Frame) {}
13987
13988        fn as_screen_tick_dispatch(
13989            &mut self,
13990        ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
13991            Some(self)
13992        }
13993    }
13994
13995    impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
13996        fn screen_ids(&self) -> Vec<String> {
13997            self.screens.clone()
13998        }
13999
14000        fn active_screen_id(&self) -> String {
14001            self.active.clone()
14002        }
14003
14004        fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
14005            self.ticked_screens.push((screen_id.to_owned(), tick_count));
14006        }
14007    }
14008
14009    /// Shared log for recording strategy transitions (inspectable after test).
14010    type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
14011
14012    /// A recording tick strategy that logs `on_screen_transition` calls
14013    /// to a shared log that can be inspected from test assertions.
14014    struct RecordingStrategy {
14015        log: TransitionLog,
14016    }
14017
14018    impl RecordingStrategy {
14019        fn new(log: TransitionLog) -> Self {
14020            Self { log }
14021        }
14022    }
14023
14024    impl crate::tick_strategy::TickStrategy for RecordingStrategy {
14025        fn should_tick(
14026            &mut self,
14027            _screen_id: &str,
14028            _tick_count: u64,
14029            _active_screen: &str,
14030        ) -> crate::tick_strategy::TickDecision {
14031            crate::tick_strategy::TickDecision::Skip
14032        }
14033
14034        fn on_screen_transition(&mut self, from: &str, to: &str) {
14035            self.log
14036                .lock()
14037                .unwrap()
14038                .push((from.to_owned(), to.to_owned()));
14039        }
14040
14041        fn name(&self) -> &str {
14042            "Recording"
14043        }
14044
14045        fn debug_stats(&self) -> Vec<(String, String)> {
14046            vec![("strategy".into(), "Recording".into())]
14047        }
14048    }
14049
14050    /// Helper to create a headless Program with a multi-screen model and
14051    /// a recording tick strategy. Returns the program and a shared log of
14052    /// `on_screen_transition` calls for assertions.
14053    fn headless_multi_screen_program(
14054        active: &str,
14055        screens: &[&str],
14056    ) -> (
14057        Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
14058        TransitionLog,
14059    ) {
14060        let model = MultiScreenModel {
14061            active: active.to_owned(),
14062            screens: screens.iter().map(|s| (*s).to_owned()).collect(),
14063            ticked_screens: Vec::new(),
14064        };
14065        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14066        let writer = TerminalWriter::new(
14067            Vec::<u8>::new(),
14068            ScreenMode::AltScreen,
14069            UiAnchor::Bottom,
14070            TerminalCapabilities::dumb(),
14071        );
14072        let config = ProgramConfig {
14073            forced_size: Some((80, 24)),
14074            tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
14075            ..ProgramConfig::default()
14076        };
14077        let mut prog =
14078            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14079                .expect("headless program creation failed");
14080
14081        // Replace the default strategy with our recording strategy.
14082        let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
14083        prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
14084
14085        (prog, log)
14086    }
14087
14088    #[test]
14089    fn check_screen_transition_first_call_records_active() {
14090        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14091
14092        assert!(prog.last_active_screen_for_strategy.is_none());
14093        prog.check_screen_transition();
14094        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
14095
14096        // First observation: no transition event, no force-tick.
14097        assert!(prog.model.ticked_screens.is_empty());
14098        assert!(log.lock().unwrap().is_empty());
14099    }
14100
14101    #[test]
14102    fn check_screen_transition_no_change_is_noop() {
14103        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14104
14105        // First call: records.
14106        prog.check_screen_transition();
14107
14108        // Second call with same active screen: no-op.
14109        prog.check_screen_transition();
14110        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
14111
14112        // No force-tick, no transition notification.
14113        assert!(prog.model.ticked_screens.is_empty());
14114        assert!(log.lock().unwrap().is_empty());
14115    }
14116
14117    #[test]
14118    fn check_screen_transition_detects_switch_and_force_ticks() {
14119        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14120
14121        prog.check_screen_transition(); // records "A"
14122
14123        // Simulate model switching to screen "B".
14124        prog.model.active = "B".to_owned();
14125        prog.check_screen_transition();
14126
14127        // D.3: force-tick should have been dispatched for "B".
14128        assert_eq!(prog.model.ticked_screens.len(), 1);
14129        assert_eq!(prog.model.ticked_screens[0].0, "B");
14130
14131        // A.2: strategy should have been notified of A → B.
14132        let transitions = log.lock().unwrap();
14133        assert_eq!(transitions.len(), 1);
14134        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
14135
14136        // last_active should now be "B".
14137        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
14138    }
14139
14140    #[test]
14141    fn check_screen_transition_marks_dirty_on_change() {
14142        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14143
14144        prog.check_screen_transition();
14145        prog.dirty = false;
14146
14147        prog.model.active = "B".to_owned();
14148        prog.check_screen_transition();
14149
14150        assert!(prog.dirty);
14151    }
14152
14153    #[test]
14154    fn check_screen_transition_not_dirty_when_unchanged() {
14155        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14156
14157        prog.check_screen_transition();
14158        prog.dirty = false;
14159
14160        prog.check_screen_transition();
14161
14162        assert!(!prog.dirty);
14163    }
14164
14165    #[test]
14166    fn check_screen_transition_noop_without_strategy() {
14167        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14168
14169        // Remove the tick strategy.
14170        prog.tick_strategy = None;
14171
14172        prog.check_screen_transition();
14173        assert!(prog.last_active_screen_for_strategy.is_none());
14174    }
14175
14176    #[test]
14177    fn check_screen_transition_multiple_switches_notifies_strategy() {
14178        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14179
14180        prog.check_screen_transition(); // records "A"
14181
14182        // A → B
14183        prog.model.active = "B".to_owned();
14184        prog.check_screen_transition();
14185        assert_eq!(prog.model.ticked_screens.len(), 1);
14186        assert_eq!(prog.model.ticked_screens[0].0, "B");
14187
14188        // B → C
14189        prog.model.active = "C".to_owned();
14190        prog.check_screen_transition();
14191        assert_eq!(prog.model.ticked_screens.len(), 2);
14192        assert_eq!(prog.model.ticked_screens[1].0, "C");
14193
14194        // C → A
14195        prog.model.active = "A".to_owned();
14196        prog.check_screen_transition();
14197        assert_eq!(prog.model.ticked_screens.len(), 3);
14198        assert_eq!(prog.model.ticked_screens[2].0, "A");
14199
14200        // A.2: strategy should have all three transitions.
14201        let transitions = log.lock().unwrap();
14202        assert_eq!(transitions.len(), 3);
14203        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
14204        assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
14205        assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
14206    }
14207
14208    #[test]
14209    fn check_screen_transition_uses_current_tick_count() {
14210        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14211        prog.tick_count = 42;
14212
14213        prog.check_screen_transition(); // records "A"
14214
14215        prog.model.active = "B".to_owned();
14216        prog.check_screen_transition();
14217
14218        // Force-tick should use the current tick_count.
14219        assert_eq!(prog.model.ticked_screens[0].1, 42);
14220    }
14221
14222    #[test]
14223    fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
14224        use crate::subscription::{StopSignal, SubId, Subscription};
14225
14226        struct TransitionSubModel {
14227            active: String,
14228            screens: Vec<String>,
14229            subscribed: bool,
14230        }
14231
14232        #[derive(Debug)]
14233        #[allow(dead_code)]
14234        enum TransitionSubMsg {
14235            Event(Event),
14236        }
14237
14238        impl From<Event> for TransitionSubMsg {
14239            fn from(event: Event) -> Self {
14240                Self::Event(event)
14241            }
14242        }
14243
14244        impl Model for TransitionSubModel {
14245            type Message = TransitionSubMsg;
14246
14247            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
14248                Cmd::none()
14249            }
14250
14251            fn view(&self, _frame: &mut Frame) {}
14252
14253            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
14254                if self.subscribed {
14255                    vec![Box::new(TransitionSubscription)]
14256                } else {
14257                    vec![]
14258                }
14259            }
14260
14261            fn as_screen_tick_dispatch(
14262                &mut self,
14263            ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
14264                Some(self)
14265            }
14266        }
14267
14268        impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
14269            fn screen_ids(&self) -> Vec<String> {
14270                self.screens.clone()
14271            }
14272
14273            fn active_screen_id(&self) -> String {
14274                self.active.clone()
14275            }
14276
14277            fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
14278                if screen_id == self.active {
14279                    self.subscribed = true;
14280                }
14281            }
14282        }
14283
14284        struct TransitionSubscription;
14285
14286        impl Subscription<TransitionSubMsg> for TransitionSubscription {
14287            fn id(&self) -> SubId {
14288                1
14289            }
14290
14291            fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
14292        }
14293
14294        struct TransitionStrategy;
14295
14296        impl crate::tick_strategy::TickStrategy for TransitionStrategy {
14297            fn should_tick(
14298                &mut self,
14299                _screen_id: &str,
14300                _tick_count: u64,
14301                _active_screen: &str,
14302            ) -> crate::tick_strategy::TickDecision {
14303                crate::tick_strategy::TickDecision::Skip
14304            }
14305
14306            fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
14307
14308            fn name(&self) -> &str {
14309                "TransitionStrategy"
14310            }
14311
14312            fn debug_stats(&self) -> Vec<(String, String)> {
14313                vec![]
14314            }
14315        }
14316
14317        let model = TransitionSubModel {
14318            active: "A".to_owned(),
14319            screens: vec!["A".to_owned(), "B".to_owned()],
14320            subscribed: false,
14321        };
14322        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14323        let writer = TerminalWriter::new(
14324            Vec::<u8>::new(),
14325            ScreenMode::AltScreen,
14326            UiAnchor::Bottom,
14327            TerminalCapabilities::dumb(),
14328        );
14329        let config = ProgramConfig::default().with_forced_size(80, 24);
14330
14331        let mut program =
14332            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14333                .expect("program creation");
14334        program.tick_strategy = Some(Box::new(TransitionStrategy));
14335
14336        program.check_screen_transition();
14337        assert_eq!(program.subscriptions.active_count(), 0);
14338
14339        program.model.active = "B".to_owned();
14340        program.check_screen_transition();
14341
14342        assert!(program.model().subscribed);
14343        assert_eq!(program.subscriptions.active_count(), 1);
14344    }
14345
14346    #[test]
14347    fn tick_strategy_stats_returns_empty_without_strategy() {
14348        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14349        prog.tick_strategy = None;
14350        assert!(prog.tick_strategy_stats().is_empty());
14351    }
14352
14353    #[test]
14354    fn tick_strategy_stats_returns_strategy_fields() {
14355        let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14356        let stats = prog.tick_strategy_stats();
14357        // RecordingStrategy returns [("strategy", "Recording")]
14358        assert!(
14359            !stats.is_empty(),
14360            "stats should not be empty when strategy is configured"
14361        );
14362    }
14363}