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::{
98    AlertSeverity, FrameGuardrails, GuardrailKind, GuardrailsConfig,
99};
100use ftui_render::sanitize::sanitize;
101use std::any::Any;
102use std::collections::HashMap;
103use std::io::{self, Stdout, Write};
104use std::panic::{self, AssertUnwindSafe};
105use std::sync::Arc;
106
107/// Check for pending termination signal. Returns `None` when crossterm is not
108/// enabled (headless / wasm builds don't install signal handlers).
109#[inline]
110fn check_termination_signal() -> Option<i32> {
111    ftui_core::shutdown_signal::pending_termination_signal()
112}
113
114/// Clear the pending termination signal.
115#[inline]
116fn clear_termination_signal() {
117    ftui_core::shutdown_signal::clear_pending_termination_signal();
118}
119use std::sync::mpsc;
120use std::thread::{self, JoinHandle};
121use tracing::{debug, debug_span, info, info_span, trace};
122use web_time::{Duration, Instant};
123
124/// The Model trait defines application state and behavior.
125///
126/// Implementations define how the application responds to events
127/// and renders its current state.
128pub trait Model: Sized {
129    /// The message type for this model.
130    ///
131    /// Messages represent actions that update the model state.
132    /// Must be convertible from terminal events.
133    type Message: From<Event> + Send + 'static;
134
135    /// Initialize the model with startup commands.
136    ///
137    /// Called once when the program starts. Return commands to execute
138    /// initial side effects like loading data.
139    fn init(&mut self) -> Cmd<Self::Message> {
140        Cmd::none()
141    }
142
143    /// Update the model in response to a message.
144    ///
145    /// This is the core state transition function. Returns commands
146    /// for any side effects that should be executed.
147    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
148
149    /// Render the current state to a frame.
150    ///
151    /// Called after updates when the UI needs to be redrawn.
152    fn view(&self, frame: &mut Frame);
153
154    /// Declare active subscriptions.
155    ///
156    /// Called after each `update()`. The runtime compares the returned set
157    /// (by `SubId`) against currently running subscriptions and starts/stops
158    /// as needed. Returning an empty vec stops all subscriptions.
159    ///
160    /// # Default
161    ///
162    /// Returns an empty vec (no subscriptions).
163    fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
164        vec![]
165    }
166
167    /// Downcast to [`ScreenTickDispatch`](crate::tick_strategy::ScreenTickDispatch)
168    /// for per-screen tick control.
169    ///
170    /// Override this to return `Some(self)` in multi-screen Models. The runtime
171    /// will then consult the active [`TickStrategy`](crate::tick_strategy::TickStrategy)
172    /// for each inactive screen instead of ticking monolithically.
173    ///
174    /// Default: `None` (all screens tick every frame, backwards-compatible).
175    fn as_screen_tick_dispatch(
176        &mut self,
177    ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
178        None
179    }
180
181    /// Called before the runtime exits, whether via [`Cmd::Quit`] or signal.
182    ///
183    /// Return cleanup commands (e.g., saving state, closing connections).
184    /// The runtime executes these before teardown.
185    ///
186    /// # Migration rationale
187    ///
188    /// Source frameworks use `componentWillUnmount`, `useEffect` cleanup, or
189    /// `beforeDestroy` hooks. This provides an equivalent lifecycle point.
190    fn on_shutdown(&mut self) -> Cmd<Self::Message> {
191        Cmd::none()
192    }
193
194    /// Called when a runtime, command, or background subscription error occurs.
195    ///
196    /// Return commands for error recovery or graceful degradation. The
197    /// `error` string contains the error description. Fatal runtime and
198    /// command errors still terminate after this hook runs; isolated
199    /// subscription failures may recover and keep the program running.
200    ///
201    /// # Migration rationale
202    ///
203    /// Source frameworks use `componentDidCatch`, error boundaries, or
204    /// `onError` hooks. This provides an equivalent error recovery point.
205    fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
206        Cmd::none()
207    }
208}
209
210/// Default weight assigned to background tasks.
211const DEFAULT_TASK_WEIGHT: f64 = 1.0;
212
213/// Frames between soft-tier memory capacity trims.
214///
215/// A soft alert fires on retained capacity; trimming (arena rebuild +
216/// grapheme-pool gc) releases it so the sensor can move again, but trimming
217/// every alerting frame would thrash allocations when usage hovers near the
218/// limit. One trim per 60 alerting frames bounds that churn (bd-1za0z).
219const SOFT_TRIM_COOLDOWN_FRAMES: u64 = 60;
220
221/// Default estimated task cost (ms) used for scheduling.
222const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
223
224/// Scheduling metadata for background tasks.
225#[derive(Debug, Clone)]
226pub struct TaskSpec {
227    /// Task weight (importance). Higher = more priority.
228    pub weight: f64,
229    /// Estimated task cost in milliseconds.
230    pub estimate_ms: f64,
231    /// Optional task name for evidence logging.
232    pub name: Option<String>,
233}
234
235impl Default for TaskSpec {
236    fn default() -> Self {
237        Self {
238            weight: DEFAULT_TASK_WEIGHT,
239            estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
240            name: None,
241        }
242    }
243}
244
245impl TaskSpec {
246    /// Create a task spec with an explicit weight and estimate.
247    #[must_use]
248    pub fn new(weight: f64, estimate_ms: f64) -> Self {
249        Self {
250            weight,
251            estimate_ms,
252            name: None,
253        }
254    }
255
256    /// Attach a task name for diagnostics.
257    #[must_use]
258    pub fn with_name(mut self, name: impl Into<String>) -> Self {
259        self.name = Some(name.into());
260        self
261    }
262}
263
264/// Per-frame timing data for profiling.
265#[derive(Debug, Clone, Copy)]
266pub struct FrameTiming {
267    pub frame_idx: u64,
268    pub update_us: u64,
269    pub render_us: u64,
270    pub diff_us: u64,
271    pub present_us: u64,
272    pub total_us: u64,
273}
274
275#[derive(Debug)]
276struct SignalTerminationError {
277    signal: i32,
278}
279
280impl std::fmt::Display for SignalTerminationError {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        write!(f, "terminated by signal {}", self.signal)
283    }
284}
285
286impl std::error::Error for SignalTerminationError {}
287
288fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
289    err.get_ref()
290        .and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
291        .map(|inner| inner.signal)
292}
293
294/// Sink for frame timing events.
295pub trait FrameTimingSink: Send + Sync {
296    fn record_frame(&self, timing: &FrameTiming);
297}
298
299/// Configuration for frame timing capture.
300#[derive(Clone)]
301pub struct FrameTimingConfig {
302    pub sink: Arc<dyn FrameTimingSink>,
303}
304
305impl FrameTimingConfig {
306    #[must_use]
307    pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
308        Self { sink }
309    }
310}
311
312impl std::fmt::Debug for FrameTimingConfig {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        f.debug_struct("FrameTimingConfig")
315            .field("sink", &"<dyn FrameTimingSink>")
316            .finish()
317    }
318}
319
320/// Commands represent side effects to be executed by the runtime.
321///
322/// Commands are returned from `init()` and `update()` to trigger
323/// actions like quitting, sending messages, or scheduling ticks.
324#[derive(Default)]
325pub enum Cmd<M> {
326    /// No operation.
327    #[default]
328    None,
329    /// Quit the application.
330    Quit,
331    /// Execute multiple commands as a batch (currently sequential).
332    Batch(Vec<Cmd<M>>),
333    /// Execute commands sequentially.
334    Sequence(Vec<Cmd<M>>),
335    /// Send a message to the model.
336    Msg(M),
337    /// Schedule a tick after a duration.
338    Tick(Duration),
339    /// Write a log message to the terminal output.
340    ///
341    /// This writes to the scrollback region in inline mode, or is ignored/handled
342    /// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
343    Log(String),
344    /// Execute a blocking operation on a background thread.
345    ///
346    /// When effect queue scheduling is enabled, tasks are enqueued and executed
347    /// in Smith-rule order on a dedicated worker thread. Otherwise the closure
348    /// runs on a spawned thread immediately. The return value is sent back
349    /// as a message to the model.
350    Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
351    /// Save widget state to the persistence registry.
352    ///
353    /// Triggers a flush of the state registry to the storage backend.
354    /// No-op if persistence is not configured.
355    SaveState,
356    /// Restore widget state from the persistence registry.
357    ///
358    /// Triggers a load from the storage backend and updates the cache.
359    /// No-op if persistence is not configured. Returns a message via
360    /// callback if state was successfully restored.
361    RestoreState,
362    /// Toggle mouse capture at runtime.
363    ///
364    /// Instructs the terminal session to enable or disable mouse event capture.
365    /// No-op in test simulators.
366    SetMouseCapture(bool),
367    /// Replace the tick strategy at runtime.
368    ///
369    /// Takes ownership of a boxed strategy. Use when switching from one
370    /// strategy to another (e.g., `Uniform` → `Predictive` after loading
371    /// persisted transition data).
372    SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
373}
374
375impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        match self {
378            Self::None => write!(f, "None"),
379            Self::Quit => write!(f, "Quit"),
380            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
381            Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
382            Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
383            Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
384            Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
385            Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
386            Self::SaveState => write!(f, "SaveState"),
387            Self::RestoreState => write!(f, "RestoreState"),
388            Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
389            Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
390        }
391    }
392}
393
394impl<M> Cmd<M> {
395    /// Create a no-op command.
396    #[inline]
397    pub fn none() -> Self {
398        Self::None
399    }
400
401    /// Create a quit command.
402    #[inline]
403    pub fn quit() -> Self {
404        Self::Quit
405    }
406
407    /// Create a message command.
408    #[inline]
409    pub fn msg(m: M) -> Self {
410        Self::Msg(m)
411    }
412
413    /// Create a log command.
414    ///
415    /// The message will be sanitized and written to the terminal log (scrollback).
416    /// A newline is appended if not present.
417    #[inline]
418    pub fn log(msg: impl Into<String>) -> Self {
419        Self::Log(msg.into())
420    }
421
422    /// Create a batch of commands.
423    pub fn batch(cmds: Vec<Self>) -> Self {
424        if cmds.is_empty() {
425            Self::None
426        } else if cmds.len() == 1 {
427            cmds.into_iter().next().unwrap_or(Self::None)
428        } else {
429            Self::Batch(cmds)
430        }
431    }
432
433    /// Create a sequence of commands.
434    pub fn sequence(cmds: Vec<Self>) -> Self {
435        if cmds.is_empty() {
436            Self::None
437        } else if cmds.len() == 1 {
438            cmds.into_iter().next().unwrap_or(Self::None)
439        } else {
440            Self::Sequence(cmds)
441        }
442    }
443
444    /// Return a stable name for telemetry and tracing.
445    #[inline]
446    pub fn type_name(&self) -> &'static str {
447        match self {
448            Self::None => "None",
449            Self::Quit => "Quit",
450            Self::Batch(_) => "Batch",
451            Self::Sequence(_) => "Sequence",
452            Self::Msg(_) => "Msg",
453            Self::Tick(_) => "Tick",
454            Self::Log(_) => "Log",
455            Self::Task(..) => "Task",
456            Self::SaveState => "SaveState",
457            Self::RestoreState => "RestoreState",
458            Self::SetMouseCapture(_) => "SetMouseCapture",
459            Self::SetTickStrategy(_) => "SetTickStrategy",
460        }
461    }
462
463    /// Create a tick command.
464    #[inline]
465    pub fn tick(duration: Duration) -> Self {
466        Self::Tick(duration)
467    }
468
469    /// Create a background task command.
470    ///
471    /// The closure runs on a spawned thread (or the effect queue worker when
472    /// scheduling is enabled). When it completes, the returned message is
473    /// sent back to the model's `update()`.
474    pub fn task<F>(f: F) -> Self
475    where
476        F: FnOnce() -> M + Send + 'static,
477    {
478        Self::Task(TaskSpec::default(), Box::new(f))
479    }
480
481    /// Create a background task command with explicit scheduling metadata.
482    pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
483    where
484        F: FnOnce() -> M + Send + 'static,
485    {
486        Self::Task(spec, Box::new(f))
487    }
488
489    /// Create a background task command with explicit weight and estimate.
490    pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
491    where
492        F: FnOnce() -> M + Send + 'static,
493    {
494        Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
495    }
496
497    /// Create a named background task command.
498    pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
499    where
500        F: FnOnce() -> M + Send + 'static,
501    {
502        Self::Task(TaskSpec::default().with_name(name), Box::new(f))
503    }
504
505    /// Replace the active tick strategy at runtime.
506    ///
507    /// Use when switching strategies (e.g., `Uniform` → `Predictive` after
508    /// loading persisted transition data).
509    pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
510        Self::SetTickStrategy(Box::new(strategy))
511    }
512
513    /// Create a save state command.
514    ///
515    /// Triggers a flush of the state registry to the storage backend.
516    /// No-op if persistence is not configured.
517    #[inline]
518    pub fn save_state() -> Self {
519        Self::SaveState
520    }
521
522    /// Create a restore state command.
523    ///
524    /// Triggers a load from the storage backend.
525    /// No-op if persistence is not configured.
526    #[inline]
527    pub fn restore_state() -> Self {
528        Self::RestoreState
529    }
530
531    /// Create a mouse capture toggle command.
532    ///
533    /// Instructs the runtime to enable or disable mouse event capture on the
534    /// underlying terminal session.
535    #[inline]
536    pub fn set_mouse_capture(enabled: bool) -> Self {
537        Self::SetMouseCapture(enabled)
538    }
539
540    /// Count the number of atomic commands in this command.
541    ///
542    /// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
543    pub fn count(&self) -> usize {
544        match self {
545            Self::None => 0,
546            Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
547            _ => 1,
548        }
549    }
550}
551
552/// Resize handling behavior for the runtime.
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub enum ResizeBehavior {
555    /// Apply resize immediately (no debounce, no placeholder).
556    Immediate,
557    /// Coalesce resize events for continuous reflow.
558    Throttled,
559}
560
561impl ResizeBehavior {
562    const fn uses_coalescer(self) -> bool {
563        matches!(self, ResizeBehavior::Throttled)
564    }
565}
566
567/// Policy controlling when terminal mouse capture is enabled.
568///
569/// Mouse capture can steal normal scrollback interaction in inline mode.
570/// `Auto` keeps inline mode scrollback-safe while still enabling mouse in
571/// alt-screen mode.
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
573pub enum MouseCapturePolicy {
574    /// Enable in alt-screen mode, disable in inline modes.
575    #[default]
576    Auto,
577    /// Always enable mouse capture.
578    On,
579    /// Always disable mouse capture.
580    Off,
581}
582
583impl MouseCapturePolicy {
584    /// Resolve the policy to a concrete mouse-capture toggle.
585    #[must_use]
586    pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
587        match self {
588            Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
589            Self::On => true,
590            Self::Off => false,
591        }
592    }
593}
594
595const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
596const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
597
598/// One splitter handle region in terminal cell-space.
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub struct PaneTerminalSplitterHandle {
601    /// Semantic resize target represented by this handle.
602    pub target: PaneResizeTarget,
603    /// Cell-space hit rectangle for this handle.
604    pub rect: Rect,
605    /// Split boundary coordinate used for deterministic nearest-target ranking.
606    pub boundary: i32,
607}
608
609/// Build deterministic splitter handle regions for terminal hit-testing.
610///
611/// Handles are emitted in split-id order and are clamped to the split rect.
612#[must_use]
613pub fn pane_terminal_splitter_handles(
614    tree: &PaneTree,
615    layout: &PaneLayout,
616    hit_thickness: u16,
617) -> Vec<PaneTerminalSplitterHandle> {
618    let thickness = if hit_thickness == 0 {
619        PANE_TERMINAL_DEFAULT_HIT_THICKNESS
620    } else {
621        hit_thickness
622    };
623    let mut handles = Vec::new();
624    for node in tree.nodes() {
625        let PaneNodeKind::Split(split) = &node.kind else {
626            continue;
627        };
628        let Some(split_rect) = layout.rect(node.id) else {
629            continue;
630        };
631        if split_rect.is_empty() {
632            continue;
633        }
634        let Some(first_rect) = layout.rect(split.first) else {
635            continue;
636        };
637        let Some(second_rect) = layout.rect(split.second) else {
638            continue;
639        };
640
641        let boundary_u16 = match split.axis {
642            SplitAxis::Horizontal => {
643                // Horizontal split => left/right panes => vertical splitter line.
644                if second_rect.x == split_rect.x {
645                    first_rect.right()
646                } else {
647                    second_rect.x
648                }
649            }
650            SplitAxis::Vertical => {
651                // Vertical split => top/bottom panes => horizontal splitter line.
652                if second_rect.y == split_rect.y {
653                    first_rect.bottom()
654                } else {
655                    second_rect.y
656                }
657            }
658        };
659        let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
660            continue;
661        };
662        handles.push(PaneTerminalSplitterHandle {
663            target: PaneResizeTarget {
664                split_id: node.id,
665                axis: split.axis,
666            },
667            rect,
668            boundary: i32::from(boundary_u16),
669        });
670    }
671    handles
672}
673
674/// Resolve a semantic splitter target from a terminal cell position.
675///
676/// If multiple handles overlap, chooses deterministically by:
677/// 1) smallest distance to the splitter boundary, then
678/// 2) smaller split_id, then
679/// 3) horizontal axis before vertical axis.
680#[must_use]
681pub fn pane_terminal_resolve_splitter_target(
682    handles: &[PaneTerminalSplitterHandle],
683    x: u16,
684    y: u16,
685) -> Option<PaneResizeTarget> {
686    let px = i32::from(x);
687    let py = i32::from(y);
688    let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
689
690    for handle in handles {
691        if !rect_contains_cell(handle.rect, x, y) {
692            continue;
693        }
694        let distance = match handle.target.axis {
695            SplitAxis::Horizontal => px.abs_diff(handle.boundary),
696            SplitAxis::Vertical => py.abs_diff(handle.boundary),
697        };
698        let axis_rank = match handle.target.axis {
699            SplitAxis::Horizontal => 0,
700            SplitAxis::Vertical => 1,
701        };
702        let key = (distance, handle.target.split_id.get(), axis_rank);
703        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
704            best = Some((key, handle.target));
705        }
706    }
707
708    best.map(|(_, target)| target)
709}
710
711/// Register pane splitter handles into the frame hit-grid.
712///
713/// Each handle is registered as `HitRegion::Handle` with encoded target data.
714/// Returns number of successfully-registered regions.
715pub fn register_pane_terminal_splitter_hits(
716    frame: &mut Frame,
717    handles: &[PaneTerminalSplitterHandle],
718    hit_id_base: u32,
719) -> usize {
720    let mut registered = 0usize;
721    for (idx, handle) in handles.iter().enumerate() {
722        let Ok(offset) = u32::try_from(idx) else {
723            break;
724        };
725        let hit_id = HitId::new(hit_id_base.saturating_add(offset));
726        if frame.register_hit(
727            handle.rect,
728            hit_id,
729            HitRegion::Handle,
730            encode_pane_resize_target(handle.target),
731        ) {
732            registered = registered.saturating_add(1);
733        }
734    }
735    registered
736}
737
738/// Decode pane resize target from a hit-grid tuple produced by pane handle registration.
739#[must_use]
740pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
741    let (_, region, data) = hit;
742    if region != HitRegion::Handle {
743        return None;
744    }
745    decode_pane_resize_target(data)
746}
747
748fn splitter_hit_rect(
749    axis: SplitAxis,
750    split_rect: Rect,
751    boundary: u16,
752    thickness: u16,
753) -> Option<Rect> {
754    let half = thickness.saturating_sub(1) / 2;
755    match axis {
756        SplitAxis::Horizontal => {
757            let start = boundary.saturating_sub(half).max(split_rect.x);
758            let end = boundary
759                .saturating_add(thickness.saturating_sub(half))
760                .min(split_rect.right());
761            let width = end.saturating_sub(start);
762            (width > 0 && split_rect.height > 0).then_some(Rect::new(
763                start,
764                split_rect.y,
765                width,
766                split_rect.height,
767            ))
768        }
769        SplitAxis::Vertical => {
770            let start = boundary.saturating_sub(half).max(split_rect.y);
771            let end = boundary
772                .saturating_add(thickness.saturating_sub(half))
773                .min(split_rect.bottom());
774            let height = end.saturating_sub(start);
775            (height > 0 && split_rect.width > 0).then_some(Rect::new(
776                split_rect.x,
777                start,
778                split_rect.width,
779                height,
780            ))
781        }
782    }
783}
784
785fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
786    x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
787}
788
789fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
790    let axis = match target.axis {
791        SplitAxis::Horizontal => 0_u64,
792        SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
793    };
794    (target.split_id.get() << 1) | axis
795}
796
797fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
798    let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
799        SplitAxis::Horizontal
800    } else {
801        SplitAxis::Vertical
802    };
803    let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
804    Some(PaneResizeTarget { split_id, axis })
805}
806
807// ============================================================================
808// Pane capability matrix for multiplexer / terminal compat (bd-6u66i)
809// ============================================================================
810
811/// Which multiplexer environment the terminal is running inside.
812#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
813pub enum PaneMuxEnvironment {
814    /// No multiplexer detected — direct terminal access.
815    None,
816    /// tmux (TMUX env var set, or DA2 terminal type 84).
817    Tmux,
818    /// GNU Screen (STY env var set, or DA2 terminal type 83).
819    Screen,
820    /// Zellij (ZELLIJ env var set).
821    Zellij,
822    /// WezTerm mux-served pane/session.
823    WeztermMux,
824}
825
826/// Resolved capability matrix describing which pane interaction features
827/// are available in the current terminal + multiplexer environment.
828///
829/// Derived from [`TerminalCapabilities`] via [`PaneCapabilityMatrix::from_capabilities`].
830/// The adapter uses this to decide which code-paths are safe and which
831/// need deterministic fallbacks.
832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub struct PaneCapabilityMatrix {
834    /// Detected multiplexer environment.
835    pub mux: PaneMuxEnvironment,
836
837    // --- Mouse input capabilities ---
838    /// SGR (1006) extended mouse protocol available.
839    /// Without this, mouse coordinates are limited to 223 columns/rows.
840    pub mouse_sgr: bool,
841    /// Mouse drag events are reliably delivered.
842    /// False in some screen versions where drag tracking is incomplete.
843    pub mouse_drag_reliable: bool,
844    /// Mouse button events include correct button identity on release.
845    /// X10/normal mode sends button 3 for all releases; SGR preserves it.
846    pub mouse_button_discrimination: bool,
847
848    // --- Focus / lifecycle ---
849    /// Terminal delivers CSI I / CSI O focus events.
850    pub focus_events: bool,
851    /// Bracketed paste mode available (affects interaction cancel heuristics).
852    pub bracketed_paste: bool,
853
854    // --- Rendering affordances ---
855    /// Unicode box-drawing glyphs available for splitter rendering.
856    pub unicode_box_drawing: bool,
857    /// True-color support for splitter highlight/drag feedback.
858    pub true_color: bool,
859
860    // --- Fallback summary ---
861    /// One or more pane features are degraded due to environment constraints.
862    pub degraded: bool,
863}
864
865/// Human-readable description of a known limitation and its fallback.
866#[derive(Debug, Clone, PartialEq, Eq)]
867pub struct PaneCapabilityLimitation {
868    /// Short identifier (e.g. `"mouse_drag_unreliable"`).
869    pub id: &'static str,
870    /// What the limitation is.
871    pub description: &'static str,
872    /// What the adapter does instead.
873    pub fallback: &'static str,
874}
875
876impl PaneCapabilityMatrix {
877    /// Derive the pane capability matrix from terminal capabilities.
878    ///
879    /// This is the single source of truth for which pane features are
880    /// available. All fallback decisions flow from this matrix.
881    #[must_use]
882    pub fn from_capabilities(
883        caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
884    ) -> Self {
885        let mux = if caps.in_tmux {
886            PaneMuxEnvironment::Tmux
887        } else if caps.in_screen {
888            PaneMuxEnvironment::Screen
889        } else if caps.in_zellij {
890            PaneMuxEnvironment::Zellij
891        } else if caps.in_wezterm_mux {
892            PaneMuxEnvironment::WeztermMux
893        } else {
894            PaneMuxEnvironment::None
895        };
896
897        let mouse_sgr = caps.mouse_sgr;
898
899        // GNU Screen has historically unreliable drag event delivery.
900        // tmux and zellij forward drags correctly in modern versions.
901        let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
902
903        // Button discrimination requires SGR mouse protocol.
904        // Without it, X10/normal mode reports button 3 for all releases.
905        let mouse_button_discrimination = mouse_sgr;
906
907        // Focus events are conservatively disabled in any mux context.
908        let focus_events = caps.focus_events && !caps.in_any_mux();
909
910        let bracketed_paste = caps.bracketed_paste;
911        let unicode_box_drawing = caps.unicode_box_drawing;
912        let true_color = caps.supports_true_color();
913
914        let degraded =
915            !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
916
917        Self {
918            mux,
919            mouse_sgr,
920            mouse_drag_reliable,
921            mouse_button_discrimination,
922            focus_events,
923            bracketed_paste,
924            unicode_box_drawing,
925            true_color,
926            degraded,
927        }
928    }
929
930    /// Whether pane drag interactions should be enabled at all.
931    ///
932    /// Drag requires at minimum mouse event support. If drag events
933    /// are unreliable (e.g. GNU Screen), drag is disabled and the
934    /// adapter falls back to keyboard-only resize.
935    #[must_use]
936    pub const fn drag_enabled(&self) -> bool {
937        self.mouse_drag_reliable
938    }
939
940    /// Whether focus-loss auto-cancel is effective.
941    ///
942    /// When focus events are unavailable, the adapter cannot detect
943    /// window blur — interactions must rely on timeout or explicit
944    /// keyboard cancel instead.
945    #[must_use]
946    pub const fn focus_cancel_effective(&self) -> bool {
947        self.focus_events
948    }
949
950    /// Collect all active limitations with their fallback descriptions.
951    #[must_use]
952    pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
953        let mut out = Vec::new();
954
955        if !self.mouse_sgr {
956            out.push(PaneCapabilityLimitation {
957                id: "no_sgr_mouse",
958                description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
959                fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
960            });
961        }
962
963        if !self.mouse_drag_reliable {
964            out.push(PaneCapabilityLimitation {
965                id: "mouse_drag_unreliable",
966                description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
967                fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
968            });
969        }
970
971        if !self.mouse_button_discrimination {
972            out.push(PaneCapabilityLimitation {
973                id: "no_button_discrimination",
974                description: "Mouse release events do not identify which button was released",
975                fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
976            });
977        }
978
979        if !self.focus_events {
980            out.push(PaneCapabilityLimitation {
981                id: "no_focus_events",
982                description: "Terminal does not deliver focus-in/focus-out events",
983                fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
984            });
985        }
986
987        out
988    }
989}
990
991/// Configuration for terminal-to-pane semantic input translation.
992///
993/// This adapter normalizes terminal `Event` streams into
994/// `PaneSemanticInputEvent` values accepted by `PaneDragResizeMachine`.
995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
996pub struct PaneTerminalAdapterConfig {
997    /// Drag start threshold in pane-local units.
998    pub drag_threshold: u16,
999    /// Drag update hysteresis threshold in pane-local units.
1000    pub update_hysteresis: u16,
1001    /// Mouse button required to begin a drag sequence.
1002    pub activation_button: PanePointerButton,
1003    /// Minimum drag delta (Manhattan distance, cells) before forwarding
1004    /// updates while already in the dragging state.
1005    pub drag_update_coalesce_distance: u16,
1006    /// Cancel active interactions on focus loss.
1007    pub cancel_on_focus_lost: bool,
1008    /// Cancel active interactions on terminal resize.
1009    pub cancel_on_resize: bool,
1010}
1011
1012impl Default for PaneTerminalAdapterConfig {
1013    fn default() -> Self {
1014        Self {
1015            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1016            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1017            activation_button: PanePointerButton::Primary,
1018            drag_update_coalesce_distance: 2,
1019            cancel_on_focus_lost: true,
1020            cancel_on_resize: true,
1021        }
1022    }
1023}
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1026struct PaneTerminalActivePointer {
1027    pointer_id: u32,
1028    target: PaneResizeTarget,
1029    button: PanePointerButton,
1030    last_position: PanePointerPosition,
1031    cumulative_delta_x: i32,
1032    cumulative_delta_y: i32,
1033    direction_changes: u16,
1034    sample_count: u32,
1035    previous_step_delta_x: i32,
1036    previous_step_delta_y: i32,
1037    start_time: Instant,
1038}
1039
1040/// Lifecycle phase observed while translating a terminal event.
1041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1042pub enum PaneTerminalLifecyclePhase {
1043    MouseDown,
1044    MouseDrag,
1045    MouseMove,
1046    MouseUp,
1047    MouseScroll,
1048    KeyResize,
1049    KeyCancel,
1050    FocusLoss,
1051    ResizeInterrupt,
1052    Other,
1053}
1054
1055/// Deterministic reason a terminal event did not map to pane semantics.
1056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1057pub enum PaneTerminalIgnoredReason {
1058    MissingTarget,
1059    NoActivePointer,
1060    PointerButtonMismatch,
1061    ActivationButtonRequired,
1062    WindowNotFocused,
1063    UnsupportedKey,
1064    FocusGainNoop,
1065    ResizeNoop,
1066    DragCoalesced,
1067    NonSemanticEvent,
1068    MachineRejectedEvent,
1069}
1070
1071/// Translation outcome for one raw terminal event.
1072#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1073pub enum PaneTerminalLogOutcome {
1074    SemanticForwarded,
1075    SemanticForwardedAfterRecovery,
1076    Ignored(PaneTerminalIgnoredReason),
1077}
1078
1079/// Structured translation log entry for one raw terminal event.
1080#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1081pub struct PaneTerminalLogEntry {
1082    pub phase: PaneTerminalLifecyclePhase,
1083    pub sequence: Option<u64>,
1084    pub pointer_id: Option<u32>,
1085    pub target: Option<PaneResizeTarget>,
1086    pub recovery_cancel_sequence: Option<u64>,
1087    pub outcome: PaneTerminalLogOutcome,
1088}
1089
1090/// Output of one terminal event translation step.
1091///
1092/// `recovery_*` fields are populated when the adapter first emits an internal
1093/// cancel (for stale/missing mouse-up recovery) and then forwards the incoming
1094/// event as a fresh semantic event.
1095#[derive(Debug, Clone, PartialEq)]
1096pub struct PaneTerminalDispatch {
1097    pub primary_event: Option<PaneSemanticInputEvent>,
1098    pub primary_transition: Option<PaneDragResizeTransition>,
1099    pub motion: Option<PaneMotionVector>,
1100    pub inertial_throw: Option<PaneInertialThrow>,
1101    pub projected_position: Option<PanePointerPosition>,
1102    pub recovery_event: Option<PaneSemanticInputEvent>,
1103    pub recovery_transition: Option<PaneDragResizeTransition>,
1104    pub log: PaneTerminalLogEntry,
1105}
1106
1107impl PaneTerminalDispatch {
1108    fn ignored(
1109        phase: PaneTerminalLifecyclePhase,
1110        reason: PaneTerminalIgnoredReason,
1111        pointer_id: Option<u32>,
1112        target: Option<PaneResizeTarget>,
1113    ) -> Self {
1114        Self {
1115            primary_event: None,
1116            primary_transition: None,
1117            motion: None,
1118            inertial_throw: None,
1119            projected_position: None,
1120            recovery_event: None,
1121            recovery_transition: None,
1122            log: PaneTerminalLogEntry {
1123                phase,
1124                sequence: None,
1125                pointer_id,
1126                target,
1127                recovery_cancel_sequence: None,
1128                outcome: PaneTerminalLogOutcome::Ignored(reason),
1129            },
1130        }
1131    }
1132
1133    fn forwarded(
1134        phase: PaneTerminalLifecyclePhase,
1135        pointer_id: Option<u32>,
1136        target: Option<PaneResizeTarget>,
1137        event: PaneSemanticInputEvent,
1138        transition: PaneDragResizeTransition,
1139    ) -> Self {
1140        let sequence = Some(event.sequence);
1141        Self {
1142            primary_event: Some(event),
1143            primary_transition: Some(transition),
1144            motion: None,
1145            inertial_throw: None,
1146            projected_position: None,
1147            recovery_event: None,
1148            recovery_transition: None,
1149            log: PaneTerminalLogEntry {
1150                phase,
1151                sequence,
1152                pointer_id,
1153                target,
1154                recovery_cancel_sequence: None,
1155                outcome: PaneTerminalLogOutcome::SemanticForwarded,
1156            },
1157        }
1158    }
1159
1160    /// Derive dynamic snap profile from translated pointer motion.
1161    #[must_use]
1162    pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1163        self.motion.map(PanePressureSnapProfile::from_motion)
1164    }
1165}
1166
1167/// Deterministic terminal adapter mapping raw `Event` values into
1168/// schema-validated pane semantic interaction events.
1169#[derive(Debug, Clone)]
1170pub struct PaneTerminalAdapter {
1171    machine: PaneDragResizeMachine,
1172    config: PaneTerminalAdapterConfig,
1173    active: Option<PaneTerminalActivePointer>,
1174    window_focused: bool,
1175    next_sequence: u64,
1176}
1177
1178impl PaneTerminalAdapter {
1179    /// Construct a new adapter with validated drag thresholds.
1180    pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1181        let config = PaneTerminalAdapterConfig {
1182            drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1183            ..config
1184        };
1185        let machine = PaneDragResizeMachine::new_with_hysteresis(
1186            config.drag_threshold,
1187            config.update_hysteresis,
1188        )?;
1189        Ok(Self {
1190            machine,
1191            config,
1192            active: None,
1193            window_focused: true,
1194            next_sequence: 1,
1195        })
1196    }
1197
1198    /// Adapter configuration.
1199    #[must_use]
1200    pub const fn config(&self) -> PaneTerminalAdapterConfig {
1201        self.config
1202    }
1203
1204    /// Active pointer id currently tracked by the adapter, if any.
1205    #[must_use]
1206    pub fn active_pointer_id(&self) -> Option<u32> {
1207        self.active.map(|active| active.pointer_id)
1208    }
1209
1210    /// Whether the host window is currently focused.
1211    #[must_use]
1212    pub const fn window_focused(&self) -> bool {
1213        self.window_focused
1214    }
1215
1216    /// Current pane drag/resize machine state.
1217    #[must_use]
1218    pub const fn machine_state(&self) -> PaneDragResizeState {
1219        self.machine.state()
1220    }
1221
1222    /// Translate one raw terminal event into pane semantic event(s).
1223    ///
1224    /// `target_hint` is provided by host hit-testing (upcoming pane-terminal
1225    /// tasks). Pointer drag/move/up reuse active target continuity once armed.
1226    pub fn translate(
1227        &mut self,
1228        event: &Event,
1229        target_hint: Option<PaneResizeTarget>,
1230    ) -> PaneTerminalDispatch {
1231        match event {
1232            Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1233            Event::Key(key) => self.translate_key(*key, target_hint),
1234            Event::Focus(focused) => self.translate_focus(*focused),
1235            Event::Resize { .. } => self.translate_resize(),
1236            _ => PaneTerminalDispatch::ignored(
1237                PaneTerminalLifecyclePhase::Other,
1238                PaneTerminalIgnoredReason::NonSemanticEvent,
1239                None,
1240                target_hint,
1241            ),
1242        }
1243    }
1244
1245    /// Translate one raw terminal event while resolving splitter targets from
1246    /// terminal hit regions.
1247    ///
1248    /// This is a convenience wrapper for host code that already has splitter
1249    /// handle regions from [`pane_terminal_splitter_handles`].
1250    pub fn translate_with_handles(
1251        &mut self,
1252        event: &Event,
1253        handles: &[PaneTerminalSplitterHandle],
1254    ) -> PaneTerminalDispatch {
1255        let active_target = self.active.map(|active| active.target);
1256        let target_hint = match event {
1257            Event::Mouse(mouse) => {
1258                let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1259                match mouse.kind {
1260                    MouseEventKind::Down(_)
1261                    | MouseEventKind::ScrollUp
1262                    | MouseEventKind::ScrollDown
1263                    | MouseEventKind::ScrollLeft
1264                    | MouseEventKind::ScrollRight => resolved,
1265                    MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1266                        resolved.or(active_target)
1267                    }
1268                }
1269            }
1270            Event::Key(_) => active_target,
1271            _ => None,
1272        };
1273        self.translate(event, target_hint)
1274    }
1275
1276    fn translate_mouse(
1277        &mut self,
1278        mouse: MouseEvent,
1279        target_hint: Option<PaneResizeTarget>,
1280    ) -> PaneTerminalDispatch {
1281        let position = mouse_position(mouse);
1282        let modifiers = pane_modifiers(mouse.modifiers);
1283        match mouse.kind {
1284            MouseEventKind::Down(button) => {
1285                let pane_button = pane_button(button);
1286                if pane_button != self.config.activation_button {
1287                    return PaneTerminalDispatch::ignored(
1288                        PaneTerminalLifecyclePhase::MouseDown,
1289                        PaneTerminalIgnoredReason::ActivationButtonRequired,
1290                        Some(pointer_id_for_button(pane_button)),
1291                        target_hint,
1292                    );
1293                }
1294                let Some(target) = target_hint else {
1295                    return PaneTerminalDispatch::ignored(
1296                        PaneTerminalLifecyclePhase::MouseDown,
1297                        PaneTerminalIgnoredReason::MissingTarget,
1298                        Some(pointer_id_for_button(pane_button)),
1299                        None,
1300                    );
1301                };
1302
1303                let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1304                let pointer_id = pointer_id_for_button(pane_button);
1305                let kind = PaneSemanticInputEventKind::PointerDown {
1306                    target,
1307                    pointer_id,
1308                    button: pane_button,
1309                    position,
1310                };
1311                let mut dispatch = self.forward_semantic(
1312                    PaneTerminalLifecyclePhase::MouseDown,
1313                    Some(pointer_id),
1314                    Some(target),
1315                    kind,
1316                    modifiers,
1317                );
1318                if dispatch.primary_transition.is_some() {
1319                    self.active = Some(PaneTerminalActivePointer {
1320                        pointer_id,
1321                        target,
1322                        button: pane_button,
1323                        last_position: position,
1324                        cumulative_delta_x: 0,
1325                        cumulative_delta_y: 0,
1326                        direction_changes: 0,
1327                        sample_count: 0,
1328                        previous_step_delta_x: 0,
1329                        previous_step_delta_y: 0,
1330                        start_time: Instant::now(),
1331                    });
1332                }
1333                if let Some((cancel_event, cancel_transition)) = recovery {
1334                    dispatch.recovery_event = Some(cancel_event);
1335                    dispatch.recovery_transition = Some(cancel_transition);
1336                    dispatch.log.recovery_cancel_sequence =
1337                        dispatch.recovery_event.as_ref().map(|event| event.sequence);
1338                    if matches!(
1339                        dispatch.log.outcome,
1340                        PaneTerminalLogOutcome::SemanticForwarded
1341                    ) {
1342                        dispatch.log.outcome =
1343                            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1344                    }
1345                }
1346                dispatch
1347            }
1348            MouseEventKind::Drag(button) => {
1349                let pane_button = pane_button(button);
1350                let Some(active) = self.active else {
1351                    return PaneTerminalDispatch::ignored(
1352                        PaneTerminalLifecyclePhase::MouseDrag,
1353                        PaneTerminalIgnoredReason::NoActivePointer,
1354                        Some(pointer_id_for_button(pane_button)),
1355                        target_hint,
1356                    );
1357                };
1358                if active.button != pane_button {
1359                    return PaneTerminalDispatch::ignored(
1360                        PaneTerminalLifecyclePhase::MouseDrag,
1361                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1362                        Some(pointer_id_for_button(pane_button)),
1363                        Some(active.target),
1364                    );
1365                }
1366                self.apply_pointer_motion(
1367                    active,
1368                    position,
1369                    modifiers,
1370                    PaneTerminalLifecyclePhase::MouseDrag,
1371                )
1372            }
1373            MouseEventKind::Moved => {
1374                let Some(active) = self.active else {
1375                    return PaneTerminalDispatch::ignored(
1376                        PaneTerminalLifecyclePhase::MouseMove,
1377                        PaneTerminalIgnoredReason::NoActivePointer,
1378                        None,
1379                        target_hint,
1380                    );
1381                };
1382                self.apply_pointer_motion(
1383                    active,
1384                    position,
1385                    modifiers,
1386                    PaneTerminalLifecyclePhase::MouseMove,
1387                )
1388            }
1389            MouseEventKind::Up(button) => {
1390                let pane_button = pane_button(button);
1391                let Some(active) = self.active else {
1392                    return PaneTerminalDispatch::ignored(
1393                        PaneTerminalLifecyclePhase::MouseUp,
1394                        PaneTerminalIgnoredReason::NoActivePointer,
1395                        Some(pointer_id_for_button(pane_button)),
1396                        target_hint,
1397                    );
1398                };
1399                if active.button != pane_button {
1400                    return PaneTerminalDispatch::ignored(
1401                        PaneTerminalLifecyclePhase::MouseUp,
1402                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1403                        Some(pointer_id_for_button(pane_button)),
1404                        Some(active.target),
1405                    );
1406                }
1407                let kind = PaneSemanticInputEventKind::PointerUp {
1408                    target: active.target,
1409                    pointer_id: active.pointer_id,
1410                    button: active.button,
1411                    position,
1412                };
1413                let mut dispatch = self.forward_semantic(
1414                    PaneTerminalLifecyclePhase::MouseUp,
1415                    Some(active.pointer_id),
1416                    Some(active.target),
1417                    kind,
1418                    modifiers,
1419                );
1420                if dispatch.primary_transition.is_some() {
1421                    let duration = active.start_time.elapsed().as_millis() as u32;
1422                    let motion = PaneMotionVector::from_delta(
1423                        active.cumulative_delta_x,
1424                        active.cumulative_delta_y,
1425                        duration,
1426                        active.direction_changes,
1427                    );
1428                    let inertial_throw = PaneInertialThrow::from_motion(motion);
1429                    dispatch.motion = Some(motion);
1430                    dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1431                    dispatch.inertial_throw = Some(inertial_throw);
1432                    self.active = None;
1433                }
1434                dispatch
1435            }
1436            MouseEventKind::ScrollUp
1437            | MouseEventKind::ScrollDown
1438            | MouseEventKind::ScrollLeft
1439            | MouseEventKind::ScrollRight => {
1440                let target = target_hint.or(self.active.map(|active| active.target));
1441                let Some(target) = target else {
1442                    return PaneTerminalDispatch::ignored(
1443                        PaneTerminalLifecyclePhase::MouseScroll,
1444                        PaneTerminalIgnoredReason::MissingTarget,
1445                        None,
1446                        None,
1447                    );
1448                };
1449                let lines = match mouse.kind {
1450                    MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1451                    MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1452                    _ => unreachable!("handled by outer match"),
1453                };
1454                let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1455                self.forward_semantic(
1456                    PaneTerminalLifecyclePhase::MouseScroll,
1457                    None,
1458                    Some(target),
1459                    kind,
1460                    modifiers,
1461                )
1462            }
1463        }
1464    }
1465
1466    /// Shared motion bookkeeping for the `Drag` and `Moved` mouse arms.
1467    ///
1468    /// Once each arm's arm-specific guards pass (`Drag` additionally checks the
1469    /// pressed button), both perform byte-identical work: compute the step
1470    /// delta, honor drag coalescing, fold direction-change and cumulative-motion
1471    /// tracking into `active`, forward a `PointerMove`, and — only on a committed
1472    /// transition — advance `last_position` and attach the motion vector. The
1473    /// sole caller-specific input is `phase` (`MouseDrag` vs `MouseMove`), used
1474    /// for both the coalesced-ignore and the forwarded event so each caller's
1475    /// diagnostics are unchanged. This is a pure extraction of the two
1476    /// previously-duplicated arm bodies — event ordering and semantics are
1477    /// identical, and the single shared path is cheaper to audit and roll back.
1478    fn apply_pointer_motion(
1479        &mut self,
1480        mut active: PaneTerminalActivePointer,
1481        position: PanePointerPosition,
1482        modifiers: PaneModifierSnapshot,
1483        phase: PaneTerminalLifecyclePhase,
1484    ) -> PaneTerminalDispatch {
1485        let delta_x = position.x.saturating_sub(active.last_position.x);
1486        let delta_y = position.y.saturating_sub(active.last_position.y);
1487        if self.should_coalesce_drag(delta_x, delta_y) {
1488            return PaneTerminalDispatch::ignored(
1489                phase,
1490                PaneTerminalIgnoredReason::DragCoalesced,
1491                Some(active.pointer_id),
1492                Some(active.target),
1493            );
1494        }
1495        if active.sample_count > 0 {
1496            let flipped_x = delta_x.signum() != 0
1497                && active.previous_step_delta_x.signum() != 0
1498                && delta_x.signum() != active.previous_step_delta_x.signum();
1499            let flipped_y = delta_y.signum() != 0
1500                && active.previous_step_delta_y.signum() != 0
1501                && delta_y.signum() != active.previous_step_delta_y.signum();
1502            if flipped_x || flipped_y {
1503                active.direction_changes = active.direction_changes.saturating_add(1);
1504            }
1505        }
1506        active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1507        active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1508        active.sample_count = active.sample_count.saturating_add(1);
1509        active.previous_step_delta_x = delta_x;
1510        active.previous_step_delta_y = delta_y;
1511        let kind = PaneSemanticInputEventKind::PointerMove {
1512            target: active.target,
1513            pointer_id: active.pointer_id,
1514            position,
1515            delta_x,
1516            delta_y,
1517        };
1518        let mut dispatch = self.forward_semantic(
1519            phase,
1520            Some(active.pointer_id),
1521            Some(active.target),
1522            kind,
1523            modifiers,
1524        );
1525        if dispatch.primary_transition.is_some() {
1526            active.last_position = position;
1527            self.active = Some(active);
1528            let duration = active.start_time.elapsed().as_millis() as u32;
1529            dispatch.motion = Some(PaneMotionVector::from_delta(
1530                active.cumulative_delta_x,
1531                active.cumulative_delta_y,
1532                duration,
1533                active.direction_changes,
1534            ));
1535        }
1536        dispatch
1537    }
1538
1539    fn translate_key(
1540        &mut self,
1541        key: KeyEvent,
1542        target_hint: Option<PaneResizeTarget>,
1543    ) -> PaneTerminalDispatch {
1544        if !self.window_focused {
1545            return PaneTerminalDispatch::ignored(
1546                PaneTerminalLifecyclePhase::KeyResize,
1547                PaneTerminalIgnoredReason::WindowNotFocused,
1548                self.active_pointer_id(),
1549                target_hint.or(self.active.map(|active| active.target)),
1550            );
1551        }
1552        if key.kind == KeyEventKind::Release {
1553            return PaneTerminalDispatch::ignored(
1554                PaneTerminalLifecyclePhase::Other,
1555                PaneTerminalIgnoredReason::UnsupportedKey,
1556                None,
1557                target_hint,
1558            );
1559        }
1560        if matches!(key.code, KeyCode::Escape) {
1561            return self.cancel_active_dispatch(
1562                PaneTerminalLifecyclePhase::KeyCancel,
1563                PaneCancelReason::EscapeKey,
1564                PaneTerminalIgnoredReason::NoActivePointer,
1565            );
1566        }
1567        let target = target_hint.or(self.active.map(|active| active.target));
1568        let Some(target) = target else {
1569            return PaneTerminalDispatch::ignored(
1570                PaneTerminalLifecyclePhase::KeyResize,
1571                PaneTerminalIgnoredReason::MissingTarget,
1572                None,
1573                None,
1574            );
1575        };
1576        let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1577            return PaneTerminalDispatch::ignored(
1578                PaneTerminalLifecyclePhase::KeyResize,
1579                PaneTerminalIgnoredReason::UnsupportedKey,
1580                None,
1581                Some(target),
1582            );
1583        };
1584        let units = keyboard_resize_units(key.modifiers);
1585        let kind = PaneSemanticInputEventKind::KeyboardResize {
1586            target,
1587            direction,
1588            units,
1589        };
1590        self.forward_semantic(
1591            PaneTerminalLifecyclePhase::KeyResize,
1592            self.active_pointer_id(),
1593            Some(target),
1594            kind,
1595            pane_modifiers(key.modifiers),
1596        )
1597    }
1598
1599    fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1600        if focused {
1601            self.window_focused = true;
1602            return PaneTerminalDispatch::ignored(
1603                PaneTerminalLifecyclePhase::Other,
1604                PaneTerminalIgnoredReason::FocusGainNoop,
1605                self.active_pointer_id(),
1606                self.active.map(|active| active.target),
1607            );
1608        }
1609        self.window_focused = false;
1610        if !self.config.cancel_on_focus_lost {
1611            return PaneTerminalDispatch::ignored(
1612                PaneTerminalLifecyclePhase::FocusLoss,
1613                PaneTerminalIgnoredReason::ResizeNoop,
1614                self.active_pointer_id(),
1615                self.active.map(|active| active.target),
1616            );
1617        }
1618        self.cancel_active_dispatch(
1619            PaneTerminalLifecyclePhase::FocusLoss,
1620            PaneCancelReason::FocusLost,
1621            PaneTerminalIgnoredReason::NoActivePointer,
1622        )
1623    }
1624
1625    fn translate_resize(&mut self) -> PaneTerminalDispatch {
1626        if !self.config.cancel_on_resize {
1627            return PaneTerminalDispatch::ignored(
1628                PaneTerminalLifecyclePhase::ResizeInterrupt,
1629                PaneTerminalIgnoredReason::ResizeNoop,
1630                self.active_pointer_id(),
1631                self.active.map(|active| active.target),
1632            );
1633        }
1634        self.cancel_active_dispatch(
1635            PaneTerminalLifecyclePhase::ResizeInterrupt,
1636            PaneCancelReason::Programmatic,
1637            PaneTerminalIgnoredReason::ResizeNoop,
1638        )
1639    }
1640
1641    fn cancel_active_dispatch(
1642        &mut self,
1643        phase: PaneTerminalLifecyclePhase,
1644        reason: PaneCancelReason,
1645        no_active_reason: PaneTerminalIgnoredReason,
1646    ) -> PaneTerminalDispatch {
1647        let Some(active) = self.active else {
1648            return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1649        };
1650        let kind = PaneSemanticInputEventKind::Cancel {
1651            target: Some(active.target),
1652            reason,
1653        };
1654        let dispatch = self.forward_semantic(
1655            phase,
1656            Some(active.pointer_id),
1657            Some(active.target),
1658            kind,
1659            PaneModifierSnapshot::default(),
1660        );
1661        if dispatch.primary_transition.is_some() {
1662            self.active = None;
1663        }
1664        dispatch
1665    }
1666
1667    fn cancel_active_internal(
1668        &mut self,
1669        reason: PaneCancelReason,
1670    ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1671        let active = self.active?;
1672        let kind = PaneSemanticInputEventKind::Cancel {
1673            target: Some(active.target),
1674            reason,
1675        };
1676        let result = self
1677            .apply_semantic(kind, PaneModifierSnapshot::default())
1678            .ok();
1679        if result.is_some() {
1680            self.active = None;
1681        }
1682        result
1683    }
1684
1685    fn forward_semantic(
1686        &mut self,
1687        phase: PaneTerminalLifecyclePhase,
1688        pointer_id: Option<u32>,
1689        target: Option<PaneResizeTarget>,
1690        kind: PaneSemanticInputEventKind,
1691        modifiers: PaneModifierSnapshot,
1692    ) -> PaneTerminalDispatch {
1693        match self.apply_semantic(kind, modifiers) {
1694            Ok((event, transition)) => {
1695                PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1696            }
1697            Err(_) => PaneTerminalDispatch::ignored(
1698                phase,
1699                PaneTerminalIgnoredReason::MachineRejectedEvent,
1700                pointer_id,
1701                target,
1702            ),
1703        }
1704    }
1705
1706    fn apply_semantic(
1707        &mut self,
1708        kind: PaneSemanticInputEventKind,
1709        modifiers: PaneModifierSnapshot,
1710    ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1711    {
1712        let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1713        event.modifiers = modifiers;
1714        let transition = self.machine.apply_event(&event)?;
1715        Ok((event, transition))
1716    }
1717
1718    fn next_sequence(&mut self) -> u64 {
1719        let sequence = self.next_sequence;
1720        self.next_sequence = self.next_sequence.saturating_add(1);
1721        sequence
1722    }
1723
1724    fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1725        if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1726            return false;
1727        }
1728        let movement = delta_x
1729            .unsigned_abs()
1730            .saturating_add(delta_y.unsigned_abs());
1731        movement < u32::from(self.config.drag_update_coalesce_distance)
1732    }
1733
1734    /// Force-cancel any active pane interaction and return diagnostic info.
1735    ///
1736    /// This is the safety-valve for cleanup paths (RAII guard drops, signal
1737    /// handlers, panic hooks) where constructing a proper semantic event is
1738    /// not feasible. It resets both the underlying drag/resize state machine
1739    /// and the adapter's active-pointer tracking.
1740    ///
1741    /// Returns `None` if no interaction was active.
1742    pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1743        let was_active = self.active.is_some();
1744        let machine_state_before = self.machine.state();
1745        let machine_transition = self.machine.force_cancel();
1746        let active_pointer = self.active.take();
1747        if !was_active && machine_transition.is_none() {
1748            return None;
1749        }
1750        Some(PaneCleanupDiagnostics {
1751            had_active_pointer: was_active,
1752            active_pointer_id: active_pointer.map(|a| a.pointer_id),
1753            machine_state_before,
1754            machine_transition,
1755        })
1756    }
1757}
1758
1759/// Structured diagnostics emitted when pane interaction state is force-cleaned.
1760///
1761/// Fields mirror the pane layout types which are already `Serialize`/`Deserialize`,
1762/// so callers can convert this struct to JSON for evidence logging.
1763#[derive(Debug, Clone, PartialEq, Eq)]
1764pub struct PaneCleanupDiagnostics {
1765    /// Whether the adapter had an active pointer tracker when cleanup ran.
1766    pub had_active_pointer: bool,
1767    /// The pointer ID that was active (if any).
1768    pub active_pointer_id: Option<u32>,
1769    /// The machine state before force-cancel was applied.
1770    pub machine_state_before: PaneDragResizeState,
1771    /// The transition produced by force-cancel, or `None` if the machine
1772    /// was already idle.
1773    pub machine_transition: Option<PaneDragResizeTransition>,
1774}
1775
1776/// RAII guard that ensures pane interaction state is cleanly canceled on drop.
1777///
1778/// When a pane interaction session is active and the guard drops (due to
1779/// panic, scope exit, or any other unwind), it force-cancels any in-progress
1780/// drag/resize and collects cleanup diagnostics.
1781///
1782/// # Usage
1783///
1784/// ```ignore
1785/// let guard = PaneInteractionGuard::new(&mut adapter);
1786/// // ... pane interaction event loop ...
1787/// // If this scope panics, guard's Drop will force-cancel the drag machine
1788/// let diagnostics = guard.finish(); // explicit clean finish
1789/// ```
1790pub struct PaneInteractionGuard<'a> {
1791    adapter: &'a mut PaneTerminalAdapter,
1792    finished: bool,
1793    diagnostics: Option<PaneCleanupDiagnostics>,
1794}
1795
1796impl<'a> PaneInteractionGuard<'a> {
1797    /// Create a new guard wrapping the given adapter.
1798    pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1799        Self {
1800            adapter,
1801            finished: false,
1802            diagnostics: None,
1803        }
1804    }
1805
1806    /// Access the wrapped adapter for normal event translation.
1807    pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1808        self.adapter
1809    }
1810
1811    /// Explicitly finish the guard, returning any cleanup diagnostics.
1812    ///
1813    /// Calling `finish()` is optional — the guard will also clean up on drop.
1814    /// However, `finish()` gives the caller access to the diagnostics.
1815    pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1816        self.finished = true;
1817        let diagnostics = self.adapter.force_cancel_all();
1818        self.diagnostics = diagnostics.clone();
1819        diagnostics
1820    }
1821}
1822
1823impl Drop for PaneInteractionGuard<'_> {
1824    fn drop(&mut self) {
1825        if !self.finished {
1826            self.diagnostics = self.adapter.force_cancel_all();
1827        }
1828    }
1829}
1830
1831fn pane_button(button: MouseButton) -> PanePointerButton {
1832    match button {
1833        MouseButton::Left => PanePointerButton::Primary,
1834        MouseButton::Right => PanePointerButton::Secondary,
1835        MouseButton::Middle => PanePointerButton::Middle,
1836    }
1837}
1838
1839fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1840    match button {
1841        PanePointerButton::Primary => 1,
1842        PanePointerButton::Secondary => 2,
1843        PanePointerButton::Middle => 3,
1844    }
1845}
1846
1847fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1848    PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1849}
1850
1851fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1852    PaneModifierSnapshot {
1853        shift: modifiers.contains(Modifiers::SHIFT),
1854        alt: modifiers.contains(Modifiers::ALT),
1855        ctrl: modifiers.contains(Modifiers::CTRL),
1856        meta: modifiers.contains(Modifiers::SUPER),
1857    }
1858}
1859
1860fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1861    match (axis, code) {
1862        (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1863        (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1864        (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1865        (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1866        (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1867        (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1868        _ => None,
1869    }
1870}
1871
1872fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1873    if modifiers.contains(Modifiers::SHIFT) {
1874        5
1875    } else {
1876        1
1877    }
1878}
1879
1880/// Configuration for state persistence in the program runtime.
1881///
1882/// Controls when and how widget state is saved/restored.
1883#[derive(Clone)]
1884pub struct PersistenceConfig {
1885    /// State registry for persistence. If None, persistence is disabled.
1886    pub registry: Option<std::sync::Arc<StateRegistry>>,
1887    /// Interval for periodic checkpoint saves. None disables checkpoints.
1888    pub checkpoint_interval: Option<Duration>,
1889    /// Automatically load state on program start.
1890    pub auto_load: bool,
1891    /// Automatically save state on program exit.
1892    pub auto_save: bool,
1893}
1894
1895impl Default for PersistenceConfig {
1896    fn default() -> Self {
1897        Self {
1898            registry: None,
1899            checkpoint_interval: None,
1900            auto_load: true,
1901            auto_save: true,
1902        }
1903    }
1904}
1905
1906impl std::fmt::Debug for PersistenceConfig {
1907    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1908        f.debug_struct("PersistenceConfig")
1909            .field(
1910                "registry",
1911                &self.registry.as_ref().map(|r| r.backend_name()),
1912            )
1913            .field("checkpoint_interval", &self.checkpoint_interval)
1914            .field("auto_load", &self.auto_load)
1915            .field("auto_save", &self.auto_save)
1916            .finish()
1917    }
1918}
1919
1920impl PersistenceConfig {
1921    /// Create a disabled persistence config.
1922    #[must_use]
1923    pub fn disabled() -> Self {
1924        Self::default()
1925    }
1926
1927    /// Create a persistence config with the given registry.
1928    #[must_use]
1929    pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1930        Self {
1931            registry: Some(registry),
1932            ..Default::default()
1933        }
1934    }
1935
1936    /// Set the checkpoint interval.
1937    #[must_use]
1938    pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1939        self.checkpoint_interval = Some(interval);
1940        self
1941    }
1942
1943    /// Enable or disable auto-load on start.
1944    #[must_use]
1945    pub fn auto_load(mut self, enabled: bool) -> Self {
1946        self.auto_load = enabled;
1947        self
1948    }
1949
1950    /// Enable or disable auto-save on exit.
1951    #[must_use]
1952    pub fn auto_save(mut self, enabled: bool) -> Self {
1953        self.auto_save = enabled;
1954        self
1955    }
1956}
1957
1958/// Configuration for widget refresh selection under render budget.
1959///
1960/// Defaults are conservative and deterministic:
1961/// - enabled: true
1962/// - staleness_window_ms: 1_000
1963/// - starve_ms: 3_000
1964/// - max_starved_per_frame: 2
1965/// - max_drop_fraction: 1.0 (disabled)
1966/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
1967/// - starve_boost: 1.5
1968/// - min_cost_us: 1.0
1969#[derive(Debug, Clone)]
1970pub struct WidgetRefreshConfig {
1971    /// Enable budgeted widget refresh selection.
1972    pub enabled: bool,
1973    /// Staleness decay window (ms) used to normalize staleness scores.
1974    pub staleness_window_ms: u64,
1975    /// Staleness threshold that triggers starvation guard (ms).
1976    pub starve_ms: u64,
1977    /// Maximum number of starved widgets to force in per frame.
1978    pub max_starved_per_frame: usize,
1979    /// Maximum fraction of non-essential widgets that may be dropped.
1980    /// Set to 1.0 to disable the guardrail.
1981    pub max_drop_fraction: f32,
1982    /// Weight for base priority signal.
1983    pub weight_priority: f32,
1984    /// Weight for staleness signal.
1985    pub weight_staleness: f32,
1986    /// Weight for focus boost.
1987    pub weight_focus: f32,
1988    /// Weight for interaction boost.
1989    pub weight_interaction: f32,
1990    /// Additive boost to value for starved widgets.
1991    pub starve_boost: f32,
1992    /// Minimum cost (us) to avoid divide-by-zero.
1993    pub min_cost_us: f32,
1994}
1995
1996impl Default for WidgetRefreshConfig {
1997    fn default() -> Self {
1998        Self {
1999            enabled: true,
2000            staleness_window_ms: 1_000,
2001            starve_ms: 3_000,
2002            max_starved_per_frame: 2,
2003            max_drop_fraction: 1.0,
2004            weight_priority: 1.0,
2005            weight_staleness: 0.5,
2006            weight_focus: 0.75,
2007            weight_interaction: 0.5,
2008            starve_boost: 1.5,
2009            min_cost_us: 1.0,
2010        }
2011    }
2012}
2013
2014/// Configuration for effect queue scheduling.
2015#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2016pub enum TaskExecutorBackend {
2017    /// Spawn one native thread per task and reap finished handles on the main loop.
2018    #[default]
2019    Spawned,
2020    /// Route tasks through the runtime's queueing scheduler.
2021    EffectQueue,
2022    /// Route blocking task closures through an Asupersync blocking pool.
2023    #[cfg(feature = "asupersync-executor")]
2024    Asupersync,
2025}
2026
2027#[derive(Debug, Clone)]
2028pub struct EffectQueueConfig {
2029    /// Whether effect queue scheduling is enabled.
2030    ///
2031    /// This legacy convenience flag is kept in sync with `backend`. New code
2032    /// should prefer `backend` for executor selection.
2033    pub enabled: bool,
2034    /// Which task executor backend to use for `Cmd::Task`.
2035    pub backend: TaskExecutorBackend,
2036    /// Scheduler configuration (Smith's rule by default).
2037    pub scheduler: SchedulerConfig,
2038    /// Maximum queue depth before backpressure kicks in (bd-2zd0a).
2039    ///
2040    /// When the queue depth exceeds this limit, new tasks are dropped with
2041    /// a `tracing::warn!` and the `effects_queue_dropped` counter increments.
2042    /// A value of `0` means unbounded (no backpressure).
2043    pub max_queue_depth: usize,
2044    /// Whether the backend selection was set explicitly by the caller.
2045    explicit_backend: bool,
2046}
2047
2048impl Default for EffectQueueConfig {
2049    fn default() -> Self {
2050        let scheduler = SchedulerConfig {
2051            smith_enabled: true,
2052            force_fifo: false,
2053            preemptive: false,
2054            aging_factor: 0.0,
2055            wait_starve_ms: 0.0,
2056            enable_logging: false,
2057            ..Default::default()
2058        };
2059        Self {
2060            enabled: false,
2061            backend: TaskExecutorBackend::Spawned,
2062            scheduler,
2063            max_queue_depth: 0,
2064            explicit_backend: false,
2065        }
2066    }
2067}
2068
2069impl EffectQueueConfig {
2070    /// Enable effect queue scheduling with the provided scheduler config.
2071    #[must_use]
2072    pub fn with_enabled(mut self, enabled: bool) -> Self {
2073        self.enabled = enabled;
2074        self.backend = if enabled {
2075            TaskExecutorBackend::EffectQueue
2076        } else {
2077            TaskExecutorBackend::Spawned
2078        };
2079        self.explicit_backend = true;
2080        self
2081    }
2082
2083    /// Select the task executor backend for `Cmd::Task`.
2084    #[must_use]
2085    pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2086        self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2087        self.backend = backend;
2088        self.explicit_backend = true;
2089        self
2090    }
2091
2092    /// Override the scheduler configuration.
2093    #[must_use]
2094    pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2095        self.scheduler = scheduler;
2096        self
2097    }
2098
2099    /// Set the maximum queue depth for backpressure (bd-2zd0a).
2100    ///
2101    /// When the queue depth exceeds this limit, new tasks are dropped.
2102    /// A value of `0` means unbounded (no backpressure, the default).
2103    #[must_use]
2104    pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2105        self.max_queue_depth = depth;
2106        self
2107    }
2108
2109    #[must_use]
2110    fn uses_legacy_default_backend(&self) -> bool {
2111        !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2112    }
2113}
2114
2115/// Immediate event-drain policy for the runtime main loop.
2116///
2117/// When a poll reports readiness, the runtime drains events by repeatedly
2118/// checking `poll_event(Duration::ZERO)` to avoid latency between buffered
2119/// inputs. This policy bounds that immediate-drain path so bursty workloads do
2120/// not devolve into zero-timeout spin storms.
2121#[derive(Debug, Clone)]
2122pub struct ImmediateDrainConfig {
2123    /// Maximum consecutive zero-timeout polls allowed in a single burst window.
2124    pub max_zero_timeout_polls_per_burst: usize,
2125    /// Maximum wall-clock time spent in a single immediate-drain burst window.
2126    pub max_burst_duration: Duration,
2127    /// Non-zero poll timeout used when the burst window budget is exhausted.
2128    pub backoff_timeout: Duration,
2129}
2130
2131impl Default for ImmediateDrainConfig {
2132    fn default() -> Self {
2133        Self {
2134            max_zero_timeout_polls_per_burst: 64,
2135            max_burst_duration: Duration::from_millis(2),
2136            backoff_timeout: Duration::from_millis(1),
2137        }
2138    }
2139}
2140
2141/// Runtime counters for immediate-drain behavior.
2142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2143pub struct ImmediateDrainStats {
2144    /// Number of event-drain bursts observed.
2145    pub bursts: u64,
2146    /// Total zero-timeout polls executed (`poll_event(Duration::ZERO)`).
2147    pub zero_timeout_polls: u64,
2148    /// Total non-zero backoff polls executed after exhausting burst budget.
2149    pub backoff_polls: u64,
2150    /// Number of bursts that hit the configured immediate-drain cap.
2151    pub capped_bursts: u64,
2152    /// Max number of zero-timeout polls seen in a single burst window.
2153    pub max_zero_timeout_polls_in_burst: u64,
2154}
2155
2156/// User-visible runtime mode reported by the conservative load governor.
2157///
2158/// These modes mirror the `bd-8vstx` runtime contract:
2159///
2160/// - `Healthy`: admit all work normally; no explicit fallback is active.
2161/// - `Stressed`: strict work is preserved while visible/coalescible or
2162///   background work may be slowed before user-visible ambiguity appears.
2163/// - `Degraded`: bounded fallback is active; strict interactive semantics
2164///   still hold while background/best-effort work is deferred or dropped.
2165/// - `Recovered`: a degraded interval has closed after the configured
2166///   hysteresis window; the next steady interval returns to `Healthy`.
2167/// - `Unsafe`: a strict semantic guarantee cannot be preserved, so optimistic
2168///   degradation must stop and the caller must surface explicit failure.
2169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2170pub enum RuntimeLoadMode {
2171    /// No sustained pressure and no active fallback.
2172    Healthy,
2173    /// Elevated pressure while strict guarantees still hold.
2174    Stressed,
2175    /// Explicit bounded fallback is active.
2176    Degraded,
2177    /// Recovery interval has been observed and reported.
2178    Recovered,
2179    /// A strict semantic guarantee was violated.
2180    Unsafe,
2181}
2182
2183impl RuntimeLoadMode {
2184    /// Stable string for evidence logs.
2185    #[inline]
2186    #[must_use]
2187    pub const fn as_str(self) -> &'static str {
2188        match self {
2189            Self::Healthy => "healthy",
2190            Self::Stressed => "stressed",
2191            Self::Degraded => "degraded",
2192            Self::Recovered => "recovered",
2193            Self::Unsafe => "unsafe",
2194        }
2195    }
2196}
2197
2198/// Pressure envelope inferred from measured runtime signals.
2199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2200pub enum RuntimePressureClass {
2201    /// Within steady-state envelope.
2202    SteadyState,
2203    /// Early overload band; bounded coalescing/deferment may begin.
2204    SoftOverload,
2205    /// Degraded band; explicit fallback is active.
2206    HardOverload,
2207    /// Terminal strict-semantics failure.
2208    Unsafe,
2209}
2210
2211impl RuntimePressureClass {
2212    /// Stable string for evidence logs.
2213    #[inline]
2214    #[must_use]
2215    pub const fn as_str(self) -> &'static str {
2216        match self {
2217            Self::SteadyState => "steady_state",
2218            Self::SoftOverload => "soft_overload",
2219            Self::HardOverload => "hard_overload",
2220            Self::Unsafe => "unsafe",
2221        }
2222    }
2223}
2224
2225/// Work disposition allowed by the current runtime mode.
2226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2227pub enum RuntimeWorkDisposition {
2228    /// Admit all work normally.
2229    AdmitAll,
2230    /// Preserve strict work; coalesce visible work and slow background work.
2231    CoalesceVisibleDeferBackground,
2232    /// Preserve strict work; defer background work and drop best-effort work.
2233    DeferBackgroundDropBestEffort,
2234    /// Re-admit deferred work after the hysteresis window closed.
2235    ReadmitAfterHysteresis,
2236    /// Stop optimistic degradation because a strict guarantee failed.
2237    FailFastStrictGuarantee,
2238}
2239
2240impl RuntimeWorkDisposition {
2241    /// Stable string for evidence logs.
2242    #[inline]
2243    #[must_use]
2244    pub const fn as_str(self) -> &'static str {
2245        match self {
2246            Self::AdmitAll => "admit_all",
2247            Self::CoalesceVisibleDeferBackground => "coalesce_visible_defer_background",
2248            Self::DeferBackgroundDropBestEffort => "defer_background_drop_best_effort",
2249            Self::ReadmitAfterHysteresis => "readmit_after_hysteresis",
2250            Self::FailFastStrictGuarantee => "fail_fast_strict_guarantee",
2251        }
2252    }
2253}
2254
2255/// Conservative policy for classifying runtime load.
2256///
2257/// The first runtime governor intentionally uses one primary control family:
2258/// render-budget degradation and explicit coalescing/admission evidence. Queue
2259/// watermarks are advisory inputs when a queue cap exists; when the queue is
2260/// uncapped, frame-budget and coalescer signals drive fallback classification.
2261#[derive(Debug, Clone, Copy, PartialEq)]
2262pub struct LoadGovernorPolicy {
2263    /// Queue occupancy ratio that enters `stressed`.
2264    pub stressed_queue_watermark: f64,
2265    /// Queue occupancy ratio that enters `degraded`.
2266    pub degraded_queue_watermark: f64,
2267    /// Queue occupancy ratio required before closing a degraded interval.
2268    pub recovery_queue_watermark: f64,
2269    /// Consecutive steady intervals required before reporting recovery.
2270    pub recovery_intervals: u8,
2271    /// Frame-time ratio over budget that enters `stressed` when uncapped.
2272    pub budget_overrun_soft_ratio: f64,
2273}
2274
2275impl Default for LoadGovernorPolicy {
2276    fn default() -> Self {
2277        Self {
2278            stressed_queue_watermark: 0.5,
2279            degraded_queue_watermark: 0.8,
2280            recovery_queue_watermark: 0.25,
2281            recovery_intervals: 3,
2282            budget_overrun_soft_ratio: 1.0,
2283        }
2284    }
2285}
2286
2287impl LoadGovernorPolicy {
2288    #[must_use]
2289    fn normalized(self) -> Self {
2290        let recovery = normalize_ratio(self.recovery_queue_watermark, 0.25);
2291        let stressed = normalize_ratio(self.stressed_queue_watermark, 0.5).max(recovery);
2292        let degraded = normalize_ratio(self.degraded_queue_watermark, 0.8).max(stressed);
2293        Self {
2294            recovery_queue_watermark: recovery,
2295            stressed_queue_watermark: stressed,
2296            degraded_queue_watermark: degraded,
2297            recovery_intervals: self.recovery_intervals.max(1),
2298            budget_overrun_soft_ratio: normalize_positive_ratio(
2299                self.budget_overrun_soft_ratio,
2300                1.0,
2301            ),
2302        }
2303    }
2304}
2305
2306#[inline]
2307fn normalize_ratio(value: f64, fallback: f64) -> f64 {
2308    if value.is_finite() {
2309        value.clamp(0.0, 1.0)
2310    } else {
2311        fallback
2312    }
2313}
2314
2315#[inline]
2316fn normalize_positive_ratio(value: f64, fallback: f64) -> f64 {
2317    if value.is_finite() && value > 0.0 {
2318        value
2319    } else {
2320        fallback
2321    }
2322}
2323
2324/// Conservative runtime load-governor configuration.
2325///
2326/// The first runtime governor keeps one primary control objective:
2327/// responsiveness under render pressure. It drives adaptive render degradation
2328/// through `RenderBudget`, classifies runtime mode with queue/coalescer inputs,
2329/// and emits replayable evidence for the allowed work disposition. Disabling
2330/// this config falls back to the legacy threshold path.
2331#[derive(Debug, Clone, PartialEq)]
2332pub struct LoadGovernorConfig {
2333    /// Whether the adaptive governor is active.
2334    pub enabled: bool,
2335    /// Controller used to decide degrade/upgrade transitions from frame timing.
2336    pub budget_controller: BudgetControllerConfig,
2337    /// Runtime-mode and work-disposition classification policy.
2338    pub policy: LoadGovernorPolicy,
2339}
2340
2341impl Default for LoadGovernorConfig {
2342    fn default() -> Self {
2343        Self {
2344            enabled: true,
2345            budget_controller: BudgetControllerConfig::default(),
2346            policy: LoadGovernorPolicy::default(),
2347        }
2348    }
2349}
2350
2351impl LoadGovernorConfig {
2352    /// Create an enabled governor with conservative defaults.
2353    #[must_use]
2354    pub fn enabled() -> Self {
2355        Self::default()
2356    }
2357
2358    /// Disable the governor and use the legacy render-budget threshold path.
2359    #[must_use]
2360    pub fn disabled() -> Self {
2361        Self {
2362            enabled: false,
2363            budget_controller: BudgetControllerConfig::default(),
2364            policy: LoadGovernorPolicy::default(),
2365        }
2366    }
2367
2368    /// Toggle governor activation.
2369    #[must_use]
2370    pub fn with_enabled(mut self, enabled: bool) -> Self {
2371        self.enabled = enabled;
2372        self
2373    }
2374
2375    /// Replace the adaptive budget controller configuration.
2376    #[must_use]
2377    pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
2378        self.budget_controller = config;
2379        self
2380    }
2381
2382    /// Replace the runtime-mode classification policy.
2383    #[must_use]
2384    pub fn with_policy(mut self, policy: LoadGovernorPolicy) -> Self {
2385        self.policy = policy.normalized();
2386        self
2387    }
2388}
2389
2390#[derive(Debug, Clone, Copy)]
2391struct LoadGovernorObservation {
2392    frame_time_us: f64,
2393    budget_us: f64,
2394    degradation: DegradationLevel,
2395    queue: crate::effect_system::QueueTelemetry,
2396    resize_coalescing_active: bool,
2397    strict_semantics_violation: bool,
2398}
2399
2400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2401struct LoadGovernorSnapshot {
2402    mode: RuntimeLoadMode,
2403    mode_before: RuntimeLoadMode,
2404    pressure_class: RuntimePressureClass,
2405    disposition: RuntimeWorkDisposition,
2406    reason_code: &'static str,
2407    transition: bool,
2408    strict_semantics_preserved: bool,
2409    queue_in_flight: u64,
2410    queue_max_depth: Option<usize>,
2411    queue_dropped_delta: u64,
2412    resize_coalescing_active: bool,
2413    recovery_intervals_observed: u8,
2414    recovery_intervals_required: u8,
2415    deferred_work_total: u64,
2416    coalesced_work_total: u64,
2417    dropped_work_total: u64,
2418}
2419
2420#[derive(Debug, Clone)]
2421struct LoadGovernorState {
2422    enabled: bool,
2423    policy: LoadGovernorPolicy,
2424    max_queue_depth: usize,
2425    mode: RuntimeLoadMode,
2426    recovery_intervals_observed: u8,
2427    last_queue_dropped: u64,
2428    queue_baseline_initialized: bool,
2429    deferred_work_total: u64,
2430    coalesced_work_total: u64,
2431    dropped_work_total: u64,
2432}
2433
2434impl LoadGovernorState {
2435    fn new(config: LoadGovernorConfig, max_queue_depth: usize) -> Self {
2436        Self {
2437            enabled: config.enabled,
2438            policy: config.policy.normalized(),
2439            max_queue_depth,
2440            mode: RuntimeLoadMode::Healthy,
2441            recovery_intervals_observed: 0,
2442            last_queue_dropped: 0,
2443            queue_baseline_initialized: false,
2444            deferred_work_total: 0,
2445            coalesced_work_total: 0,
2446            dropped_work_total: 0,
2447        }
2448    }
2449
2450    fn observe(&mut self, observation: LoadGovernorObservation) -> LoadGovernorSnapshot {
2451        if !self.enabled {
2452            return self.snapshot(
2453                RuntimeLoadMode::Healthy,
2454                RuntimeLoadMode::Healthy,
2455                RuntimePressureClass::SteadyState,
2456                RuntimeWorkDisposition::AdmitAll,
2457                "governor_disabled",
2458                false,
2459                true,
2460                observation,
2461                0,
2462            );
2463        }
2464
2465        let dropped_delta = self.queue_dropped_delta(observation.queue.dropped);
2466        let pressure = self.classify_pressure(observation, dropped_delta);
2467        let mode_before = self.mode;
2468        let reason_code = self.reason_code(observation, pressure, dropped_delta);
2469
2470        match pressure {
2471            RuntimePressureClass::Unsafe => {
2472                self.mode = RuntimeLoadMode::Unsafe;
2473                self.recovery_intervals_observed = 0;
2474            }
2475            // Strict-semantics failure is terminal: once Unsafe is entered the
2476            // governor latches there regardless of later pressure (this mirrors
2477            // the `Unsafe => {}` arm in `observe_steady_interval`, which already
2478            // refuses to recover Unsafe under steady load). Without this guard,
2479            // a subsequent overload interval would downgrade Unsafe to
2480            // Degraded/Stressed and then recover to Healthy, silently escaping
2481            // the fail-fast guarantee. Only an explicit reset clears Unsafe.
2482            _ if self.mode == RuntimeLoadMode::Unsafe => {
2483                self.recovery_intervals_observed = 0;
2484            }
2485            RuntimePressureClass::HardOverload => {
2486                self.mode = RuntimeLoadMode::Degraded;
2487                self.recovery_intervals_observed = 0;
2488            }
2489            RuntimePressureClass::SoftOverload => {
2490                if self.mode != RuntimeLoadMode::Degraded {
2491                    self.mode = RuntimeLoadMode::Stressed;
2492                }
2493                self.recovery_intervals_observed = 0;
2494            }
2495            RuntimePressureClass::SteadyState => self.observe_steady_interval(),
2496        }
2497
2498        self.record_work_disposition(observation, dropped_delta);
2499        let disposition = Self::disposition_for_mode(self.mode);
2500        self.snapshot(
2501            self.mode,
2502            mode_before,
2503            pressure,
2504            disposition,
2505            if self.mode == RuntimeLoadMode::Unsafe {
2506                // Whether freshly violated this interval or latched from a prior
2507                // one, the terminal cause is always the strict-semantics failure.
2508                "strict_semantics_violation"
2509            } else if self.mode == RuntimeLoadMode::Recovered {
2510                "recovery_hysteresis_satisfied"
2511            } else if mode_before == RuntimeLoadMode::Recovered
2512                && self.mode == RuntimeLoadMode::Healthy
2513            {
2514                "recovered_interval_closed"
2515            } else {
2516                reason_code
2517            },
2518            mode_before != self.mode,
2519            // Reflect the latched mode, not the instantaneous pressure: once the
2520            // governor is Unsafe, strict semantics stay unpreserved even on an
2521            // interval whose raw pressure classified lower.
2522            self.mode != RuntimeLoadMode::Unsafe,
2523            observation,
2524            dropped_delta,
2525        )
2526    }
2527
2528    fn queue_dropped_delta(&mut self, dropped_total: u64) -> u64 {
2529        if !self.queue_baseline_initialized {
2530            self.queue_baseline_initialized = true;
2531            self.last_queue_dropped = dropped_total;
2532            return 0;
2533        }
2534        let delta = dropped_total.saturating_sub(self.last_queue_dropped);
2535        self.last_queue_dropped = dropped_total;
2536        delta
2537    }
2538
2539    fn classify_pressure(
2540        &self,
2541        observation: LoadGovernorObservation,
2542        dropped_delta: u64,
2543    ) -> RuntimePressureClass {
2544        if observation.strict_semantics_violation {
2545            return RuntimePressureClass::Unsafe;
2546        }
2547        if dropped_delta > 0
2548            || self
2549                .queue_ratio(observation.queue.in_flight)
2550                .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark)
2551            || observation.degradation > DegradationLevel::Full
2552        {
2553            return RuntimePressureClass::HardOverload;
2554        }
2555        if self
2556            .queue_ratio(observation.queue.in_flight)
2557            .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark)
2558            || observation.resize_coalescing_active
2559            || observation.frame_time_us
2560                > observation.budget_us * self.policy.budget_overrun_soft_ratio
2561        {
2562            return RuntimePressureClass::SoftOverload;
2563        }
2564        RuntimePressureClass::SteadyState
2565    }
2566
2567    fn observe_steady_interval(&mut self) {
2568        match self.mode {
2569            RuntimeLoadMode::Degraded => {
2570                self.recovery_intervals_observed = self
2571                    .recovery_intervals_observed
2572                    .saturating_add(1)
2573                    .min(self.policy.recovery_intervals);
2574                if self.recovery_intervals_observed >= self.policy.recovery_intervals {
2575                    self.mode = RuntimeLoadMode::Recovered;
2576                }
2577            }
2578            RuntimeLoadMode::Recovered | RuntimeLoadMode::Stressed => {
2579                self.mode = RuntimeLoadMode::Healthy;
2580                self.recovery_intervals_observed = 0;
2581            }
2582            RuntimeLoadMode::Healthy => {
2583                self.recovery_intervals_observed = 0;
2584            }
2585            RuntimeLoadMode::Unsafe => {}
2586        }
2587    }
2588
2589    fn record_work_disposition(
2590        &mut self,
2591        observation: LoadGovernorObservation,
2592        dropped_delta: u64,
2593    ) {
2594        if dropped_delta > 0 {
2595            self.dropped_work_total = self.dropped_work_total.saturating_add(dropped_delta);
2596        }
2597        match self.mode {
2598            RuntimeLoadMode::Stressed => {
2599                if observation.resize_coalescing_active {
2600                    self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2601                }
2602            }
2603            RuntimeLoadMode::Degraded => {
2604                self.deferred_work_total = self.deferred_work_total.saturating_add(1);
2605                if observation.resize_coalescing_active {
2606                    self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2607                }
2608            }
2609            RuntimeLoadMode::Healthy | RuntimeLoadMode::Recovered | RuntimeLoadMode::Unsafe => {}
2610        }
2611    }
2612
2613    fn reason_code(
2614        &self,
2615        observation: LoadGovernorObservation,
2616        pressure: RuntimePressureClass,
2617        dropped_delta: u64,
2618    ) -> &'static str {
2619        match pressure {
2620            RuntimePressureClass::Unsafe => "strict_semantics_violation",
2621            RuntimePressureClass::HardOverload if dropped_delta > 0 => "effect_queue_drop",
2622            RuntimePressureClass::HardOverload
2623                if self
2624                    .queue_ratio(observation.queue.in_flight)
2625                    .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark) =>
2626            {
2627                "queue_degraded_watermark"
2628            }
2629            RuntimePressureClass::HardOverload => "budget_degradation_active",
2630            RuntimePressureClass::SoftOverload
2631                if self
2632                    .queue_ratio(observation.queue.in_flight)
2633                    .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark) =>
2634            {
2635                "queue_stressed_watermark"
2636            }
2637            RuntimePressureClass::SoftOverload if observation.resize_coalescing_active => {
2638                "resize_coalescing_active"
2639            }
2640            RuntimePressureClass::SoftOverload => "frame_budget_overrun",
2641            RuntimePressureClass::SteadyState if self.mode == RuntimeLoadMode::Degraded => {
2642                "recovery_hysteresis_pending"
2643            }
2644            RuntimePressureClass::SteadyState => "steady_state",
2645        }
2646    }
2647
2648    fn queue_ratio(&self, in_flight: u64) -> Option<f64> {
2649        (self.max_queue_depth > 0).then(|| in_flight as f64 / self.max_queue_depth as f64)
2650    }
2651
2652    const fn disposition_for_mode(mode: RuntimeLoadMode) -> RuntimeWorkDisposition {
2653        match mode {
2654            RuntimeLoadMode::Healthy => RuntimeWorkDisposition::AdmitAll,
2655            RuntimeLoadMode::Stressed => RuntimeWorkDisposition::CoalesceVisibleDeferBackground,
2656            RuntimeLoadMode::Degraded => RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
2657            RuntimeLoadMode::Recovered => RuntimeWorkDisposition::ReadmitAfterHysteresis,
2658            RuntimeLoadMode::Unsafe => RuntimeWorkDisposition::FailFastStrictGuarantee,
2659        }
2660    }
2661
2662    // Internal constructor that gathers all snapshot fields in one place; the
2663    // wide signature is intentional (a builder would add indirection for a
2664    // private helper). Pre-existing lint, unrelated to the #78 fix.
2665    #[allow(clippy::too_many_arguments)]
2666    fn snapshot(
2667        &self,
2668        mode: RuntimeLoadMode,
2669        mode_before: RuntimeLoadMode,
2670        pressure_class: RuntimePressureClass,
2671        disposition: RuntimeWorkDisposition,
2672        reason_code: &'static str,
2673        transition: bool,
2674        strict_semantics_preserved: bool,
2675        observation: LoadGovernorObservation,
2676        dropped_delta: u64,
2677    ) -> LoadGovernorSnapshot {
2678        LoadGovernorSnapshot {
2679            mode,
2680            mode_before,
2681            pressure_class,
2682            disposition,
2683            reason_code,
2684            transition,
2685            strict_semantics_preserved,
2686            queue_in_flight: observation.queue.in_flight,
2687            queue_max_depth: (self.max_queue_depth > 0).then_some(self.max_queue_depth),
2688            queue_dropped_delta: dropped_delta,
2689            resize_coalescing_active: observation.resize_coalescing_active,
2690            recovery_intervals_observed: self.recovery_intervals_observed,
2691            recovery_intervals_required: self.policy.recovery_intervals,
2692            deferred_work_total: self.deferred_work_total,
2693            coalesced_work_total: self.coalesced_work_total,
2694            dropped_work_total: self.dropped_work_total,
2695        }
2696    }
2697}
2698
2699/// Runtime lane for the Asupersync migration rollout.
2700///
2701/// Controls which subscription/effect execution backend is active.
2702/// The default is `Structured`, reflecting the completed CancellationToken migration (bd-3tmu4).
2703///
2704/// # Migration rollout
2705///
2706/// 1. `Legacy` — pre-migration thread-based subscriptions with manual stop coordination
2707/// 2. `Structured` — CancellationToken-backed subscriptions (current default after bd-3tmu4)
2708/// 3. `Asupersync` — full Asupersync-native execution (future)
2709///
2710/// Selection is logged at startup so operators can tell which lane is active.
2711/// Fallback from `Asupersync` → `Structured` → `Legacy` is automatic on error.
2712#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2713pub enum RuntimeLane {
2714    /// Pre-migration behavior: thread-based subscriptions with manual stop coordination.
2715    /// This is the safe default that preserves all existing semantics.
2716    Legacy,
2717    /// Structured cancellation: subscriptions use CancellationToken internally.
2718    /// Externally observable behavior is identical to Legacy.
2719    #[default]
2720    Structured,
2721    /// Full Asupersync-native execution (reserved for future use).
2722    /// Falls back to Structured if Asupersync primitives are unavailable.
2723    Asupersync,
2724}
2725
2726impl RuntimeLane {
2727    /// Resolve the effective lane, applying fallback rules.
2728    ///
2729    /// If the requested lane is not yet implemented, falls back to the
2730    /// highest available lane. Currently: Asupersync → Structured.
2731    #[must_use]
2732    pub fn resolve(self) -> Self {
2733        match self {
2734            Self::Asupersync => {
2735                tracing::info!(
2736                    target: "ftui.runtime",
2737                    requested = "asupersync",
2738                    resolved = "structured",
2739                    "Asupersync lane not yet available; falling back to structured cancellation"
2740                );
2741                Self::Structured
2742            }
2743            other => other,
2744        }
2745    }
2746
2747    /// Returns a human-readable label for logging.
2748    #[must_use]
2749    pub fn label(self) -> &'static str {
2750        match self {
2751            Self::Legacy => "legacy",
2752            Self::Structured => "structured",
2753            Self::Asupersync => "asupersync",
2754        }
2755    }
2756
2757    /// Check if this lane uses structured cancellation (CancellationToken).
2758    #[must_use]
2759    pub fn uses_structured_cancellation(self) -> bool {
2760        matches!(self, Self::Structured | Self::Asupersync)
2761    }
2762
2763    /// Resolve the default task executor backend for this lane.
2764    ///
2765    /// # Input-lag regression fix (#78)
2766    ///
2767    /// The `Structured` lane changes *subscription cancellation* semantics
2768    /// (CancellationToken-backed stop signals in `subscription.rs`); it must
2769    /// NOT also collapse `Cmd::Task` concurrency. Earlier this returned
2770    /// `EffectQueue`, which routed every `Cmd::Task` through a single
2771    /// `effect_queue_loop` worker thread with a Smith's-rule (SPT) scheduler,
2772    /// serializing all tasks. Apps that forward PTY output via per-pane
2773    /// `Cmd::task` polling loops (e.g. 10ms drains) then contended for one
2774    /// serialized worker, adding per-keystroke head-of-line latency versus
2775    /// v0.2.1, where each `Cmd::task` got its own `std::thread::spawn`.
2776    ///
2777    /// Structured cancellation does not depend on the effect queue (stop
2778    /// signals are token-backed regardless of executor backend), so the two
2779    /// concerns are decoupled here: `Structured` keeps the structured
2780    /// cancellation semantics but restores per-task-thread (`Spawned`)
2781    /// execution. Apps that explicitly opt into `EffectQueue` still get it
2782    /// (the lane default only applies when the app uses the legacy default
2783    /// backend — see `EffectQueueConfig::uses_legacy_default_backend`).
2784    #[must_use]
2785    fn task_executor_backend(self) -> TaskExecutorBackend {
2786        match self {
2787            // Legacy and Structured both use per-task `Spawned` execution.
2788            // (Structured only changes cancellation semantics, not concurrency
2789            // — see the regression note above.) Kept as a combined arm so the
2790            // `match_same_arms` lint stays happy under `-D warnings`.
2791            Self::Legacy | Self::Structured => TaskExecutorBackend::Spawned,
2792            Self::Asupersync => {
2793                #[cfg(feature = "asupersync-executor")]
2794                {
2795                    TaskExecutorBackend::Asupersync
2796                }
2797                #[cfg(not(feature = "asupersync-executor"))]
2798                {
2799                    TaskExecutorBackend::EffectQueue
2800                }
2801            }
2802        }
2803    }
2804
2805    /// Read the lane from the `FTUI_RUNTIME_LANE` environment variable.
2806    ///
2807    /// Accepted values (case-insensitive): `legacy`, `structured`, `asupersync`.
2808    /// Returns `None` if the variable is unset or contains an unrecognized value.
2809    #[must_use]
2810    pub fn from_env() -> Option<Self> {
2811        let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2812        Self::parse(&val)
2813    }
2814
2815    /// Parse a lane name (case-insensitive).
2816    ///
2817    /// Returns `None` for unrecognized values.
2818    #[must_use]
2819    pub fn parse(s: &str) -> Option<Self> {
2820        match s.to_ascii_lowercase().as_str() {
2821            "legacy" => Some(Self::Legacy),
2822            "structured" => Some(Self::Structured),
2823            "asupersync" => Some(Self::Asupersync),
2824            _ => {
2825                tracing::warn!(
2826                    target: "ftui.runtime",
2827                    value = s,
2828                    "RuntimeLane::parse: unrecognized value"
2829                );
2830                None
2831            }
2832        }
2833    }
2834}
2835
2836/// Rollout policy for the Asupersync migration (bd-2crbt).
2837///
2838/// Controls how the runtime lane transition is managed:
2839///
2840/// - `Off` — use only the configured lane, no shadow comparison.
2841/// - `Shadow` — run both baseline and candidate lanes, compare outputs,
2842///   but use only the baseline lane for actual rendering. Evidence is emitted
2843///   to the configured JSONL sink for operator review.
2844/// - `Enabled` — use the candidate lane for rendering (requires prior shadow
2845///   evidence showing deterministic match).
2846///
2847/// The policy is logged at startup and can be overridden via the
2848/// `FTUI_ROLLOUT_POLICY` environment variable (`off`, `shadow`, `enabled`).
2849#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2850pub enum RolloutPolicy {
2851    /// No rollout activity — use the configured lane directly.
2852    #[default]
2853    Off,
2854    /// Shadow-run comparison mode: run both lanes, emit evidence, use baseline.
2855    Shadow,
2856    /// Candidate lane is live — requires prior shadow evidence.
2857    Enabled,
2858}
2859
2860impl RolloutPolicy {
2861    /// Read the policy from the `FTUI_ROLLOUT_POLICY` environment variable.
2862    ///
2863    /// Accepted values (case-insensitive): `off`, `shadow`, `enabled`.
2864    /// Returns `None` if unset or unrecognized.
2865    #[must_use]
2866    pub fn from_env() -> Option<Self> {
2867        let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2868        Self::parse(&val)
2869    }
2870
2871    /// Parse a rollout policy name (case-insensitive).
2872    ///
2873    /// Returns `None` for unrecognized values.
2874    #[must_use]
2875    pub fn parse(s: &str) -> Option<Self> {
2876        match s.to_ascii_lowercase().as_str() {
2877            "off" => Some(Self::Off),
2878            "shadow" => Some(Self::Shadow),
2879            "enabled" => Some(Self::Enabled),
2880            _ => {
2881                tracing::warn!(
2882                    target: "ftui.runtime",
2883                    value = s,
2884                    "RolloutPolicy::parse: unrecognized value"
2885                );
2886                None
2887            }
2888        }
2889    }
2890
2891    /// Returns a human-readable label for logging.
2892    #[must_use]
2893    pub fn label(self) -> &'static str {
2894        match self {
2895            Self::Off => "off",
2896            Self::Shadow => "shadow",
2897            Self::Enabled => "enabled",
2898        }
2899    }
2900
2901    /// Whether this policy involves shadow comparison.
2902    #[must_use]
2903    pub fn is_shadow(self) -> bool {
2904        matches!(self, Self::Shadow)
2905    }
2906}
2907
2908impl std::fmt::Display for RolloutPolicy {
2909    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2910        f.write_str(self.label())
2911    }
2912}
2913
2914impl std::fmt::Display for RuntimeLane {
2915    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2916        f.write_str(self.label())
2917    }
2918}
2919
2920/// Configuration for the program runtime.
2921#[derive(Debug, Clone)]
2922pub struct ProgramConfig {
2923    /// Screen mode (inline or alternate screen).
2924    pub screen_mode: ScreenMode,
2925    /// UI anchor for inline mode.
2926    pub ui_anchor: UiAnchor,
2927    /// Frame budget configuration.
2928    pub budget: FrameBudgetConfig,
2929    /// Runtime load-governor configuration.
2930    pub load_governor: LoadGovernorConfig,
2931    /// Diff strategy configuration for the terminal writer.
2932    pub diff_config: RuntimeDiffConfig,
2933    /// Evidence JSONL sink configuration.
2934    pub evidence_sink: EvidenceSinkConfig,
2935    /// Render-trace recorder configuration.
2936    pub render_trace: RenderTraceConfig,
2937    /// Optional frame timing sink.
2938    pub frame_timing: Option<FrameTimingConfig>,
2939    /// Conformal predictor configuration for frame-time risk gating.
2940    pub conformal_config: Option<ConformalConfig>,
2941    /// Locale context used for rendering.
2942    pub locale_context: LocaleContext,
2943    /// Input poll timeout.
2944    pub poll_timeout: Duration,
2945    /// Immediate event-drain policy for burst handling.
2946    pub immediate_drain: ImmediateDrainConfig,
2947    /// Resize coalescer configuration.
2948    pub resize_coalescer: CoalescerConfig,
2949    /// Resize handling behavior (immediate/throttled).
2950    pub resize_behavior: ResizeBehavior,
2951    /// Forced terminal size override (when set, resize events are ignored).
2952    pub forced_size: Option<(u16, u16)>,
2953    /// Mouse capture policy (`Auto`, `On`, `Off`).
2954    ///
2955    /// `Auto` is inline-safe: off in inline modes, on in alt-screen mode.
2956    pub mouse_capture_policy: MouseCapturePolicy,
2957    /// Enable bracketed paste.
2958    pub bracketed_paste: bool,
2959    /// Enable focus reporting.
2960    pub focus_reporting: bool,
2961    /// Enable Kitty keyboard protocol (repeat/release events).
2962    pub kitty_keyboard: bool,
2963    /// State persistence configuration.
2964    pub persistence: PersistenceConfig,
2965    /// Inline auto UI height remeasurement policy.
2966    pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2967    /// Widget refresh selection configuration.
2968    pub widget_refresh: WidgetRefreshConfig,
2969    /// Effect queue scheduling configuration.
2970    pub effect_queue: EffectQueueConfig,
2971    /// Frame guardrails configuration (memory + queue safety limits).
2972    pub guardrails: GuardrailsConfig,
2973    /// Install signal handlers for cleanup on SIGINT/SIGTERM/SIGHUP.
2974    ///
2975    /// Defaults to `true` for application safety. Set to `false` in tests or
2976    /// when the embedding application manages signals.
2977    pub intercept_signals: bool,
2978    /// Optional tick strategy for selective background screen ticking.
2979    ///
2980    /// When `None` (default), all screens tick every frame (current behavior).
2981    /// When set, the runtime consults the strategy for each inactive screen.
2982    pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2983    /// Runtime execution lane for the Asupersync migration rollout.
2984    ///
2985    /// Controls which subscription/effect backend is active.
2986    /// Defaults to `Structured` (CancellationToken-backed, current migration state).
2987    /// Logged at startup so operators can identify the active lane.
2988    pub runtime_lane: RuntimeLane,
2989    /// Rollout policy for the Asupersync migration (bd-2crbt).
2990    ///
2991    /// Controls whether shadow-run comparison is active during this session.
2992    /// When `Shadow`, both the baseline and candidate lanes run in parallel
2993    /// and evidence is emitted; rendering uses the baseline lane only.
2994    pub rollout_policy: RolloutPolicy,
2995}
2996
2997impl Default for ProgramConfig {
2998    fn default() -> Self {
2999        Self {
3000            screen_mode: ScreenMode::Inline { ui_height: 4 },
3001            ui_anchor: UiAnchor::Bottom,
3002            budget: FrameBudgetConfig::default(),
3003            load_governor: LoadGovernorConfig::default(),
3004            diff_config: RuntimeDiffConfig::default(),
3005            evidence_sink: EvidenceSinkConfig::default(),
3006            render_trace: RenderTraceConfig::default(),
3007            frame_timing: None,
3008            conformal_config: None,
3009            locale_context: LocaleContext::global(),
3010            poll_timeout: Duration::from_millis(100),
3011            immediate_drain: ImmediateDrainConfig::default(),
3012            resize_coalescer: CoalescerConfig::default(),
3013            resize_behavior: ResizeBehavior::Throttled,
3014            forced_size: None,
3015            mouse_capture_policy: MouseCapturePolicy::Auto,
3016            bracketed_paste: true,
3017            focus_reporting: false,
3018            kitty_keyboard: false,
3019            persistence: PersistenceConfig::default(),
3020            inline_auto_remeasure: None,
3021            widget_refresh: WidgetRefreshConfig::default(),
3022            effect_queue: EffectQueueConfig::default(),
3023            guardrails: GuardrailsConfig::default(),
3024            intercept_signals: true,
3025            tick_strategy: None,
3026            runtime_lane: RuntimeLane::default(),
3027            rollout_policy: RolloutPolicy::default(),
3028        }
3029    }
3030}
3031
3032impl ProgramConfig {
3033    /// Create config for fullscreen applications.
3034    pub fn fullscreen() -> Self {
3035        Self {
3036            screen_mode: ScreenMode::AltScreen,
3037            ..Default::default()
3038        }
3039    }
3040
3041    /// Create config for inline mode with specified height.
3042    pub fn inline(height: u16) -> Self {
3043        Self {
3044            screen_mode: ScreenMode::Inline { ui_height: height },
3045            ..Default::default()
3046        }
3047    }
3048
3049    /// Create config for inline mode with automatic UI height.
3050    pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
3051        Self {
3052            screen_mode: ScreenMode::InlineAuto {
3053                min_height,
3054                max_height,
3055            },
3056            inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
3057            ..Default::default()
3058        }
3059    }
3060
3061    /// Enable mouse support.
3062    #[must_use]
3063    pub fn with_mouse(mut self) -> Self {
3064        self.mouse_capture_policy = MouseCapturePolicy::On;
3065        self
3066    }
3067
3068    /// Set mouse capture policy.
3069    #[must_use]
3070    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
3071        self.mouse_capture_policy = policy;
3072        self
3073    }
3074
3075    /// Force mouse capture enabled/disabled regardless of screen mode.
3076    #[must_use]
3077    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
3078        self.mouse_capture_policy = if enabled {
3079            MouseCapturePolicy::On
3080        } else {
3081            MouseCapturePolicy::Off
3082        };
3083        self
3084    }
3085
3086    /// Resolve mouse capture using the configured policy and screen mode.
3087    #[must_use]
3088    pub const fn resolved_mouse_capture(&self) -> bool {
3089        self.mouse_capture_policy.resolve(self.screen_mode)
3090    }
3091
3092    /// Set the budget configuration.
3093    #[must_use]
3094    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
3095        self.budget = budget;
3096        self
3097    }
3098
3099    /// Set the runtime load-governor configuration.
3100    #[must_use]
3101    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
3102        self.load_governor = config;
3103        self
3104    }
3105
3106    /// Disable the adaptive load governor and use legacy render-budget behavior.
3107    #[must_use]
3108    pub fn without_load_governor(mut self) -> Self {
3109        self.load_governor = LoadGovernorConfig::disabled();
3110        self
3111    }
3112
3113    /// Set the diff strategy configuration for the terminal writer.
3114    #[must_use]
3115    pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
3116        self.diff_config = diff_config;
3117        self
3118    }
3119
3120    /// Set the evidence JSONL sink configuration.
3121    #[must_use]
3122    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
3123        self.evidence_sink = config;
3124        self
3125    }
3126
3127    /// Set the render-trace recorder configuration.
3128    #[must_use]
3129    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
3130        self.render_trace = config;
3131        self
3132    }
3133
3134    /// Set a frame timing sink for per-frame profiling.
3135    #[must_use]
3136    pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
3137        self.frame_timing = Some(config);
3138        self
3139    }
3140
3141    /// Enable conformal frame-time risk gating with the given config.
3142    #[must_use]
3143    pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
3144        self.conformal_config = Some(config);
3145        self
3146    }
3147
3148    /// Disable conformal frame-time risk gating.
3149    #[must_use]
3150    pub fn without_conformal(mut self) -> Self {
3151        self.conformal_config = None;
3152        self
3153    }
3154
3155    /// Set the locale context used for rendering.
3156    #[must_use]
3157    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
3158        self.locale_context = locale_context;
3159        self
3160    }
3161
3162    /// Set the base locale used for rendering.
3163    #[must_use]
3164    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
3165        self.locale_context = LocaleContext::new(locale);
3166        self
3167    }
3168
3169    /// Set the widget refresh selection configuration.
3170    #[must_use]
3171    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
3172        self.widget_refresh = config;
3173        self
3174    }
3175
3176    /// Set the effect queue scheduling configuration.
3177    #[must_use]
3178    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
3179        self.effect_queue = config;
3180        self
3181    }
3182
3183    /// Set the resize coalescer configuration.
3184    #[must_use]
3185    pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
3186        self.resize_coalescer = config;
3187        self
3188    }
3189
3190    /// Set the resize handling behavior.
3191    #[must_use]
3192    pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
3193        self.resize_behavior = behavior;
3194        self
3195    }
3196
3197    /// Force a fixed terminal size (cols, rows). Resize events are ignored.
3198    #[must_use]
3199    pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
3200        let width = width.max(1);
3201        let height = height.max(1);
3202        self.forced_size = Some((width, height));
3203        self
3204    }
3205
3206    /// Clear any forced terminal size override.
3207    #[must_use]
3208    pub fn without_forced_size(mut self) -> Self {
3209        self.forced_size = None;
3210        self
3211    }
3212
3213    /// Toggle legacy immediate-resize behavior for migration.
3214    #[must_use]
3215    pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
3216        if enabled {
3217            self.resize_behavior = ResizeBehavior::Immediate;
3218        }
3219        self
3220    }
3221
3222    /// Set the persistence configuration.
3223    #[must_use]
3224    pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
3225        self.persistence = persistence;
3226        self
3227    }
3228
3229    /// Enable persistence with the given registry.
3230    #[must_use]
3231    pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
3232        self.persistence = PersistenceConfig::with_registry(registry);
3233        self
3234    }
3235
3236    /// Enable inline auto UI height remeasurement with the given policy.
3237    #[must_use]
3238    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
3239        self.inline_auto_remeasure = Some(config);
3240        self
3241    }
3242
3243    /// Disable inline auto UI height remeasurement.
3244    #[must_use]
3245    pub fn without_inline_auto_remeasure(mut self) -> Self {
3246        self.inline_auto_remeasure = None;
3247        self
3248    }
3249
3250    /// Enable or disable signal interception (SIGHUP/SIGTERM/SIGINT) for cleanup.
3251    #[must_use]
3252    pub fn with_signal_interception(mut self, enabled: bool) -> Self {
3253        self.intercept_signals = enabled;
3254        self
3255    }
3256
3257    /// Set frame guardrails configuration.
3258    #[must_use]
3259    pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
3260        self.guardrails = config;
3261        self
3262    }
3263
3264    /// Set the immediate event-drain policy for burst handling.
3265    #[must_use]
3266    pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
3267        self.immediate_drain = config;
3268        self
3269    }
3270
3271    /// Set the tick strategy for selective background screen ticking.
3272    ///
3273    /// When set, the runtime consults the strategy to decide which inactive
3274    /// screens should tick on each frame. Without a strategy, all screens
3275    /// tick every frame (backwards-compatible default).
3276    ///
3277    /// ```ignore
3278    /// ProgramConfig::default()
3279    ///     .with_tick_strategy(TickStrategyKind::Uniform { divisor: 5 })
3280    /// ```
3281    #[must_use]
3282    pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
3283        self.tick_strategy = Some(strategy);
3284        self
3285    }
3286
3287    /// Set the runtime execution lane.
3288    #[must_use]
3289    pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
3290        self.runtime_lane = lane;
3291        self
3292    }
3293
3294    /// Set the rollout policy for the Asupersync migration.
3295    #[must_use]
3296    pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
3297        self.rollout_policy = policy;
3298        self
3299    }
3300
3301    /// Apply environment-variable overrides for lane and rollout policy.
3302    ///
3303    /// Reads `FTUI_RUNTIME_LANE` and `FTUI_ROLLOUT_POLICY`. Unset variables
3304    /// are ignored. Unrecognized values emit a `tracing::warn` and are
3305    /// ignored (the programmatic default or prior builder value is retained).
3306    #[must_use]
3307    pub fn with_env_overrides(mut self) -> Self {
3308        if let Some(lane) = RuntimeLane::from_env() {
3309            self.runtime_lane = lane;
3310        }
3311        if let Some(policy) = RolloutPolicy::from_env() {
3312            self.rollout_policy = policy;
3313        }
3314        self
3315    }
3316
3317    #[must_use]
3318    fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
3319        if !self.effect_queue.uses_legacy_default_backend() {
3320            return self.effect_queue.clone();
3321        }
3322
3323        self.effect_queue
3324            .clone()
3325            .with_backend(self.runtime_lane.resolve().task_executor_backend())
3326    }
3327}
3328
3329fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
3330    let budget = RenderBudget::from_config(&config.budget);
3331    if config.load_governor.enabled {
3332        let mut controller = config.load_governor.budget_controller.clone();
3333        controller.target = config.budget.total;
3334        budget.with_controller(controller)
3335    } else {
3336        budget
3337    }
3338}
3339
3340enum EffectCommand<M> {
3341    Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
3342    Shutdown,
3343}
3344
3345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3346enum EffectLoopControl {
3347    Continue,
3348    ShutdownRequested,
3349}
3350
3351struct EffectQueue<M: Send + 'static> {
3352    sender: mpsc::Sender<EffectCommand<M>>,
3353    handle: Option<JoinHandle<()>>,
3354    closed: bool,
3355}
3356
3357impl<M: Send + 'static> EffectQueue<M> {
3358    fn start(
3359        config: EffectQueueConfig,
3360        result_sender: mpsc::Sender<M>,
3361        evidence_sink: Option<EvidenceSink>,
3362    ) -> io::Result<Self> {
3363        let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
3364        let handle = thread::Builder::new()
3365            .name("ftui-effects".into())
3366            .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
3367
3368        Ok(Self {
3369            sender: tx,
3370            handle: Some(handle),
3371            closed: false,
3372        })
3373    }
3374
3375    fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3376        if self.closed {
3377            crate::effect_system::record_queue_drop("post_shutdown");
3378            tracing::debug!("rejecting task enqueue after effect queue shutdown");
3379            return;
3380        }
3381        if self
3382            .sender
3383            .send(EffectCommand::Enqueue(spec, task))
3384            .is_err()
3385        {
3386            crate::effect_system::record_queue_drop("channel_closed");
3387        }
3388    }
3389
3390    /// Timeout for the effect-queue thread to finish after sending Shutdown.
3391    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3392    /// Poll interval when waiting for the effect-queue thread (bd-170o5).
3393    ///
3394    /// This sleep-poll pattern is the idiomatic Rust approach for bounded
3395    /// thread joins — `JoinHandle` has no `join_timeout` in stable Rust.
3396    /// 1ms is chosen to minimize shutdown latency while avoiding spin.
3397    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3398
3399    fn shutdown(&mut self) {
3400        self.closed = true;
3401        let _ = self.sender.send(EffectCommand::Shutdown);
3402        if let Some(handle) = self.handle.take() {
3403            let start = Instant::now();
3404            // Fast path: most shutdowns complete nearly instantly after the
3405            // Shutdown command is drained. Check once before entering poll loop.
3406            if handle.is_finished() {
3407                let _ = handle.join();
3408                let elapsed_us = start.elapsed().as_micros() as u64;
3409                tracing::debug!(
3410                    target: "ftui.runtime",
3411                    elapsed_us,
3412                    "effect-queue shutdown (fast path)"
3413                );
3414                return;
3415            }
3416            // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
3417            while !handle.is_finished() {
3418                if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3419                    tracing::warn!(
3420                        target: "ftui.runtime",
3421                        timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3422                        "effect-queue thread did not stop within timeout; detaching"
3423                    );
3424                    return;
3425                }
3426                thread::sleep(Self::SHUTDOWN_POLL);
3427            }
3428            let _ = handle.join();
3429            let elapsed_us = start.elapsed().as_micros() as u64;
3430            tracing::debug!(
3431                target: "ftui.runtime",
3432                elapsed_us,
3433                "effect-queue shutdown (slow path)"
3434            );
3435        }
3436    }
3437}
3438
3439impl<M: Send + 'static> Drop for EffectQueue<M> {
3440    fn drop(&mut self) {
3441        self.shutdown();
3442    }
3443}
3444
3445struct SpawnTaskExecutor<M: Send + 'static> {
3446    result_sender: mpsc::Sender<M>,
3447    evidence_sink: Option<EvidenceSink>,
3448    handles: Vec<JoinHandle<()>>,
3449    /// Backpressure bound on in-flight spawned threads (`0` = unbounded),
3450    /// matching the EffectQueue / asupersync lanes.
3451    max_queue_depth: usize,
3452    /// Tasks shed by backpressure or post-shutdown rejection.
3453    dropped: u64,
3454    closed: bool,
3455}
3456
3457impl<M: Send + 'static> SpawnTaskExecutor<M> {
3458    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3459    /// Poll interval for bounded thread joins (bd-170o5).
3460    ///
3461    /// Same rationale as `EffectQueue::SHUTDOWN_POLL` — `JoinHandle` has no
3462    /// `join_timeout` in stable Rust, so we poll `is_finished()` with a
3463    /// 1ms sleep to minimize shutdown latency while avoiding spin.
3464    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3465
3466    fn new(
3467        result_sender: mpsc::Sender<M>,
3468        evidence_sink: Option<EvidenceSink>,
3469        max_queue_depth: usize,
3470    ) -> Self {
3471        Self {
3472            result_sender,
3473            evidence_sink,
3474            handles: Vec::new(),
3475            max_queue_depth,
3476            dropped: 0,
3477            closed: false,
3478        }
3479    }
3480
3481    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3482        if self.closed {
3483            self.dropped += 1;
3484            crate::effect_system::record_queue_drop("post_shutdown");
3485            tracing::debug!("rejecting spawned task submit after shutdown");
3486            return;
3487        }
3488        // Join finished threads first so the in-flight depth used for
3489        // backpressure reflects only tasks that are still running.
3490        self.reap_finished();
3491        // Backpressure: bound the number of in-flight spawned threads. The
3492        // `with_max_queue_depth` contract promises drops + counters for every
3493        // backend; this lane previously spawned unboundedly and counted
3494        // nothing. `0` means unbounded.
3495        if self.max_queue_depth > 0 && self.handles.len() >= self.max_queue_depth {
3496            self.dropped += 1;
3497            crate::effect_system::record_queue_drop("backpressure");
3498            emit_task_executor_backpressure_evidence(
3499                self.evidence_sink.as_ref(),
3500                "spawned",
3501                "drop",
3502                self.handles.len(),
3503                self.max_queue_depth,
3504                self.dropped,
3505            );
3506            return;
3507        }
3508        crate::effect_system::record_queue_enqueue(self.handles.len() as u64 + 1);
3509        let sender = self.result_sender.clone();
3510        let evidence_sink = self.evidence_sink.clone();
3511        let handle = thread::spawn(move || {
3512            let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
3513            crate::effect_system::record_queue_processed();
3514        });
3515        self.handles.push(handle);
3516    }
3517
3518    fn reap_finished(&mut self) {
3519        if self.handles.is_empty() {
3520            return;
3521        }
3522
3523        let mut i = 0;
3524        while i < self.handles.len() {
3525            if self.handles[i].is_finished() {
3526                let handle = self.handles.swap_remove(i);
3527                let _ = handle.join();
3528            } else {
3529                i += 1;
3530            }
3531        }
3532    }
3533
3534    fn shutdown(&mut self) {
3535        self.closed = true;
3536        let start = Instant::now();
3537        // Fast path: reap any already-finished handles first.
3538        self.reap_finished();
3539        if self.handles.is_empty() {
3540            let elapsed_us = start.elapsed().as_micros() as u64;
3541            tracing::debug!(
3542                target: "ftui.runtime",
3543                elapsed_us,
3544                "spawn-executor shutdown (fast path, all tasks already finished)"
3545            );
3546            return;
3547        }
3548        // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
3549        let pending_at_start = self.handles.len();
3550        while self.handles.iter().any(|handle| !handle.is_finished()) {
3551            if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3552                let still_pending = self
3553                    .handles
3554                    .iter()
3555                    .filter(|handle| !handle.is_finished())
3556                    .count();
3557                tracing::warn!(
3558                    target: "ftui.runtime",
3559                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3560                    pending_handles = still_pending,
3561                    "background task threads did not stop within timeout; detaching"
3562                );
3563                self.handles.clear();
3564                return;
3565            }
3566            thread::sleep(Self::SHUTDOWN_POLL);
3567        }
3568        self.reap_finished();
3569        let elapsed_us = start.elapsed().as_micros() as u64;
3570        tracing::debug!(
3571            target: "ftui.runtime",
3572            elapsed_us,
3573            pending_at_start,
3574            "spawn-executor shutdown (slow path)"
3575        );
3576    }
3577}
3578
3579#[cfg(feature = "asupersync-executor")]
3580struct AsupersyncTaskExecutor<M: Send + 'static> {
3581    result_sender: mpsc::Sender<M>,
3582    evidence_sink: Option<EvidenceSink>,
3583    runtime: AsupersyncRuntime,
3584    handles: Vec<BlockingTaskHandle>,
3585    /// Backpressure cap on in-flight blocking tasks (`0` = unbounded). Mirrors
3586    /// the `EffectQueue` lane's `max_queue_depth` so both backends shed load and
3587    /// report queue telemetry identically.
3588    max_queue_depth: usize,
3589    /// Total tasks rejected by backpressure or post-shutdown submission.
3590    dropped: u64,
3591    closed: bool,
3592}
3593
3594#[cfg(feature = "asupersync-executor")]
3595impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
3596    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3597
3598    fn new(
3599        result_sender: mpsc::Sender<M>,
3600        evidence_sink: Option<EvidenceSink>,
3601        max_queue_depth: usize,
3602    ) -> io::Result<Self> {
3603        let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
3604        let runtime = RuntimeBuilder::new()
3605            .blocking_threads(1, max_threads)
3606            .thread_name_prefix("ftui-asupersync-task")
3607            .build()
3608            .map_err(|error| {
3609                io::Error::other(format!("asupersync runtime init failed: {error}"))
3610            })?;
3611
3612        Ok(Self {
3613            result_sender,
3614            evidence_sink,
3615            runtime,
3616            handles: Vec::new(),
3617            max_queue_depth,
3618            dropped: 0,
3619            closed: false,
3620        })
3621    }
3622
3623    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3624        if self.closed {
3625            self.dropped += 1;
3626            crate::effect_system::record_queue_drop("post_shutdown");
3627            tracing::debug!("rejecting asupersync task submit after shutdown");
3628            return;
3629        }
3630        // Prune completed handles so the in-flight depth used for backpressure
3631        // reflects only tasks that are still running or queued in the pool.
3632        self.handles.retain(|handle| !handle.is_done());
3633        // Backpressure: bound the number of in-flight blocking tasks, matching
3634        // the EffectQueue lane (bd-2zd0a). `0` means unbounded.
3635        if self.max_queue_depth > 0 && self.handles.len() >= self.max_queue_depth {
3636            self.dropped += 1;
3637            crate::effect_system::record_queue_drop("backpressure");
3638            emit_task_executor_backpressure_evidence(
3639                self.evidence_sink.as_ref(),
3640                "asupersync",
3641                "drop",
3642                self.handles.len(),
3643                self.max_queue_depth,
3644                self.dropped,
3645            );
3646            return;
3647        }
3648        crate::effect_system::record_queue_enqueue(self.handles.len() as u64 + 1);
3649        let sender = self.result_sender.clone();
3650        let evidence_sink = self.evidence_sink.clone();
3651        let handle = self
3652            .runtime
3653            .spawn_blocking(move || {
3654                let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3655                crate::effect_system::record_queue_processed();
3656            })
3657            .expect("asupersync blocking pool must be configured");
3658        self.handles.push(handle);
3659    }
3660
3661    fn reap_finished(&mut self) {
3662        self.handles.retain(|handle| !handle.is_done());
3663    }
3664
3665    fn shutdown(&mut self) {
3666        self.closed = true;
3667        let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3668        for handle in &self.handles {
3669            let remaining = deadline.saturating_duration_since(Instant::now());
3670            if remaining.is_zero() || !handle.wait_timeout(remaining) {
3671                tracing::warn!(
3672                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3673                    pending_handles = self
3674                        .handles
3675                        .iter()
3676                        .filter(|pending| !pending.is_done())
3677                        .count(),
3678                    "Asupersync blocking tasks did not stop within timeout; detaching"
3679                );
3680                self.handles.clear();
3681                return;
3682            }
3683        }
3684        self.handles.clear();
3685    }
3686}
3687
3688enum TaskExecutor<M: Send + 'static> {
3689    Spawned(SpawnTaskExecutor<M>),
3690    Queued(EffectQueue<M>),
3691    #[cfg(feature = "asupersync-executor")]
3692    Asupersync(AsupersyncTaskExecutor<M>),
3693}
3694
3695impl<M: Send + 'static> TaskExecutor<M> {
3696    fn new(
3697        config: &EffectQueueConfig,
3698        result_sender: mpsc::Sender<M>,
3699        evidence_sink: Option<EvidenceSink>,
3700    ) -> io::Result<Self> {
3701        let executor = match config.backend {
3702            TaskExecutorBackend::Spawned => Self::Spawned(SpawnTaskExecutor::new(
3703                result_sender,
3704                evidence_sink.clone(),
3705                config.max_queue_depth,
3706            )),
3707            TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3708                config.clone(),
3709                result_sender,
3710                evidence_sink.clone(),
3711            )?),
3712            #[cfg(feature = "asupersync-executor")]
3713            TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3714                result_sender,
3715                evidence_sink.clone(),
3716                config.max_queue_depth,
3717            )?),
3718        };
3719
3720        emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3721        Ok(executor)
3722    }
3723
3724    fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3725        match self {
3726            Self::Spawned(executor) => executor.submit(task),
3727            Self::Queued(queue) => queue.enqueue(spec, task),
3728            #[cfg(feature = "asupersync-executor")]
3729            Self::Asupersync(executor) => executor.submit(task),
3730        }
3731    }
3732
3733    fn reap_finished(&mut self) {
3734        match self {
3735            Self::Spawned(executor) => executor.reap_finished(),
3736            #[cfg(feature = "asupersync-executor")]
3737            Self::Asupersync(executor) => executor.reap_finished(),
3738            Self::Queued(_) => {}
3739        }
3740    }
3741
3742    fn shutdown(&mut self) {
3743        match self {
3744            Self::Spawned(executor) => executor.shutdown(),
3745            Self::Queued(queue) => queue.shutdown(),
3746            #[cfg(feature = "asupersync-executor")]
3747            Self::Asupersync(executor) => executor.shutdown(),
3748        }
3749    }
3750
3751    #[cfg(test)]
3752    fn kind_name(&self) -> &'static str {
3753        self.kind_name_for_logs()
3754    }
3755
3756    fn kind_name_for_logs(&self) -> &'static str {
3757        match self {
3758            Self::Spawned(_) => "spawned",
3759            Self::Queued(_) => "queued",
3760            #[cfg(feature = "asupersync-executor")]
3761            Self::Asupersync(_) => "asupersync",
3762        }
3763    }
3764}
3765
3766fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3767    let Some(sink) = sink else {
3768        return;
3769    };
3770    let _ = sink.write_jsonl(&format!(
3771        r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3772    ));
3773}
3774
3775fn emit_task_executor_completion_evidence(
3776    sink: Option<&EvidenceSink>,
3777    backend: &str,
3778    duration_us: u64,
3779) {
3780    let Some(sink) = sink else {
3781        return;
3782    };
3783    let _ = sink.write_jsonl(&format!(
3784        r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3785    ));
3786}
3787
3788fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3789    let Some(sink) = sink else {
3790        return;
3791    };
3792    let escaped = panic_msg
3793        .replace('\\', "\\\\")
3794        .replace('"', "\\\"")
3795        .replace('\n', "\\n")
3796        .replace('\r', "\\r")
3797        .replace('\t', "\\t");
3798    let _ = sink.write_jsonl(&format!(
3799        r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3800    ));
3801}
3802
3803fn emit_task_executor_backpressure_evidence(
3804    sink: Option<&EvidenceSink>,
3805    backend: &str,
3806    action: &str,
3807    queue_length: usize,
3808    max_queue_size: usize,
3809    total_rejected: u64,
3810) {
3811    let Some(sink) = sink else {
3812        return;
3813    };
3814    let _ = sink.write_jsonl(&format!(
3815        r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3816    ));
3817}
3818
3819fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3820    if let Some(s) = payload.downcast_ref::<&str>() {
3821        (*s).to_owned()
3822    } else if let Some(s) = payload.downcast_ref::<String>() {
3823        s.clone()
3824    } else {
3825        "unknown panic payload".to_owned()
3826    }
3827}
3828
3829fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3830    #[cfg(feature = "tracing")]
3831    tracing::error!(
3832        executor_backend = backend,
3833        panic_msg,
3834        "task executor task panicked"
3835    );
3836    #[cfg(not(feature = "tracing"))]
3837    eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3838}
3839
3840fn run_task_closure<M: Send + 'static>(
3841    task: Box<dyn FnOnce() -> M + Send>,
3842    backend: &str,
3843    evidence_sink: Option<&EvidenceSink>,
3844    result_sender: &mpsc::Sender<M>,
3845) -> bool {
3846    let start = Instant::now();
3847    // This is a RECOVERABLE panic boundary — the program keeps running.
3848    // Suppress the terminal panic hook: without this, a panicking task
3849    // triggers best_effort_cleanup mid-run (scroll-region reset, alt-screen
3850    // leave, raw-mode exit) and the still-live UI renders onto a cooked,
3851    // echoing main screen.
3852    let caught =
3853        ftui_core::with_panic_cleanup_suppressed(|| panic::catch_unwind(AssertUnwindSafe(task)));
3854    match caught {
3855        Ok(msg) => {
3856            let duration_us = start.elapsed().as_micros() as u64;
3857            tracing::debug!(
3858                target: "ftui.effect",
3859                command_type = "task",
3860                executor_backend = backend,
3861                duration_us = duration_us,
3862                effect_duration_us = duration_us,
3863                "task effect completed"
3864            );
3865            emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3866            let _ = result_sender.send(msg);
3867            true
3868        }
3869        Err(payload) => {
3870            let panic_msg = panic_payload_message(payload);
3871            log_task_executor_panic(backend, &panic_msg);
3872            emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3873            false
3874        }
3875    }
3876}
3877
3878fn effect_queue_loop<M: Send + 'static>(
3879    config: EffectQueueConfig,
3880    rx: mpsc::Receiver<EffectCommand<M>>,
3881    result_sender: mpsc::Sender<M>,
3882    evidence_sink: Option<EvidenceSink>,
3883) {
3884    let mut scheduler = QueueingScheduler::new(config.scheduler);
3885    let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3886    let mut shutdown_requested = false;
3887    let max_depth = config.max_queue_depth;
3888
3889    loop {
3890        if tasks.is_empty() {
3891            if shutdown_requested {
3892                return;
3893            }
3894            match rx.recv() {
3895                Ok(cmd) => {
3896                    if matches!(
3897                        handle_effect_command(
3898                            cmd,
3899                            &mut scheduler,
3900                            &mut tasks,
3901                            &result_sender,
3902                            evidence_sink.as_ref(),
3903                            max_depth,
3904                        ),
3905                        EffectLoopControl::ShutdownRequested
3906                    ) {
3907                        shutdown_requested = true;
3908                    }
3909                }
3910                Err(_) => return,
3911            }
3912        }
3913
3914        while let Ok(cmd) = rx.try_recv() {
3915            if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3916                crate::effect_system::record_queue_drop("post_shutdown");
3917                continue;
3918            }
3919            if matches!(
3920                handle_effect_command(
3921                    cmd,
3922                    &mut scheduler,
3923                    &mut tasks,
3924                    &result_sender,
3925                    evidence_sink.as_ref(),
3926                    max_depth,
3927                ),
3928                EffectLoopControl::ShutdownRequested
3929            ) {
3930                shutdown_requested = true;
3931            }
3932        }
3933
3934        if tasks.is_empty() {
3935            if shutdown_requested {
3936                return;
3937            }
3938            continue;
3939        }
3940
3941        let Some(job) = scheduler.peek_next().cloned() else {
3942            continue;
3943        };
3944
3945        if let Some(ref sink) = evidence_sink {
3946            let evidence = scheduler.evidence();
3947            let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3948        }
3949
3950        let completed = scheduler.tick(job.remaining_time);
3951        for job_id in completed {
3952            if let Some(task) = tasks.remove(&job_id) {
3953                let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3954                crate::effect_system::record_queue_processed();
3955            }
3956        }
3957    }
3958}
3959
3960fn handle_effect_command<M: Send + 'static>(
3961    cmd: EffectCommand<M>,
3962    scheduler: &mut QueueingScheduler,
3963    tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3964    result_sender: &mpsc::Sender<M>,
3965    evidence_sink: Option<&EvidenceSink>,
3966    max_depth: usize,
3967) -> EffectLoopControl {
3968    match cmd {
3969        EffectCommand::Enqueue(spec, task) => {
3970            // Backpressure: drop task if queue depth exceeds limit (bd-2zd0a)
3971            if max_depth > 0 && tasks.len() >= max_depth {
3972                crate::effect_system::record_queue_drop("backpressure");
3973                return EffectLoopControl::Continue;
3974            }
3975            let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3976                WeightSource::Default
3977            } else {
3978                WeightSource::Explicit
3979            };
3980            let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3981                EstimateSource::Default
3982            } else {
3983                EstimateSource::Explicit
3984            };
3985            let id = scheduler.submit_with_sources(
3986                spec.weight,
3987                spec.estimate_ms,
3988                weight_source,
3989                estimate_source,
3990                spec.name,
3991            );
3992            if let Some(id) = id {
3993                tasks.insert(id, task);
3994                crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3995            } else {
3996                let stats = scheduler.stats();
3997                emit_task_executor_backpressure_evidence(
3998                    evidence_sink,
3999                    "queued",
4000                    "inline_fallback",
4001                    stats.queue_length,
4002                    scheduler.max_queue_size(),
4003                    stats.total_rejected,
4004                );
4005                let _ =
4006                    run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
4007            }
4008            EffectLoopControl::Continue
4009        }
4010        EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
4011    }
4012}
4013
4014// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
4015
4016/// Policy for remeasuring inline auto UI height.
4017///
4018/// Uses VOI (value-of-information) sampling to decide when to perform
4019/// a costly full-height measurement, with any-time valid guarantees via
4020/// the embedded e-process in `VoiSampler`.
4021#[derive(Debug, Clone)]
4022pub struct InlineAutoRemeasureConfig {
4023    /// VOI sampling configuration.
4024    pub voi: VoiConfig,
4025    /// Minimum row delta to count as a "violation".
4026    pub change_threshold_rows: u16,
4027}
4028
4029impl Default for InlineAutoRemeasureConfig {
4030    fn default() -> Self {
4031        Self {
4032            voi: VoiConfig {
4033                // Height changes are expected to be rare; bias toward fewer samples.
4034                prior_alpha: 1.0,
4035                prior_beta: 9.0,
4036                // Allow ~1s max latency to adapt to growth/shrink.
4037                max_interval_ms: 1000,
4038                // Avoid over-sampling in high-FPS loops.
4039                min_interval_ms: 100,
4040                // Disable event forcing; use time-based gating.
4041                max_interval_events: 0,
4042                min_interval_events: 0,
4043                // Treat sampling as moderately expensive.
4044                sample_cost: 0.08,
4045                ..VoiConfig::default()
4046            },
4047            change_threshold_rows: 1,
4048        }
4049    }
4050}
4051
4052#[derive(Debug)]
4053struct InlineAutoRemeasureState {
4054    config: InlineAutoRemeasureConfig,
4055    sampler: VoiSampler,
4056}
4057
4058impl InlineAutoRemeasureState {
4059    fn new(config: InlineAutoRemeasureConfig) -> Self {
4060        let sampler = VoiSampler::new(config.voi.clone());
4061        Self { config, sampler }
4062    }
4063
4064    fn reset(&mut self) {
4065        self.sampler = VoiSampler::new(self.config.voi.clone());
4066    }
4067}
4068
4069#[derive(Debug, Clone)]
4070struct ConformalEvidence {
4071    bucket_key: String,
4072    n_b: usize,
4073    alpha: f64,
4074    q_b: f64,
4075    y_hat: f64,
4076    upper_us: f64,
4077    risk: bool,
4078    fallback_level: u8,
4079    window_size: usize,
4080    reset_count: u64,
4081}
4082
4083impl ConformalEvidence {
4084    fn from_prediction(prediction: &ConformalPrediction) -> Self {
4085        let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
4086        Self {
4087            bucket_key: prediction.bucket.to_string(),
4088            n_b: prediction.sample_count,
4089            alpha,
4090            q_b: prediction.quantile,
4091            y_hat: prediction.y_hat,
4092            upper_us: prediction.upper_us,
4093            risk: prediction.risk,
4094            fallback_level: prediction.fallback_level,
4095            window_size: prediction.window_size,
4096            reset_count: prediction.reset_count,
4097        }
4098    }
4099}
4100
4101#[derive(Debug, Clone)]
4102struct BudgetDecisionEvidence {
4103    frame_idx: u64,
4104    decision: BudgetDecision,
4105    controller_decision: BudgetDecision,
4106    degradation_before: DegradationLevel,
4107    degradation_after: DegradationLevel,
4108    frame_time_us: f64,
4109    budget_us: f64,
4110    pid_output: f64,
4111    pid_p: f64,
4112    pid_i: f64,
4113    pid_d: f64,
4114    e_value: f64,
4115    frames_observed: u32,
4116    frames_since_change: u32,
4117    in_warmup: bool,
4118    controller_reason: BudgetDecisionReason,
4119    load_governor: LoadGovernorSnapshot,
4120    conformal: Option<ConformalEvidence>,
4121}
4122
4123impl BudgetDecisionEvidence {
4124    fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
4125        if after > before {
4126            BudgetDecision::Degrade
4127        } else if after < before {
4128            BudgetDecision::Upgrade
4129        } else {
4130            BudgetDecision::Hold
4131        }
4132    }
4133
4134    #[must_use]
4135    fn to_jsonl(&self) -> String {
4136        let conformal = self.conformal.as_ref();
4137        let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
4138        let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
4139        let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
4140        let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
4141        let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
4142        let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
4143        let risk = Self::opt_bool(conformal.map(|c| c.risk));
4144        let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
4145        let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
4146        let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
4147        let queue_max_depth = Self::opt_usize(self.load_governor.queue_max_depth);
4148
4149        format!(
4150            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":{}}}"#,
4151            self.frame_idx,
4152            self.decision.as_str(),
4153            self.controller_decision.as_str(),
4154            self.controller_reason.as_str(),
4155            self.degradation_before.as_str(),
4156            self.degradation_after.as_str(),
4157            self.frame_time_us,
4158            self.budget_us,
4159            self.pid_output,
4160            self.pid_p,
4161            self.pid_i,
4162            self.pid_d,
4163            self.e_value,
4164            self.frames_observed,
4165            self.frames_since_change,
4166            self.in_warmup,
4167            self.load_governor.mode.as_str(),
4168            self.load_governor.mode_before.as_str(),
4169            self.load_governor.pressure_class.as_str(),
4170            self.load_governor.disposition.as_str(),
4171            self.load_governor.reason_code,
4172            self.load_governor.transition,
4173            self.load_governor.strict_semantics_preserved,
4174            self.load_governor.queue_in_flight,
4175            queue_max_depth,
4176            self.load_governor.queue_dropped_delta,
4177            self.load_governor.resize_coalescing_active,
4178            self.load_governor.recovery_intervals_observed,
4179            self.load_governor.recovery_intervals_required,
4180            self.load_governor.deferred_work_total,
4181            self.load_governor.coalesced_work_total,
4182            self.load_governor.dropped_work_total,
4183            bucket_key,
4184            n_b,
4185            alpha,
4186            q_b,
4187            y_hat,
4188            upper_us,
4189            risk,
4190            fallback_level,
4191            window_size,
4192            reset_count
4193        )
4194    }
4195
4196    fn opt_f64(value: Option<f64>) -> String {
4197        value
4198            .map(|v| format!("{v:.6}"))
4199            .unwrap_or_else(|| "null".to_string())
4200    }
4201
4202    fn opt_u64(value: Option<u64>) -> String {
4203        value
4204            .map(|v| v.to_string())
4205            .unwrap_or_else(|| "null".to_string())
4206    }
4207
4208    fn opt_u8(value: Option<u8>) -> String {
4209        value
4210            .map(|v| v.to_string())
4211            .unwrap_or_else(|| "null".to_string())
4212    }
4213
4214    fn opt_usize(value: Option<usize>) -> String {
4215        value
4216            .map(|v| v.to_string())
4217            .unwrap_or_else(|| "null".to_string())
4218    }
4219
4220    fn opt_bool(value: Option<bool>) -> String {
4221        value
4222            .map(|v| v.to_string())
4223            .unwrap_or_else(|| "null".to_string())
4224    }
4225
4226    fn opt_str(value: Option<&str>) -> String {
4227        value
4228            .map(|v| {
4229                format!(
4230                    "\"{}\"",
4231                    v.replace('\\', "\\\\")
4232                        .replace('"', "\\\"")
4233                        .replace('\n', "\\n")
4234                        .replace('\r', "\\r")
4235                        .replace('\t', "\\t")
4236                )
4237            })
4238            .unwrap_or_else(|| "null".to_string())
4239    }
4240}
4241
4242#[derive(Debug, Clone)]
4243struct FairnessConfigEvidence {
4244    enabled: bool,
4245    input_priority_threshold_ms: u64,
4246    dominance_threshold: u32,
4247    fairness_threshold: f64,
4248}
4249
4250impl FairnessConfigEvidence {
4251    #[must_use]
4252    fn to_jsonl(&self) -> String {
4253        format!(
4254            r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
4255            self.enabled,
4256            self.input_priority_threshold_ms,
4257            self.dominance_threshold,
4258            self.fairness_threshold
4259        )
4260    }
4261}
4262
4263#[derive(Debug, Clone)]
4264struct FairnessDecisionEvidence {
4265    frame_idx: u64,
4266    decision: &'static str,
4267    reason: &'static str,
4268    pending_input_latency_ms: Option<u64>,
4269    jain_index: f64,
4270    resize_dominance_count: u32,
4271    dominance_threshold: u32,
4272    fairness_threshold: f64,
4273    input_priority_threshold_ms: u64,
4274}
4275
4276impl FairnessDecisionEvidence {
4277    #[must_use]
4278    fn to_jsonl(&self) -> String {
4279        let pending_latency = self
4280            .pending_input_latency_ms
4281            .map(|v| v.to_string())
4282            .unwrap_or_else(|| "null".to_string());
4283        format!(
4284            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":{}}}"#,
4285            self.frame_idx,
4286            self.decision,
4287            self.reason,
4288            pending_latency,
4289            self.jain_index,
4290            self.resize_dominance_count,
4291            self.dominance_threshold,
4292            self.fairness_threshold,
4293            self.input_priority_threshold_ms
4294        )
4295    }
4296}
4297
4298#[derive(Debug, Clone)]
4299struct WidgetRefreshEntry {
4300    widget_id: u64,
4301    essential: bool,
4302    starved: bool,
4303    value: f32,
4304    cost_us: f32,
4305    score: f32,
4306    staleness_ms: u64,
4307}
4308
4309impl WidgetRefreshEntry {
4310    fn to_json(&self) -> String {
4311        format!(
4312            r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
4313            self.widget_id,
4314            self.cost_us,
4315            self.value,
4316            self.score,
4317            self.essential,
4318            self.starved,
4319            self.staleness_ms
4320        )
4321    }
4322}
4323
4324#[derive(Debug, Clone)]
4325struct WidgetRefreshPlan {
4326    frame_idx: u64,
4327    budget_us: f64,
4328    degradation: DegradationLevel,
4329    essentials_cost_us: f64,
4330    selected_cost_us: f64,
4331    selected_value: f64,
4332    signal_count: usize,
4333    selected: Vec<WidgetRefreshEntry>,
4334    skipped_count: usize,
4335    skipped_starved: usize,
4336    starved_selected: usize,
4337    over_budget: bool,
4338}
4339
4340impl WidgetRefreshPlan {
4341    fn new() -> Self {
4342        Self {
4343            frame_idx: 0,
4344            budget_us: 0.0,
4345            degradation: DegradationLevel::Full,
4346            essentials_cost_us: 0.0,
4347            selected_cost_us: 0.0,
4348            selected_value: 0.0,
4349            signal_count: 0,
4350            selected: Vec::new(),
4351            skipped_count: 0,
4352            skipped_starved: 0,
4353            starved_selected: 0,
4354            over_budget: false,
4355        }
4356    }
4357
4358    fn clear(&mut self) {
4359        self.frame_idx = 0;
4360        self.budget_us = 0.0;
4361        self.degradation = DegradationLevel::Full;
4362        self.essentials_cost_us = 0.0;
4363        self.selected_cost_us = 0.0;
4364        self.selected_value = 0.0;
4365        self.signal_count = 0;
4366        self.selected.clear();
4367        self.skipped_count = 0;
4368        self.skipped_starved = 0;
4369        self.starved_selected = 0;
4370        self.over_budget = false;
4371    }
4372
4373    fn as_budget(&self) -> WidgetBudget {
4374        if self.signal_count == 0 {
4375            return WidgetBudget::allow_all();
4376        }
4377        let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
4378        WidgetBudget::allow_only(ids)
4379    }
4380
4381    fn recompute(
4382        &mut self,
4383        frame_idx: u64,
4384        budget_us: f64,
4385        degradation: DegradationLevel,
4386        signals: &[WidgetSignal],
4387        config: &WidgetRefreshConfig,
4388    ) {
4389        self.clear();
4390        self.frame_idx = frame_idx;
4391        self.budget_us = budget_us;
4392        self.degradation = degradation;
4393
4394        if !config.enabled || signals.is_empty() {
4395            return;
4396        }
4397
4398        self.signal_count = signals.len();
4399        let mut essentials_cost = 0.0f64;
4400        let mut selected_cost = 0.0f64;
4401        let mut selected_value = 0.0f64;
4402
4403        let staleness_window = config.staleness_window_ms.max(1) as f32;
4404        let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
4405
4406        for signal in signals {
4407            let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
4408            let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
4409            let mut value = config.weight_priority * signal.priority
4410                + config.weight_staleness * staleness_score
4411                + config.weight_focus * signal.focus_boost
4412                + config.weight_interaction * signal.interaction_boost;
4413            if starved {
4414                value += config.starve_boost;
4415            }
4416            let raw_cost = if signal.recent_cost_us > 0.0 {
4417                signal.recent_cost_us
4418            } else {
4419                signal.cost_estimate_us
4420            };
4421            let cost_us = raw_cost.max(config.min_cost_us);
4422            let score = if cost_us > 0.0 {
4423                value / cost_us
4424            } else {
4425                value
4426            };
4427
4428            let entry = WidgetRefreshEntry {
4429                widget_id: signal.widget_id,
4430                essential: signal.essential,
4431                starved,
4432                value,
4433                cost_us,
4434                score,
4435                staleness_ms: signal.staleness_ms,
4436            };
4437
4438            if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
4439                self.skipped_count += 1;
4440                if starved {
4441                    self.skipped_starved = self.skipped_starved.saturating_add(1);
4442                }
4443                continue;
4444            }
4445
4446            if signal.essential {
4447                essentials_cost += cost_us as f64;
4448                selected_cost += cost_us as f64;
4449                selected_value += value as f64;
4450                if starved {
4451                    self.starved_selected = self.starved_selected.saturating_add(1);
4452                }
4453                self.selected.push(entry);
4454            } else {
4455                candidates.push(entry);
4456            }
4457        }
4458
4459        let mut remaining = budget_us - selected_cost;
4460
4461        if degradation < DegradationLevel::EssentialOnly {
4462            let nonessential_total = candidates.len();
4463            let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
4464            let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
4465            let min_nonessential_selected = if enforce_drop_rate {
4466                let min_fraction = (1.0 - max_drop_fraction).max(0.0);
4467                ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
4468            } else {
4469                0
4470            };
4471
4472            candidates.sort_by(|a, b| {
4473                b.starved
4474                    .cmp(&a.starved)
4475                    .then_with(|| b.score.total_cmp(&a.score))
4476                    .then_with(|| b.value.total_cmp(&a.value))
4477                    .then_with(|| a.cost_us.total_cmp(&b.cost_us))
4478                    .then_with(|| a.widget_id.cmp(&b.widget_id))
4479            });
4480
4481            let mut forced_starved = 0usize;
4482            let mut nonessential_selected = 0usize;
4483            let mut skipped_candidates = if enforce_drop_rate {
4484                Vec::with_capacity(candidates.len())
4485            } else {
4486                Vec::new()
4487            };
4488
4489            for entry in candidates.into_iter() {
4490                if entry.starved && forced_starved >= config.max_starved_per_frame {
4491                    self.skipped_count += 1;
4492                    self.skipped_starved = self.skipped_starved.saturating_add(1);
4493                    if enforce_drop_rate {
4494                        skipped_candidates.push(entry);
4495                    }
4496                    continue;
4497                }
4498
4499                if remaining >= entry.cost_us as f64 {
4500                    remaining -= entry.cost_us as f64;
4501                    selected_cost += entry.cost_us as f64;
4502                    selected_value += entry.value as f64;
4503                    if entry.starved {
4504                        self.starved_selected = self.starved_selected.saturating_add(1);
4505                        forced_starved += 1;
4506                    }
4507                    nonessential_selected += 1;
4508                    self.selected.push(entry);
4509                } else if entry.starved
4510                    && forced_starved < config.max_starved_per_frame
4511                    && nonessential_selected == 0
4512                {
4513                    // Starvation guard: ensure at least one starved widget can refresh.
4514                    selected_cost += entry.cost_us as f64;
4515                    selected_value += entry.value as f64;
4516                    self.starved_selected = self.starved_selected.saturating_add(1);
4517                    forced_starved += 1;
4518                    nonessential_selected += 1;
4519                    self.selected.push(entry);
4520                } else {
4521                    self.skipped_count += 1;
4522                    if entry.starved {
4523                        self.skipped_starved = self.skipped_starved.saturating_add(1);
4524                    }
4525                    if enforce_drop_rate {
4526                        skipped_candidates.push(entry);
4527                    }
4528                }
4529            }
4530
4531            if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
4532                for entry in skipped_candidates.into_iter() {
4533                    if nonessential_selected >= min_nonessential_selected {
4534                        break;
4535                    }
4536                    if entry.starved && forced_starved >= config.max_starved_per_frame {
4537                        continue;
4538                    }
4539                    selected_cost += entry.cost_us as f64;
4540                    selected_value += entry.value as f64;
4541                    if entry.starved {
4542                        self.starved_selected = self.starved_selected.saturating_add(1);
4543                        forced_starved += 1;
4544                        self.skipped_starved = self.skipped_starved.saturating_sub(1);
4545                    }
4546                    self.skipped_count = self.skipped_count.saturating_sub(1);
4547                    nonessential_selected += 1;
4548                    self.selected.push(entry);
4549                }
4550            }
4551        }
4552
4553        self.essentials_cost_us = essentials_cost;
4554        self.selected_cost_us = selected_cost;
4555        self.selected_value = selected_value;
4556        self.over_budget = selected_cost > budget_us;
4557    }
4558
4559    #[must_use]
4560    fn to_jsonl(&self) -> String {
4561        let mut out = String::with_capacity(256 + self.selected.len() * 96);
4562        out.push_str(r#"{"event":"widget_refresh""#);
4563        out.push_str(&format!(
4564            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":{}"#,
4565            self.frame_idx,
4566            self.budget_us,
4567            self.degradation.as_str(),
4568            self.essentials_cost_us,
4569            self.selected_cost_us,
4570            self.selected_value,
4571            self.selected.len(),
4572            self.skipped_count,
4573            self.starved_selected,
4574            self.skipped_starved,
4575            self.over_budget
4576        ));
4577        out.push_str(r#","selected":["#);
4578        for (i, entry) in self.selected.iter().enumerate() {
4579            if i > 0 {
4580                out.push(',');
4581            }
4582            out.push_str(&entry.to_json());
4583        }
4584        out.push_str("]}");
4585        out
4586    }
4587}
4588
4589// =============================================================================
4590// CrosstermEventSource: BackendEventSource adapter for TerminalSession
4591// =============================================================================
4592
4593#[cfg(feature = "crossterm-compat")]
4594/// Adapter that wraps [`TerminalSession`] to implement [`BackendEventSource`].
4595///
4596/// This provides the bridge between the legacy crossterm-based terminal session
4597/// and the new backend abstraction. Once the native `ftui-tty` backend fully
4598/// replaces crossterm, this adapter will be removed.
4599pub struct CrosstermEventSource {
4600    session: TerminalSession,
4601    features: BackendFeatures,
4602}
4603
4604#[cfg(feature = "crossterm-compat")]
4605impl CrosstermEventSource {
4606    /// Create a new crossterm event source from a terminal session.
4607    pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
4608        Self {
4609            session,
4610            features: initial_features,
4611        }
4612    }
4613}
4614
4615#[cfg(feature = "crossterm-compat")]
4616impl BackendEventSource for CrosstermEventSource {
4617    type Error = io::Error;
4618
4619    fn size(&self) -> Result<(u16, u16), io::Error> {
4620        self.session.size()
4621    }
4622
4623    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4624        if features.mouse_capture != self.features.mouse_capture {
4625            self.session.set_mouse_capture(features.mouse_capture)?;
4626        }
4627        // bracketed_paste, focus_events, and kitty_keyboard are set at session
4628        // construction and cleaned up in TerminalSession::Drop. Runtime toggling
4629        // is not supported by the crossterm backend.
4630        self.features = features;
4631        Ok(())
4632    }
4633
4634    fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
4635        self.session.poll_event(timeout)
4636    }
4637
4638    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4639        self.session.read_event()
4640    }
4641}
4642
4643// =============================================================================
4644// HeadlessEventSource: no-op event source for headless/test programs
4645// =============================================================================
4646
4647/// A no-op event source for headless and test programs.
4648///
4649/// Returns a fixed terminal size, accepts feature changes silently, and never
4650/// produces events. This allows the test helper to construct a `Program`
4651/// without depending on crossterm or a real terminal.
4652pub struct HeadlessEventSource {
4653    width: u16,
4654    height: u16,
4655    features: BackendFeatures,
4656}
4657
4658impl HeadlessEventSource {
4659    /// Create a headless event source with the given terminal size.
4660    pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
4661        Self {
4662            width,
4663            height,
4664            features,
4665        }
4666    }
4667}
4668
4669impl BackendEventSource for HeadlessEventSource {
4670    type Error = io::Error;
4671
4672    fn size(&self) -> Result<(u16, u16), io::Error> {
4673        Ok((self.width, self.height))
4674    }
4675
4676    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4677        self.features = features;
4678        Ok(())
4679    }
4680
4681    fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
4682        Ok(false)
4683    }
4684
4685    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4686        Ok(None)
4687    }
4688}
4689
4690// =============================================================================
4691// Program
4692// =============================================================================
4693
4694/// The program runtime that manages the update/view loop.
4695pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4696    /// The application model.
4697    model: M,
4698    /// Terminal output coordinator.
4699    writer: TerminalWriter<W>,
4700    /// Event source (terminal input, size queries, feature toggles).
4701    events: E,
4702    /// Currently active backend feature toggles.
4703    backend_features: BackendFeatures,
4704    /// Whether the program is running.
4705    running: bool,
4706    /// Whether the model shutdown hook and runtime teardown have completed.
4707    shutdown_complete: bool,
4708    /// Current tick rate (if any).
4709    tick_rate: Option<Duration>,
4710    /// Total commands actually executed by the runtime.
4711    executed_cmd_count: usize,
4712    /// Last tick time.
4713    last_tick: Instant,
4714    /// Whether the UI needs to be redrawn.
4715    dirty: bool,
4716    /// Monotonic frame index for evidence logging.
4717    frame_idx: u64,
4718    /// Monotonic tick index for tick-strategy scheduling.
4719    tick_count: u64,
4720    /// Widget scheduling signals captured during the last render.
4721    widget_signals: Vec<WidgetSignal>,
4722    /// Widget refresh selection configuration.
4723    widget_refresh_config: WidgetRefreshConfig,
4724    /// Last computed widget refresh plan.
4725    widget_refresh_plan: WidgetRefreshPlan,
4726    /// Current terminal width.
4727    width: u16,
4728    /// Current terminal height.
4729    height: u16,
4730    /// Forced terminal size override (when set, resize events are ignored).
4731    forced_size: Option<(u16, u16)>,
4732    /// Poll timeout when no tick is scheduled.
4733    poll_timeout: Duration,
4734    /// Whether the runtime should observe process-level termination signals.
4735    intercept_signals: bool,
4736    /// Immediate drain policy for bursty input handling.
4737    immediate_drain_config: ImmediateDrainConfig,
4738    /// Runtime counters for immediate-drain behavior.
4739    immediate_drain_stats: ImmediateDrainStats,
4740    /// Frame budget configuration.
4741    budget: RenderBudget,
4742    /// Runtime load-governor state for mode and fallback evidence.
4743    load_governor: LoadGovernorState,
4744    /// Conformal predictor for frame-time risk gating.
4745    conformal_predictor: Option<ConformalPredictor>,
4746    /// Last observed frame time (microseconds), used as a baseline predictor.
4747    last_frame_time_us: Option<f64>,
4748    /// Last observed update duration (microseconds).
4749    last_update_us: Option<u64>,
4750    /// Optional frame timing sink for profiling.
4751    frame_timing: Option<FrameTimingConfig>,
4752    /// Locale context used for rendering.
4753    locale_context: LocaleContext,
4754    /// Last observed locale version.
4755    locale_version: u64,
4756    /// Resize coalescer for rapid resize events.
4757    resize_coalescer: ResizeCoalescer,
4758    /// Shared evidence sink for decision logs (optional).
4759    evidence_sink: Option<EvidenceSink>,
4760    /// Whether fairness config has been logged to evidence sink.
4761    fairness_config_logged: bool,
4762    /// Resize handling behavior.
4763    resize_behavior: ResizeBehavior,
4764    /// Input fairness guard for scheduler integration.
4765    fairness_guard: InputFairnessGuard,
4766    /// Optional event recorder for macro capture.
4767    event_recorder: Option<EventRecorder>,
4768    /// Subscription lifecycle manager.
4769    subscriptions: SubscriptionManager<M::Message>,
4770    /// Channel for receiving messages from background tasks.
4771    #[cfg(test)]
4772    task_sender: std::sync::mpsc::Sender<M::Message>,
4773    /// Channel for receiving messages from background tasks.
4774    task_receiver: std::sync::mpsc::Receiver<M::Message>,
4775    /// Internal task execution substrate behind `Cmd::Task`.
4776    task_executor: TaskExecutor<M::Message>,
4777    /// Optional state registry for widget persistence.
4778    state_registry: Option<std::sync::Arc<StateRegistry>>,
4779    /// Persistence configuration.
4780    persistence_config: PersistenceConfig,
4781    /// Last checkpoint save time.
4782    last_checkpoint: Instant,
4783    /// Inline auto UI height remeasurement state.
4784    inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4785    /// Per-frame bump arena for temporary render-path allocations.
4786    frame_arena: FrameArena,
4787    /// Unified frame guardrails (memory/queue limits).
4788    guardrails: FrameGuardrails,
4789    /// Frame index of the last soft-tier capacity trim (`None` = never
4790    /// trimmed).
4791    ///
4792    /// Soft memory alerts fire on retained CAPACITY that only a rebuild can
4793    /// shed; trimming every alerting frame would thrash allocations when
4794    /// usage hovers near the limit, so trims are cooldown-gated
4795    /// (bd-1za0z: actuator must be able to move the sensor).
4796    last_soft_trim_frame: Option<u64>,
4797    /// Optional tick strategy for selective background screen ticking.
4798    tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4799    /// Last active screen observed by the tick strategy dispatch path.
4800    last_active_screen_for_strategy: Option<String>,
4801}
4802
4803#[cfg(feature = "crossterm-compat")]
4804impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4805    /// Create a new program with default configuration.
4806    pub fn new(model: M) -> io::Result<Self>
4807    where
4808        M::Message: Send + 'static,
4809    {
4810        Self::with_config(model, ProgramConfig::default())
4811    }
4812
4813    /// Create a new program with the specified configuration.
4814    pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4815    where
4816        M::Message: Send + 'static,
4817    {
4818        let resolved_lane = config.runtime_lane.resolve();
4819        let effect_queue_config = config.resolved_effect_queue_config();
4820        let capabilities = TerminalCapabilities::with_overrides();
4821        let mouse_capture = config.resolved_mouse_capture();
4822        let requested_features = BackendFeatures {
4823            mouse_capture,
4824            bracketed_paste: config.bracketed_paste,
4825            focus_events: config.focus_reporting,
4826            kitty_keyboard: config.kitty_keyboard,
4827        };
4828        let initial_features =
4829            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4830        let session = TerminalSession::new(SessionOptions {
4831            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4832            mouse_capture: initial_features.mouse_capture,
4833            bracketed_paste: initial_features.bracketed_paste,
4834            focus_events: initial_features.focus_events,
4835            kitty_keyboard: initial_features.kitty_keyboard,
4836            intercept_signals: config.intercept_signals,
4837        })?;
4838        let events = CrosstermEventSource::new(session, initial_features);
4839
4840        let mut writer = TerminalWriter::with_diff_config(
4841            io::stdout(),
4842            config.screen_mode,
4843            config.ui_anchor,
4844            capabilities,
4845            config.diff_config.clone(),
4846        );
4847
4848        let frame_timing = config.frame_timing.clone();
4849        writer.set_timing_enabled(frame_timing.is_some());
4850
4851        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4852        if let Some(ref sink) = evidence_sink {
4853            writer = writer.with_evidence_sink(sink.clone());
4854        }
4855
4856        let render_trace = crate::RenderTraceRecorder::from_config(
4857            &config.render_trace,
4858            crate::RenderTraceContext {
4859                capabilities: writer.capabilities(),
4860                diff_config: config.diff_config.clone(),
4861                resize_config: config.resize_coalescer.clone(),
4862                conformal_config: config.conformal_config.clone(),
4863            },
4864        )?;
4865        if let Some(recorder) = render_trace {
4866            writer = writer.with_render_trace(recorder);
4867        }
4868
4869        // Get terminal size for initial frame (or forced size override).
4870        let (w, h) = config
4871            .forced_size
4872            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4873        let width = w.max(1);
4874        let height = h.max(1);
4875        writer.set_size(width, height);
4876
4877        let budget = render_budget_from_program_config(&config);
4878        let load_governor = LoadGovernorState::new(
4879            config.load_governor.clone(),
4880            effect_queue_config.max_queue_depth,
4881        );
4882        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4883        let locale_context = config.locale_context.clone();
4884        let locale_version = locale_context.version();
4885        let mut resize_coalescer =
4886            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4887                .with_screen_mode(config.screen_mode);
4888        if let Some(ref sink) = evidence_sink {
4889            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4890        }
4891        let subscriptions = SubscriptionManager::new();
4892        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4893        let inline_auto_remeasure = config
4894            .inline_auto_remeasure
4895            .clone()
4896            .map(InlineAutoRemeasureState::new);
4897        let task_executor = TaskExecutor::new(
4898            &effect_queue_config,
4899            task_sender.clone(),
4900            evidence_sink.clone(),
4901        )?;
4902        let guardrails = FrameGuardrails::new(config.guardrails);
4903
4904        // Log runtime lane and rollout policy at startup (bd-2crbt)
4905        tracing::info!(
4906            target: "ftui.runtime",
4907            requested_lane = config.runtime_lane.label(),
4908            resolved_lane = resolved_lane.label(),
4909            rollout_policy = config.rollout_policy.label(),
4910            "runtime startup: lane={}, rollout={}",
4911            resolved_lane.label(),
4912            config.rollout_policy.label(),
4913        );
4914
4915        Ok(Self {
4916            model,
4917            writer,
4918            events,
4919            backend_features: initial_features,
4920            running: true,
4921            shutdown_complete: false,
4922            tick_rate: None,
4923            executed_cmd_count: 0,
4924            last_tick: Instant::now(),
4925            dirty: true,
4926            frame_idx: 0,
4927            tick_count: 0,
4928            widget_signals: Vec::new(),
4929            widget_refresh_config: config.widget_refresh,
4930            widget_refresh_plan: WidgetRefreshPlan::new(),
4931            width,
4932            height,
4933            forced_size: config.forced_size,
4934            poll_timeout: config.poll_timeout,
4935            intercept_signals: config.intercept_signals,
4936            immediate_drain_config: config.immediate_drain,
4937            immediate_drain_stats: ImmediateDrainStats::default(),
4938            budget,
4939            load_governor,
4940            conformal_predictor,
4941            last_frame_time_us: None,
4942            last_update_us: None,
4943            frame_timing,
4944            locale_context,
4945            locale_version,
4946            resize_coalescer,
4947            evidence_sink,
4948            fairness_config_logged: false,
4949            resize_behavior: config.resize_behavior,
4950            fairness_guard: InputFairnessGuard::new(),
4951            event_recorder: None,
4952            subscriptions,
4953            #[cfg(test)]
4954            task_sender,
4955            task_receiver,
4956            task_executor,
4957            state_registry: config.persistence.registry.clone(),
4958            persistence_config: config.persistence,
4959            last_checkpoint: Instant::now(),
4960            inline_auto_remeasure,
4961            frame_arena: FrameArena::default(),
4962            guardrails,
4963            last_soft_trim_frame: None,
4964            tick_strategy: config
4965                .tick_strategy
4966                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4967            last_active_screen_for_strategy: None,
4968        })
4969    }
4970}
4971
4972impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4973    /// Create a program with an externally-constructed event source and writer.
4974    ///
4975    /// This is the generic entry point for alternative backends (native tty,
4976    /// WASM, headless testing). The caller is responsible for terminal
4977    /// lifecycle (raw mode, cleanup) — the event source should handle that
4978    /// via its `Drop` impl or an external RAII guard.
4979    pub fn with_event_source(
4980        model: M,
4981        events: E,
4982        backend_features: BackendFeatures,
4983        writer: TerminalWriter<W>,
4984        config: ProgramConfig,
4985    ) -> io::Result<Self>
4986    where
4987        M::Message: Send + 'static,
4988    {
4989        let effect_queue_config = config.resolved_effect_queue_config();
4990        let (width, height) = config
4991            .forced_size
4992            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4993        let width = width.max(1);
4994        let height = height.max(1);
4995
4996        let mut writer = writer;
4997        writer.set_size(width, height);
4998
4999        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
5000        if let Some(ref sink) = evidence_sink {
5001            writer = writer.with_evidence_sink(sink.clone());
5002        }
5003
5004        let render_trace = crate::RenderTraceRecorder::from_config(
5005            &config.render_trace,
5006            crate::RenderTraceContext {
5007                capabilities: writer.capabilities(),
5008                diff_config: config.diff_config.clone(),
5009                resize_config: config.resize_coalescer.clone(),
5010                conformal_config: config.conformal_config.clone(),
5011            },
5012        )?;
5013        if let Some(recorder) = render_trace {
5014            writer = writer.with_render_trace(recorder);
5015        }
5016
5017        let frame_timing = config.frame_timing.clone();
5018        writer.set_timing_enabled(frame_timing.is_some());
5019
5020        let budget = render_budget_from_program_config(&config);
5021        let load_governor = LoadGovernorState::new(
5022            config.load_governor.clone(),
5023            effect_queue_config.max_queue_depth,
5024        );
5025        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
5026        let locale_context = config.locale_context.clone();
5027        let locale_version = locale_context.version();
5028        let mut resize_coalescer =
5029            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
5030                .with_screen_mode(config.screen_mode);
5031        if let Some(ref sink) = evidence_sink {
5032            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
5033        }
5034        let subscriptions = SubscriptionManager::new();
5035        let (task_sender, task_receiver) = std::sync::mpsc::channel();
5036        let inline_auto_remeasure = config
5037            .inline_auto_remeasure
5038            .clone()
5039            .map(InlineAutoRemeasureState::new);
5040        let task_executor = TaskExecutor::new(
5041            &effect_queue_config,
5042            task_sender.clone(),
5043            evidence_sink.clone(),
5044        )?;
5045
5046        let guardrails = FrameGuardrails::new(config.guardrails);
5047
5048        Ok(Self {
5049            model,
5050            writer,
5051            events,
5052            backend_features,
5053            running: true,
5054            shutdown_complete: false,
5055            tick_rate: None,
5056            executed_cmd_count: 0,
5057            last_tick: Instant::now(),
5058            dirty: true,
5059            frame_idx: 0,
5060            tick_count: 0,
5061            widget_signals: Vec::new(),
5062            widget_refresh_config: config.widget_refresh,
5063            widget_refresh_plan: WidgetRefreshPlan::new(),
5064            width,
5065            height,
5066            forced_size: config.forced_size,
5067            poll_timeout: config.poll_timeout,
5068            intercept_signals: config.intercept_signals,
5069            immediate_drain_config: config.immediate_drain,
5070            immediate_drain_stats: ImmediateDrainStats::default(),
5071            budget,
5072            load_governor,
5073            conformal_predictor,
5074            last_frame_time_us: None,
5075            last_update_us: None,
5076            frame_timing,
5077            locale_context,
5078            locale_version,
5079            resize_coalescer,
5080            evidence_sink,
5081            fairness_config_logged: false,
5082            resize_behavior: config.resize_behavior,
5083            fairness_guard: InputFairnessGuard::new(),
5084            event_recorder: None,
5085            subscriptions,
5086            #[cfg(test)]
5087            task_sender,
5088            task_receiver,
5089            task_executor,
5090            state_registry: config.persistence.registry.clone(),
5091            persistence_config: config.persistence,
5092            last_checkpoint: Instant::now(),
5093            inline_auto_remeasure,
5094            frame_arena: FrameArena::default(),
5095            guardrails,
5096            last_soft_trim_frame: None,
5097            tick_strategy: config
5098                .tick_strategy
5099                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
5100            last_active_screen_for_strategy: None,
5101        })
5102    }
5103}
5104
5105// =============================================================================
5106// Native TTY backend constructor (feature-gated)
5107// =============================================================================
5108
5109#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
5110#[inline]
5111const fn sanitize_backend_features_for_capabilities(
5112    requested: BackendFeatures,
5113    capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
5114) -> BackendFeatures {
5115    let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
5116    let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
5117
5118    BackendFeatures {
5119        mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
5120        bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
5121        focus_events: requested.focus_events && focus_events_supported,
5122        kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
5123    }
5124}
5125
5126#[cfg(feature = "native-backend")]
5127impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
5128    /// Create a program backed by the native TTY backend (no Crossterm).
5129    ///
5130    /// This opens a live terminal session via `ftui-tty`, entering raw mode
5131    /// and enabling the requested features. When the program exits (or panics),
5132    /// `TtyBackend::drop()` restores the terminal to its original state.
5133    ///
5134    /// **Unix-only.** `ftui-tty` does not yet have a Windows-native backend.
5135    /// On non-Unix targets call [`Program::with_config`] (with `crossterm-compat`)
5136    /// instead — calling `with_native_backend` from Windows used to silently
5137    /// fall through to the headless 0×0 test backend and produce a single
5138    /// init-frame-then-silence pattern that looks like a hung TUI.
5139    #[cfg(unix)]
5140    pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
5141    where
5142        M::Message: Send + 'static,
5143    {
5144        let mut capabilities =
5145            ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
5146        let mouse_capture = config.resolved_mouse_capture();
5147        let requested_features = BackendFeatures {
5148            mouse_capture,
5149            bracketed_paste: config.bracketed_paste,
5150            focus_events: config.focus_reporting,
5151            kitty_keyboard: config.kitty_keyboard,
5152        };
5153        let features =
5154            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
5155        let options = ftui_tty::TtySessionOptions {
5156            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
5157            features,
5158            intercept_signals: config.intercept_signals,
5159        };
5160        let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
5161
5162        // Runtime truecolor recovery. If environment detection did NOT already
5163        // establish 24-bit color — the classic case being an `ssh` hop, which
5164        // forwards `TERM` but strips `COLORTERM`/`TERM_PROGRAM`, so a truecolor
5165        // terminal (e.g. WezTerm/FrankenTerm) is mis-detected as 256-color —
5166        // ask the terminal DIRECTLY via the XTGETTCAP `RGB` query now that the
5167        // native session is live (raw mode). The query round-trips to the real
5168        // terminal even across ssh, so it recovers what the env var could not.
5169        // It runs ONLY in this degraded case (zero added latency when truecolor
5170        // is already known), is bounded by a timeout, fail-open, and
5171        // upgrade-only (a non-answer never downgrades a known-good profile).
5172        //
5173        // Restrict probing to the degraded 256-color case so explicit
5174        // monochrome policy (`NO_COLOR`, dumb/vt100) can never be upgraded.
5175        if capabilities.color_depth == ftui_core::terminal_capabilities::ColorDepth::Ansi256 {
5176            let probe =
5177                ftui_core::caps_probe::probe_capabilities(&ftui_core::caps_probe::ProbeConfig {
5178                    timeout: std::time::Duration::from_millis(300),
5179                    probe_da1: false,
5180                    probe_da2: false,
5181                    probe_background: false,
5182                    probe_truecolor: true,
5183                });
5184            capabilities.refine_from_probe(&probe);
5185        }
5186
5187        let writer = TerminalWriter::with_diff_config(
5188            io::stdout(),
5189            config.screen_mode,
5190            config.ui_anchor,
5191            capabilities,
5192            config.diff_config.clone(),
5193        );
5194
5195        Self::with_event_source(model, backend, features, writer, config)
5196    }
5197}
5198
5199impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
5200    /// Run the main event loop.
5201    ///
5202    /// This is the main entry point. It handles:
5203    /// 1. Initialization (terminal setup, raw mode)
5204    /// 2. Event polling and message dispatch
5205    /// 3. Frame rendering
5206    /// 4. Shutdown (terminal cleanup)
5207    pub fn run(&mut self) -> io::Result<()> {
5208        if self.shutdown_complete {
5209            return Err(io::Error::new(
5210                io::ErrorKind::InvalidInput,
5211                "program lifecycle has already completed",
5212            ));
5213        }
5214
5215        let run_result = self.run_event_loop();
5216        self.complete_lifecycle(run_result)
5217    }
5218
5219    #[inline]
5220    fn observed_termination_signal(&self) -> Option<i32> {
5221        if self.intercept_signals {
5222            check_termination_signal()
5223        } else {
5224            None
5225        }
5226    }
5227
5228    /// Access widget scheduling signals captured on the last render.
5229    #[inline]
5230    pub fn last_widget_signals(&self) -> &[WidgetSignal] {
5231        &self.widget_signals
5232    }
5233
5234    /// Snapshot immediate-drain runtime counters.
5235    #[inline]
5236    pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
5237        self.immediate_drain_stats
5238    }
5239
5240    /// The inner event loop, separated for proper cleanup handling.
5241    fn run_event_loop(&mut self) -> io::Result<Option<i32>> {
5242        // Auto-load state on start
5243        if self.persistence_config.auto_load {
5244            self.load_state();
5245        }
5246
5247        // Initialize
5248        let cmd = {
5249            let _span = info_span!("ftui.program.init").entered();
5250            self.model.init()
5251        };
5252        self.execute_cmd(cmd)?;
5253
5254        let mut termination_signal = self.observed_termination_signal();
5255        if self.running && termination_signal.is_none() {
5256            // Reconcile initial subscriptions
5257            self.reconcile_subscriptions();
5258            self.process_subscription_failures(false)?;
5259
5260            // Initial render
5261            if self.running {
5262                self.render_frame()?;
5263            }
5264        }
5265
5266        // Main loop
5267        let mut loop_count: u64 = 0;
5268        while self.running {
5269            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5270            if termination_signal.is_some() {
5271                self.running = false;
5272                break;
5273            }
5274
5275            loop_count += 1;
5276            // Log heartbeat every 100 iterations to avoid flooding stderr
5277            if loop_count.is_multiple_of(100) {
5278                crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
5279            }
5280
5281            // Poll for input with tick timeout
5282            let timeout = self.effective_timeout();
5283
5284            // Poll for events with timeout
5285            let poll_result = self.events.poll_event(timeout)?;
5286            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5287            if termination_signal.is_some() {
5288                self.running = false;
5289                break;
5290            }
5291            if poll_result {
5292                self.drain_ready_events()?;
5293            }
5294            if !self.running {
5295                break;
5296            }
5297            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5298            if termination_signal.is_some() {
5299                self.running = false;
5300                break;
5301            }
5302
5303            // Process subscription messages
5304            self.process_subscription_messages()?;
5305            self.process_subscription_failures(false)?;
5306            if !self.running {
5307                break;
5308            }
5309
5310            // Process background task results
5311            self.process_task_results()?;
5312            self.reap_finished_tasks();
5313            if !self.running {
5314                break;
5315            }
5316
5317            self.process_resize_coalescer()?;
5318            if !self.running {
5319                break;
5320            }
5321            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5322            if termination_signal.is_some() {
5323                self.running = false;
5324                break;
5325            }
5326
5327            // Detect screen transitions from any update() calls above.
5328            // A.2: notifies the tick strategy so predictive strategies learn.
5329            // D.3: force-ticks the newly active screen for immediate refresh.
5330            self.check_screen_transition();
5331
5332            // Check for tick - deliver to model so periodic logic can run
5333            if self.should_tick() {
5334                self.tick_count = self.tick_count.wrapping_add(1);
5335                let tick_count = self.tick_count;
5336
5337                let mut used_screen_dispatch = false;
5338
5339                // Per-screen tick dispatch: if the model supports multi-screen
5340                // dispatch and a tick strategy is configured, tick individual
5341                // screens selectively instead of calling monolithic
5342                // `update(Tick)`.
5343                if let Some(strategy) = self.tick_strategy.as_mut() {
5344                    // Snapshot screen topology first so the mutable borrow of the
5345                    // dispatch adapter does not overlap strategy decisions.
5346                    let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
5347                        let active = dispatch.active_screen_id();
5348                        let all_screens = dispatch.screen_ids();
5349                        (active, all_screens)
5350                    });
5351
5352                    if let Some((active, all_screens)) = dispatch_snapshot {
5353                        used_screen_dispatch = true;
5354
5355                        // Feed active-screen transitions into the strategy so
5356                        // predictive strategies can learn from real navigation.
5357                        if let Some(previous_active) =
5358                            self.last_active_screen_for_strategy.as_deref()
5359                            && previous_active != active
5360                        {
5361                            strategy.on_screen_transition(previous_active, &active);
5362                        }
5363                        self.last_active_screen_for_strategy = Some(active.clone());
5364
5365                        let all_screens_count = all_screens.len();
5366                        let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
5367                        // Active screen is always ticked.
5368                        tick_targets.push(active.clone());
5369
5370                        // Tick inactive screens according to the strategy.
5371                        for screen_id in all_screens {
5372                            if screen_id != active
5373                                && strategy.should_tick(&screen_id, tick_count, &active)
5374                                    == crate::tick_strategy::TickDecision::Tick
5375                            {
5376                                tick_targets.push(screen_id);
5377                            }
5378                        }
5379
5380                        // Compute skipped screens for tracing.
5381                        let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
5382
5383                        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5384                            for screen_id in &tick_targets {
5385                                dispatch.tick_screen(screen_id, tick_count);
5386                            }
5387                        }
5388
5389                        trace!(
5390                            tick = tick_count,
5391                            active = %active,
5392                            ticked = tick_targets.len(),
5393                            skipped = skipped_count,
5394                            "tick_strategy.frame"
5395                        );
5396
5397                        // Maintenance tick for the strategy.
5398                        strategy.maintenance_tick(tick_count);
5399                        self.mark_dirty();
5400                    }
5401                }
5402
5403                if used_screen_dispatch && self.running {
5404                    self.reconcile_subscriptions();
5405                }
5406
5407                if !used_screen_dispatch {
5408                    // Monolithic model path does not expose active-screen
5409                    // transitions, so clear dispatch-local transition state.
5410                    self.last_active_screen_for_strategy = None;
5411                    let msg = M::Message::from(Event::Tick);
5412                    let cmd = {
5413                        let _span = debug_span!(
5414                            "ftui.program.update",
5415                            msg_type = "Tick",
5416                            duration_us = tracing::field::Empty,
5417                            cmd_type = tracing::field::Empty
5418                        )
5419                        .entered();
5420                        let start = Instant::now();
5421                        let cmd = self.model.update(msg);
5422                        tracing::Span::current()
5423                            .record("duration_us", start.elapsed().as_micros() as u64);
5424                        tracing::Span::current()
5425                            .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5426                        cmd
5427                    };
5428                    self.mark_dirty();
5429                    self.execute_cmd(cmd)?;
5430                    if self.running {
5431                        self.reconcile_subscriptions();
5432                    }
5433                }
5434            }
5435
5436            // Check for periodic checkpoint save
5437            self.check_checkpoint_save();
5438
5439            // Detect locale changes outside the event loop.
5440            self.check_locale_change();
5441            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5442            if termination_signal.is_some() {
5443                self.running = false;
5444                break;
5445            }
5446
5447            // Render if dirty
5448            if self.dirty {
5449                self.render_frame()?;
5450            }
5451
5452            // Periodic grapheme pool GC
5453            if loop_count.is_multiple_of(1000) {
5454                self.writer.gc(None);
5455            }
5456        }
5457
5458        Ok(termination_signal)
5459    }
5460
5461    /// Complete the model/runtime lifecycle after every event-loop exit path.
5462    fn complete_lifecycle(&mut self, run_result: io::Result<Option<i32>>) -> io::Result<()> {
5463        let (loop_signal, primary_error) = match run_result {
5464            Ok(signal) => (signal, None),
5465            Err(error) => (None, Some(error)),
5466        };
5467        let termination_signal = loop_signal.or_else(|| self.observed_termination_signal());
5468
5469        // The event loop is over. Lifecycle commands may run model updates,
5470        // but they must never restart normal event processing.
5471        self.running = false;
5472
5473        let mut hook_error = None;
5474        if let Some(error) = primary_error.as_ref()
5475            && let Err(error) = self.invoke_error_hook(&error.to_string(), true)
5476        {
5477            hook_error = Some(error);
5478        }
5479
5480        if let Err(error) = self.process_subscription_failures(true) {
5481            hook_error.get_or_insert(error);
5482        }
5483
5484        let shutdown_error = self.shutdown_once().err();
5485
5486        // A pending termination signal takes precedence over lifecycle-step
5487        // errors: it is what ended the loop, and callers rely on the
5488        // SignalTerminationError contract for the 128+signal process exit.
5489        if let Some(signal) = termination_signal {
5490            clear_termination_signal();
5491            let error = io::Error::new(
5492                io::ErrorKind::Interrupted,
5493                SignalTerminationError { signal },
5494            );
5495            debug_assert_eq!(signal_termination_from_error(&error), Some(signal));
5496            return Err(error);
5497        }
5498
5499        if let Some(error) = primary_error {
5500            return Err(error);
5501        }
5502
5503        if let Some(error) = hook_error {
5504            return Err(error);
5505        }
5506
5507        if let Some(error) = shutdown_error {
5508            // A failing cleanup command is still a command error. Report it
5509            // after the one-shot shutdown hook without attempting teardown a
5510            // second time.
5511            let _ = self.invoke_error_hook(&error.to_string(), true);
5512            return Err(error);
5513        }
5514
5515        Ok(())
5516    }
5517
5518    /// Run model shutdown and runtime teardown at most once.
5519    fn shutdown_once(&mut self) -> io::Result<()> {
5520        if self.shutdown_complete {
5521            return Ok(());
5522        }
5523        self.shutdown_complete = true;
5524
5525        let shutdown_cmd = {
5526            let _span = info_span!("ftui.program.shutdown").entered();
5527            self.model.on_shutdown()
5528        };
5529        // The shutdown sequence is error-isolated: a failing shutdown command
5530        // must not skip auto-save, strategy/executor shutdown, or signal exit
5531        // mapping. The first error is captured and surfaced after cleanup.
5532        let mut shutdown_error = self.execute_lifecycle_cmd(shutdown_cmd).err();
5533
5534        if self.persistence_config.auto_save {
5535            self.save_state();
5536        }
5537
5538        if let Some(ref mut strategy) = self.tick_strategy {
5539            strategy.shutdown();
5540        }
5541
5542        self.subscriptions.stop_all();
5543        if let Err(error) = self.process_subscription_failures(true) {
5544            shutdown_error.get_or_insert(error);
5545        }
5546
5547        self.task_executor.shutdown();
5548        self.reap_finished_tasks();
5549        if let Err(error) = self.drain_shutdown_task_results() {
5550            shutdown_error.get_or_insert(error);
5551        }
5552
5553        match shutdown_error {
5554            Some(error) => Err(error),
5555            None => Ok(()),
5556        }
5557    }
5558
5559    /// Drain ready events while bounding zero-timeout polling work.
5560    ///
5561    /// The runtime preserves low-latency draining by polling with
5562    /// `Duration::ZERO`, but switches to a bounded backoff path when a burst
5563    /// exceeds configured immediate-drain budgets.
5564    fn drain_ready_events(&mut self) -> io::Result<()> {
5565        self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
5566
5567        let zero_poll_limit = self
5568            .immediate_drain_config
5569            .max_zero_timeout_polls_per_burst
5570            .max(1);
5571        let max_burst_duration = self.immediate_drain_config.max_burst_duration;
5572        let backoff_timeout = self.immediate_drain_config.backoff_timeout;
5573
5574        let mut burst_start = Instant::now();
5575        let mut zero_polls_in_burst_window: u64 = 0;
5576        let mut capped_this_burst = false;
5577
5578        loop {
5579            if let Some(event) = self.events.read_event()? {
5580                self.handle_event(event)?;
5581                if !self.running {
5582                    break;
5583                }
5584            }
5585
5586            let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
5587                || burst_start.elapsed() >= max_burst_duration;
5588
5589            if budget_exhausted {
5590                if !capped_this_burst {
5591                    capped_this_burst = true;
5592                    self.immediate_drain_stats.capped_bursts =
5593                        self.immediate_drain_stats.capped_bursts.saturating_add(1);
5594                }
5595
5596                self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5597                    .immediate_drain_stats
5598                    .max_zero_timeout_polls_in_burst
5599                    .max(zero_polls_in_burst_window);
5600
5601                std::thread::yield_now();
5602                self.immediate_drain_stats.backoff_polls =
5603                    self.immediate_drain_stats.backoff_polls.saturating_add(1);
5604                if !self.events.poll_event(backoff_timeout)? {
5605                    break;
5606                }
5607                zero_polls_in_burst_window = 0;
5608                burst_start = Instant::now();
5609                continue;
5610            }
5611
5612            self.immediate_drain_stats.zero_timeout_polls = self
5613                .immediate_drain_stats
5614                .zero_timeout_polls
5615                .saturating_add(1);
5616            zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
5617            if !self.events.poll_event(Duration::ZERO)? {
5618                break;
5619            }
5620        }
5621
5622        self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5623            .immediate_drain_stats
5624            .max_zero_timeout_polls_in_burst
5625            .max(zero_polls_in_burst_window);
5626
5627        Ok(())
5628    }
5629
5630    /// Load state from the persistence registry.
5631    fn load_state(&mut self) {
5632        if let Some(registry) = &self.state_registry {
5633            match registry.load() {
5634                Ok(count) => {
5635                    info!(count, "loaded widget state from persistence");
5636                }
5637                Err(e) => {
5638                    tracing::warn!(error = %e, "failed to load widget state");
5639                }
5640            }
5641        }
5642    }
5643
5644    /// Save state to the persistence registry.
5645    fn save_state(&mut self) {
5646        if let Some(registry) = &self.state_registry {
5647            match registry.flush() {
5648                Ok(true) => {
5649                    debug!("saved widget state to persistence");
5650                }
5651                Ok(false) => {
5652                    // No changes to save
5653                }
5654                Err(e) => {
5655                    tracing::warn!(error = %e, "failed to save widget state");
5656                }
5657            }
5658        }
5659    }
5660
5661    /// Check if it's time for a periodic checkpoint save.
5662    fn check_checkpoint_save(&mut self) {
5663        if let Some(interval) = self.persistence_config.checkpoint_interval
5664            && self.last_checkpoint.elapsed() >= interval
5665        {
5666            self.save_state();
5667            self.last_checkpoint = Instant::now();
5668        }
5669    }
5670
5671    fn handle_event(&mut self, event: Event) -> io::Result<()> {
5672        // Track event start time and type for fairness scheduling.
5673        let event_start = Instant::now();
5674        let fairness_event_type = Self::classify_event_for_fairness(&event);
5675        if fairness_event_type == FairnessEventType::Input {
5676            self.fairness_guard.input_arrived(event_start);
5677        }
5678
5679        // Record event before processing (no-op when recorder is None or idle).
5680        if let Some(recorder) = &mut self.event_recorder {
5681            recorder.record(&event);
5682        }
5683
5684        let event = match event {
5685            Event::Resize { width, height } => {
5686                debug!(
5687                    width,
5688                    height,
5689                    behavior = ?self.resize_behavior,
5690                    "Resize event received"
5691                );
5692                if let Some((forced_width, forced_height)) = self.forced_size {
5693                    debug!(
5694                        forced_width,
5695                        forced_height, "Resize ignored due to forced size override"
5696                    );
5697                    self.fairness_guard.event_processed(
5698                        fairness_event_type,
5699                        event_start.elapsed(),
5700                        Instant::now(),
5701                    );
5702                    return Ok(());
5703                }
5704                // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
5705                let width = width.max(1);
5706                let height = height.max(1);
5707                match self.resize_behavior {
5708                    ResizeBehavior::Immediate => {
5709                        self.resize_coalescer
5710                            .record_external_apply(width, height, Instant::now());
5711                        let result = self.apply_resize(width, height, Duration::ZERO, false);
5712                        self.fairness_guard.event_processed(
5713                            fairness_event_type,
5714                            event_start.elapsed(),
5715                            Instant::now(),
5716                        );
5717                        return result;
5718                    }
5719                    ResizeBehavior::Throttled => {
5720                        let action = self.resize_coalescer.handle_resize(width, height);
5721                        if let CoalesceAction::ApplyResize {
5722                            width,
5723                            height,
5724                            coalesce_time,
5725                            forced_by_deadline,
5726                        } = action
5727                        {
5728                            let result =
5729                                self.apply_resize(width, height, coalesce_time, forced_by_deadline);
5730                            self.fairness_guard.event_processed(
5731                                fairness_event_type,
5732                                event_start.elapsed(),
5733                                Instant::now(),
5734                            );
5735                            return result;
5736                        }
5737
5738                        self.fairness_guard.event_processed(
5739                            fairness_event_type,
5740                            event_start.elapsed(),
5741                            Instant::now(),
5742                        );
5743                        return Ok(());
5744                    }
5745                }
5746            }
5747            other => other,
5748        };
5749
5750        let msg = M::Message::from(event);
5751        let cmd = {
5752            let _span = debug_span!(
5753                "ftui.program.update",
5754                msg_type = "event",
5755                duration_us = tracing::field::Empty,
5756                cmd_type = tracing::field::Empty
5757            )
5758            .entered();
5759            let start = Instant::now();
5760            let cmd = self.model.update(msg);
5761            let elapsed_us = start.elapsed().as_micros() as u64;
5762            self.last_update_us = Some(elapsed_us);
5763            tracing::Span::current().record("duration_us", elapsed_us);
5764            tracing::Span::current()
5765                .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5766            cmd
5767        };
5768        self.mark_dirty();
5769        self.execute_cmd(cmd)?;
5770        if self.running {
5771            self.reconcile_subscriptions();
5772        }
5773
5774        // Track input event processing for fairness.
5775        self.fairness_guard.event_processed(
5776            fairness_event_type,
5777            event_start.elapsed(),
5778            Instant::now(),
5779        );
5780
5781        Ok(())
5782    }
5783
5784    /// Classify an event for fairness tracking.
5785    fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
5786        match event {
5787            Event::Key(_)
5788            | Event::Mouse(_)
5789            | Event::Paste(_)
5790            | Event::Ime(_)
5791            | Event::Focus(_)
5792            | Event::Clipboard(_) => FairnessEventType::Input,
5793            Event::Resize { .. } => FairnessEventType::Resize,
5794            Event::Tick => FairnessEventType::Tick,
5795        }
5796    }
5797
5798    /// Reconcile the model's declared subscriptions with running ones.
5799    fn reconcile_subscriptions(&mut self) {
5800        let _span = debug_span!(
5801            "ftui.program.subscriptions",
5802            active_count = tracing::field::Empty,
5803            started = tracing::field::Empty,
5804            stopped = tracing::field::Empty
5805        )
5806        .entered();
5807        let subs = self.model.subscriptions();
5808        let before_count = self.subscriptions.active_count();
5809        self.subscriptions.reconcile(subs);
5810        let after_count = self.subscriptions.active_count();
5811        let started = after_count.saturating_sub(before_count);
5812        let stopped = before_count.saturating_sub(after_count);
5813        crate::debug_trace!(
5814            "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
5815            before_count,
5816            after_count,
5817            started,
5818            stopped
5819        );
5820        if after_count == 0 {
5821            crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5822        }
5823        let current = tracing::Span::current();
5824        current.record("active_count", after_count);
5825        // started/stopped would require tracking in SubscriptionManager
5826        current.record("started", started);
5827        current.record("stopped", stopped);
5828    }
5829
5830    /// Report newly observed subscription failures through `Model::on_error`.
5831    fn process_subscription_failures(&mut self, during_lifecycle: bool) -> io::Result<()> {
5832        let mut first_error = None;
5833        for failure in self.subscriptions.drain_failures() {
5834            let error = format!("subscription {} failed: {}", failure.id, failure.error);
5835            if let Err(error) = self.invoke_error_hook(&error, during_lifecycle) {
5836                first_error.get_or_insert(error);
5837            }
5838        }
5839
5840        match first_error {
5841            Some(error) => Err(error),
5842            None => Ok(()),
5843        }
5844    }
5845
5846    /// Invoke the model error hook and execute its recovery command.
5847    fn invoke_error_hook(&mut self, error: &str, during_lifecycle: bool) -> io::Result<()> {
5848        let cmd = {
5849            let _span = info_span!("ftui.program.error", error).entered();
5850            self.model.on_error(error)
5851        };
5852        if during_lifecycle {
5853            self.execute_lifecycle_cmd(cmd)
5854        } else {
5855            self.execute_cmd(cmd)
5856        }
5857    }
5858
5859    /// Execute a lifecycle command even after the normal loop stopped.
5860    ///
5861    /// `Cmd::Batch` and `Cmd::Sequence` use the running flag as their halt
5862    /// boundary, so temporarily reopening dispatch is required for cleanup
5863    /// batches. An explicit `Cmd::Quit` inside the lifecycle command still
5864    /// halts the remaining commands.
5865    fn execute_lifecycle_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5866        let was_running = std::mem::replace(&mut self.running, true);
5867        let result = self.execute_cmd(cmd);
5868        self.running = was_running && self.running;
5869        result
5870    }
5871
5872    /// Process pending messages from subscriptions.
5873    fn process_subscription_messages(&mut self) -> io::Result<()> {
5874        let messages = self.subscriptions.drain_messages();
5875        let msg_count = messages.len();
5876        if msg_count > 0 {
5877            crate::debug_trace!("processing {} subscription message(s)", msg_count);
5878        }
5879        for msg in messages {
5880            let cmd = {
5881                let _span = debug_span!(
5882                    "ftui.program.update",
5883                    msg_type = "subscription",
5884                    duration_us = tracing::field::Empty,
5885                    cmd_type = tracing::field::Empty
5886                )
5887                .entered();
5888                let start = Instant::now();
5889                let cmd = self.model.update(msg);
5890                let elapsed_us = start.elapsed().as_micros() as u64;
5891                self.last_update_us = Some(elapsed_us);
5892                tracing::Span::current().record("duration_us", elapsed_us);
5893                tracing::Span::current()
5894                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5895                cmd
5896            };
5897            self.mark_dirty();
5898            self.execute_cmd(cmd)?;
5899            if !self.running {
5900                break;
5901            }
5902        }
5903        if self.running && self.dirty {
5904            self.reconcile_subscriptions();
5905        }
5906        Ok(())
5907    }
5908
5909    /// Process results from background tasks.
5910    fn process_task_results(&mut self) -> io::Result<()> {
5911        while let Ok(msg) = self.task_receiver.try_recv() {
5912            let cmd = {
5913                let _span = debug_span!(
5914                    "ftui.program.update",
5915                    msg_type = "task",
5916                    duration_us = tracing::field::Empty,
5917                    cmd_type = tracing::field::Empty
5918                )
5919                .entered();
5920                let start = Instant::now();
5921                let cmd = self.model.update(msg);
5922                let elapsed_us = start.elapsed().as_micros() as u64;
5923                self.last_update_us = Some(elapsed_us);
5924                tracing::Span::current().record("duration_us", elapsed_us);
5925                tracing::Span::current()
5926                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5927                cmd
5928            };
5929            self.mark_dirty();
5930            self.execute_cmd(cmd)?;
5931            if !self.running {
5932                break;
5933            }
5934        }
5935        if self.running && self.dirty {
5936            self.reconcile_subscriptions();
5937        }
5938        Ok(())
5939    }
5940
5941    /// Execute a command.
5942    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5943        self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5944        match cmd {
5945            Cmd::None => {}
5946            Cmd::Quit => self.running = false,
5947            Cmd::Msg(m) => {
5948                let start = Instant::now();
5949                let cmd = self.model.update(m);
5950                let elapsed_us = start.elapsed().as_micros() as u64;
5951                self.last_update_us = Some(elapsed_us);
5952                self.mark_dirty();
5953                self.execute_cmd(cmd)?;
5954            }
5955            Cmd::Batch(cmds) => {
5956                // Batch currently executes sequentially. This is intentional
5957                // until an async runtime or task scheduler is added.
5958                for c in cmds {
5959                    self.execute_cmd(c)?;
5960                    if !self.running {
5961                        break;
5962                    }
5963                }
5964            }
5965            Cmd::Sequence(cmds) => {
5966                for c in cmds {
5967                    self.execute_cmd(c)?;
5968                    if !self.running {
5969                        break;
5970                    }
5971                }
5972            }
5973            Cmd::Tick(duration) => {
5974                self.tick_rate = Some(duration);
5975                self.last_tick = Instant::now();
5976            }
5977            Cmd::Log(text) => {
5978                let sanitized = sanitize(&text);
5979                let mut text_crlf = if sanitized.contains('\n') {
5980                    sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5981                } else {
5982                    sanitized.into_owned()
5983                };
5984                if !text_crlf.ends_with("\r\n") {
5985                    if text_crlf.ends_with('\n') {
5986                        text_crlf.pop();
5987                    }
5988                    text_crlf.push_str("\r\n");
5989                }
5990                self.writer.write_log(&text_crlf)?;
5991            }
5992            Cmd::Task(spec, f) => {
5993                crate::effect_system::record_command_effect("task", 0);
5994                self.task_executor.submit(spec, f);
5995            }
5996            Cmd::SaveState => {
5997                self.save_state();
5998            }
5999            Cmd::RestoreState => {
6000                self.load_state();
6001            }
6002            Cmd::SetMouseCapture(enabled) => {
6003                self.backend_features.mouse_capture = enabled;
6004                self.events.set_features(self.backend_features)?;
6005            }
6006            Cmd::SetTickStrategy(strategy) => {
6007                let new_name = strategy.name().to_owned();
6008                if let Some(mut previous) = self.tick_strategy.replace(strategy) {
6009                    let old_name = previous.name().to_owned();
6010                    previous.shutdown();
6011                    info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
6012                } else {
6013                    info!(new = %new_name, "tick strategy changed at runtime");
6014                }
6015                self.last_active_screen_for_strategy = None;
6016            }
6017        }
6018        Ok(())
6019    }
6020
6021    /// Detect active-screen transitions after any `update()` call and react:
6022    ///
6023    /// - **A.2** — notify the tick strategy via `on_screen_transition()` so
6024    ///   predictive strategies can learn navigation patterns.
6025    /// - **D.3** — force-tick the newly active screen so it renders fresh
6026    ///   content immediately, without waiting for the next tick interval.
6027    ///
6028    /// This is a no-op when no tick strategy is configured or when the model
6029    /// does not implement [`ScreenTickDispatch`].
6030    fn check_screen_transition(&mut self) {
6031        if self.tick_strategy.is_none() {
6032            return;
6033        }
6034
6035        // Snapshot the current active screen (releases &mut self.model).
6036        let current_active = match self.model.as_screen_tick_dispatch() {
6037            Some(dispatch) => dispatch.active_screen_id(),
6038            None => return,
6039        };
6040
6041        // First observation: just record, no transition event.
6042        let previous = match self.last_active_screen_for_strategy.take() {
6043            Some(prev) => prev,
6044            None => {
6045                self.last_active_screen_for_strategy = Some(current_active);
6046                return;
6047            }
6048        };
6049
6050        if previous == current_active {
6051            self.last_active_screen_for_strategy = Some(current_active);
6052            return;
6053        }
6054
6055        // A.2: Notify strategy of the transition.
6056        if let Some(strategy) = self.tick_strategy.as_mut() {
6057            strategy.on_screen_transition(&previous, &current_active);
6058        }
6059
6060        // D.3: Force-tick the newly active screen immediately.
6061        let mut force_ticked = false;
6062        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
6063            dispatch.tick_screen(&current_active, self.tick_count);
6064            force_ticked = true;
6065        }
6066        if force_ticked && self.running {
6067            self.reconcile_subscriptions();
6068        }
6069
6070        self.last_active_screen_for_strategy = Some(current_active);
6071        self.mark_dirty();
6072    }
6073
6074    fn reap_finished_tasks(&mut self) {
6075        self.task_executor.reap_finished();
6076    }
6077
6078    fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
6079        while let Ok(msg) = self.task_receiver.try_recv() {
6080            let cmd = {
6081                let _span = debug_span!(
6082                    "ftui.program.update",
6083                    msg_type = "shutdown_task",
6084                    duration_us = tracing::field::Empty,
6085                    cmd_type = tracing::field::Empty
6086                )
6087                .entered();
6088                let start = Instant::now();
6089                let cmd = self.model.update(msg);
6090                let elapsed_us = start.elapsed().as_micros() as u64;
6091                self.last_update_us = Some(elapsed_us);
6092                tracing::Span::current().record("duration_us", elapsed_us);
6093                tracing::Span::current()
6094                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
6095                cmd
6096            };
6097            self.mark_dirty();
6098            self.execute_lifecycle_cmd(cmd)?;
6099        }
6100        Ok(())
6101    }
6102
6103    /// Render a frame with budget tracking.
6104    fn render_frame(&mut self) -> io::Result<()> {
6105        crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
6106
6107        self.frame_idx = self.frame_idx.wrapping_add(1);
6108        let frame_idx = self.frame_idx;
6109        let degradation_start = self.budget.degradation();
6110
6111        // Reset budget for new frame, potentially upgrading quality
6112        self.budget.next_frame();
6113
6114        // Check frame guardrails (memory/queue limits)
6115        let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
6116        // Synchronous program has effectively zero queue depth.
6117        let verdict = self.guardrails.check_frame(memory_bytes, 0);
6118
6119        // F2 observability: export a guardrail snapshot whenever any
6120        // guardrail fires. Alerts are naturally rate-limited by the
6121        // degradation logic, so this stays quiet in healthy runs
6122        // (bd-1za0z: GuardrailSnapshot::to_jsonl was production-dead).
6123        if !verdict.alerts.is_empty()
6124            && let Some(ref sink) = self.evidence_sink
6125        {
6126            let line = format!(
6127                r#"{{"schema_version":"{}","event":"guardrail_snapshot","snapshot":{}}}"#,
6128                crate::evidence_sink::EVIDENCE_SCHEMA_VERSION,
6129                self.guardrails.snapshot().to_jsonl(),
6130            );
6131            let _ = sink.write_jsonl(&line);
6132        }
6133
6134        if verdict.should_drop_frame() {
6135            // Emergency shed: skip this frame entirely to prevent OOM.
6136            // CRUCIALLY, remediate: two of the sensor's components are
6137            // retained CAPACITY that never shrinks on its own (bumpalo keeps
6138            // its largest chunk across reset(); the grapheme pool's Vec never
6139            // shrinks). Without releasing them the verdict is permanent and
6140            // the UI freezes forever — dropping frames cannot move the
6141            // sensor. Rebuilding the arena frees the retained chunk so the
6142            // next frame re-measures honestly.
6143            self.frame_arena = FrameArena::default();
6144            self.writer.gc(None);
6145            tracing::warn!(
6146                target: "ftui.guardrails",
6147                memory_bytes,
6148                "emergency frame drop: memory guardrail tripped; arena rebuilt"
6149            );
6150            return Ok(());
6151        }
6152
6153        if verdict.should_degrade() {
6154            // Apply guardrail-recommended degradation if it's stricter than budget's
6155            let current = self.budget.degradation();
6156            if verdict.recommended_level > current {
6157                self.budget.set_degradation(verdict.recommended_level);
6158            }
6159
6160            // Soft-tier early trim (bd-1za0z): the memory sensor measures
6161            // retained CAPACITY, so once past the soft limit nothing the
6162            // degradation actuator does can lower it again — only releasing
6163            // capacity can. Trim early (same remediation as the emergency
6164            // shed, but non-fatal) instead of waiting for the emergency
6165            // tier; cooldown-gated so hovering near the limit cannot thrash
6166            // allocations.
6167            let soft_alert = verdict.alerts.iter().any(|alert| {
6168                alert.kind == GuardrailKind::Memory && alert.severity == AlertSeverity::Warning
6169            });
6170            let cooldown_elapsed = match self.last_soft_trim_frame {
6171                None => true,
6172                Some(last) => self.frame_idx.saturating_sub(last) >= SOFT_TRIM_COOLDOWN_FRAMES,
6173            };
6174            if soft_alert && cooldown_elapsed {
6175                self.frame_arena = FrameArena::default();
6176                self.writer.gc(None);
6177                self.last_soft_trim_frame = Some(self.frame_idx);
6178                tracing::debug!(
6179                    target: "ftui.guardrails",
6180                    memory_bytes,
6181                    "soft memory alert: arena rebuilt to release retained capacity"
6182                );
6183            }
6184        }
6185
6186        // Apply conformal risk gate before rendering (if enabled)
6187        let mut conformal_prediction = None;
6188        if let Some(predictor) = self.conformal_predictor.as_ref() {
6189            let baseline_us = self
6190                .last_frame_time_us
6191                .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6192            let diff_strategy = self
6193                .writer
6194                .last_diff_strategy()
6195                .unwrap_or(DiffStrategy::Full);
6196            let frame_height_hint = self.writer.render_height_hint().max(1);
6197            let key = BucketKey::from_context(
6198                self.writer.screen_mode(),
6199                diff_strategy,
6200                self.width,
6201                frame_height_hint,
6202            );
6203            let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
6204            let prediction = predictor.predict(key, baseline_us, budget_us);
6205            if prediction.risk {
6206                self.budget.degrade();
6207                info!(
6208                    bucket = %prediction.bucket,
6209                    upper_us = prediction.upper_us,
6210                    budget_us = prediction.budget_us,
6211                    fallback_level = prediction.fallback_level,
6212                    degradation = self.budget.degradation().as_str(),
6213                    "conformal gate triggered strategy downgrade"
6214                );
6215                debug!(
6216                    monotonic.counter.conformal_gate_triggers_total = 1_u64,
6217                    bucket = %prediction.bucket,
6218                    "conformal gate trigger"
6219                );
6220            }
6221            debug!(
6222                bucket = %prediction.bucket,
6223                upper_us = prediction.upper_us,
6224                budget_us = prediction.budget_us,
6225                fallback = prediction.fallback_level,
6226                risk = prediction.risk,
6227                "conformal risk gate"
6228            );
6229            debug!(
6230                monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
6231                bucket = %prediction.bucket,
6232                "conformal prediction interval width"
6233            );
6234            conformal_prediction = Some(prediction);
6235        }
6236
6237        // Early skip if budget says to skip this frame entirely
6238        if self.budget.exhausted() {
6239            self.budget.record_frame_time(Duration::ZERO);
6240            let load_snapshot =
6241                self.update_load_governor_snapshot(frame_idx, 0.0, conformal_prediction.as_ref());
6242            self.emit_budget_evidence(
6243                frame_idx,
6244                degradation_start,
6245                0.0,
6246                conformal_prediction.as_ref(),
6247                &load_snapshot,
6248            );
6249            crate::debug_trace!(
6250                "frame skipped: budget exhausted (degradation={})",
6251                self.budget.degradation().as_str()
6252            );
6253            debug!(
6254                degradation = self.budget.degradation().as_str(),
6255                "frame skipped: budget exhausted before render"
6256            );
6257            // Keep dirty=true: the UI update was never presented, so a
6258            // future frame must still pick it up.
6259            return Ok(());
6260        }
6261
6262        let auto_bounds = self.writer.inline_auto_bounds();
6263        let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
6264        let mut should_measure = needs_measure;
6265        if auto_bounds.is_some()
6266            && let Some(state) = self.inline_auto_remeasure.as_mut()
6267        {
6268            let decision = state.sampler.decide(Instant::now());
6269            if decision.should_sample {
6270                should_measure = true;
6271            }
6272        } else {
6273            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6274        }
6275
6276        // --- Render phase ---
6277        let render_start = Instant::now();
6278        if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
6279            let measure_height = if needs_measure {
6280                self.writer.render_height_hint().max(1)
6281            } else {
6282                max_height.max(1)
6283            };
6284            let (measure_buffer, _) = self.render_measure_buffer(measure_height);
6285            let measured_height = measure_buffer.content_height();
6286            let clamped = measured_height.clamp(min_height, max_height);
6287            let previous_height = self.writer.auto_ui_height();
6288            self.writer.set_auto_ui_height(clamped);
6289            if let Some(state) = self.inline_auto_remeasure.as_mut() {
6290                let threshold = state.config.change_threshold_rows;
6291                let violated = previous_height
6292                    .map(|prev| prev.abs_diff(clamped) >= threshold)
6293                    .unwrap_or(false);
6294                state.sampler.observe(violated);
6295            }
6296        }
6297        if auto_bounds.is_some()
6298            && let Some(state) = self.inline_auto_remeasure.as_ref()
6299        {
6300            let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
6301            crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
6302        }
6303
6304        let frame_height = self.writer.render_height_hint().max(1);
6305        let _frame_span = info_span!(
6306            "ftui.render.frame",
6307            width = self.width,
6308            height = frame_height,
6309            duration_us = tracing::field::Empty
6310        )
6311        .entered();
6312        let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
6313        self.update_widget_refresh_plan(frame_idx);
6314        let render_elapsed = render_start.elapsed();
6315        let mut present_elapsed = Duration::ZERO;
6316        let mut presented = false;
6317
6318        // Check if render phase overspent its budget
6319        let render_budget = self.budget.phase_budgets().render;
6320        if render_elapsed > render_budget {
6321            debug!(
6322                render_ms = render_elapsed.as_millis() as u32,
6323                budget_ms = render_budget.as_millis() as u32,
6324                "render phase exceeded budget"
6325            );
6326            // With the load governor active, the controller decides degradation
6327            // from measured frame history at the next frame boundary. The
6328            // legacy path keeps its immediate threshold fallback.
6329            if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
6330                self.budget.degrade();
6331            }
6332        }
6333
6334        // --- Present phase ---
6335        if !self.budget.exhausted() {
6336            let present_start = Instant::now();
6337            {
6338                let _present_span = debug_span!("ftui.render.present").entered();
6339                self.writer
6340                    .present_ui_owned(buffer, cursor, cursor_visible)?;
6341            }
6342            presented = true;
6343            present_elapsed = present_start.elapsed();
6344
6345            let present_budget = self.budget.phase_budgets().present;
6346            if present_elapsed > present_budget {
6347                debug!(
6348                    present_ms = present_elapsed.as_millis() as u32,
6349                    budget_ms = present_budget.as_millis() as u32,
6350                    "present phase exceeded budget"
6351                );
6352            }
6353        } else {
6354            debug!(
6355                degradation = self.budget.degradation().as_str(),
6356                elapsed_ms = self.budget.elapsed().as_millis() as u32,
6357                "frame present skipped: budget exhausted after render"
6358            );
6359        }
6360
6361        if let Some(ref frame_timing) = self.frame_timing {
6362            let update_us = self.last_update_us.unwrap_or(0);
6363            let render_us = render_elapsed.as_micros() as u64;
6364            let present_us = present_elapsed.as_micros() as u64;
6365            let diff_us = if presented {
6366                self.writer
6367                    .take_last_present_timings()
6368                    .map(|timings| timings.diff_us)
6369                    .unwrap_or(0)
6370            } else {
6371                let _ = self.writer.take_last_present_timings();
6372                0
6373            };
6374            let total_us = update_us
6375                .saturating_add(render_us)
6376                .saturating_add(present_us);
6377            let timing = FrameTiming {
6378                frame_idx,
6379                update_us,
6380                render_us,
6381                diff_us,
6382                present_us,
6383                total_us,
6384            };
6385            frame_timing.sink.record_frame(&timing);
6386        }
6387
6388        let frame_time = render_elapsed.saturating_add(present_elapsed);
6389        self.budget.record_frame_time(frame_time);
6390        let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
6391
6392        if let (Some(predictor), Some(prediction)) = (
6393            self.conformal_predictor.as_mut(),
6394            conformal_prediction.as_ref(),
6395        ) {
6396            let diff_strategy = self
6397                .writer
6398                .last_diff_strategy()
6399                .unwrap_or(DiffStrategy::Full);
6400            let key = BucketKey::from_context(
6401                self.writer.screen_mode(),
6402                diff_strategy,
6403                self.width,
6404                frame_height,
6405            );
6406            predictor.observe(key, prediction.y_hat, frame_time_us);
6407        }
6408        self.last_frame_time_us = Some(frame_time_us);
6409        let load_snapshot = self.update_load_governor_snapshot(
6410            frame_idx,
6411            frame_time_us,
6412            conformal_prediction.as_ref(),
6413        );
6414        self.emit_budget_evidence(
6415            frame_idx,
6416            degradation_start,
6417            frame_time_us,
6418            conformal_prediction.as_ref(),
6419            &load_snapshot,
6420        );
6421
6422        // Only clear dirty when the frame was actually presented.
6423        // If present was skipped (budget exhausted after render), the UI
6424        // update was never shown and must be retried on the next frame.
6425        if presented {
6426            self.dirty = false;
6427        }
6428
6429        Ok(())
6430    }
6431
6432    fn update_load_governor_snapshot(
6433        &mut self,
6434        _frame_idx: u64,
6435        frame_time_us: f64,
6436        conformal_prediction: Option<&ConformalPrediction>,
6437    ) -> LoadGovernorSnapshot {
6438        let budget_us = conformal_prediction
6439            .map(|prediction| prediction.budget_us)
6440            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6441        let resize_stats = self.resize_coalescer.stats();
6442        self.load_governor.observe(LoadGovernorObservation {
6443            frame_time_us,
6444            budget_us,
6445            degradation: self.budget.degradation(),
6446            queue: crate::effect_system::queue_telemetry(),
6447            // Only meaningful when the coalescer actually runs: in legacy
6448            // Immediate mode `tick_at` never fires, so a single Burst entry
6449            // would otherwise pin the governor at SoftOverload forever.
6450            resize_coalescing_active: self.resize_behavior.uses_coalescer()
6451                && (resize_stats.has_pending
6452                    || !matches!(resize_stats.regime, crate::resize_coalescer::Regime::Steady)),
6453            strict_semantics_violation: false,
6454        })
6455    }
6456
6457    fn emit_budget_evidence(
6458        &self,
6459        frame_idx: u64,
6460        degradation_start: DegradationLevel,
6461        frame_time_us: f64,
6462        conformal_prediction: Option<&ConformalPrediction>,
6463        load_snapshot: &LoadGovernorSnapshot,
6464    ) {
6465        let Some(telemetry) = self.budget.telemetry() else {
6466            set_budget_snapshot(None);
6467            return;
6468        };
6469
6470        let budget_us = conformal_prediction
6471            .map(|prediction| prediction.budget_us)
6472            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6473        let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
6474        let degradation_after = self.budget.degradation();
6475
6476        let evidence = BudgetDecisionEvidence {
6477            frame_idx,
6478            decision: BudgetDecisionEvidence::decision_from_levels(
6479                degradation_start,
6480                degradation_after,
6481            ),
6482            controller_decision: telemetry.last_decision,
6483            degradation_before: degradation_start,
6484            degradation_after,
6485            frame_time_us,
6486            budget_us,
6487            pid_output: telemetry.pid_output,
6488            pid_p: telemetry.pid_p,
6489            pid_i: telemetry.pid_i,
6490            pid_d: telemetry.pid_d,
6491            e_value: telemetry.e_value,
6492            frames_observed: telemetry.frames_observed,
6493            frames_since_change: telemetry.frames_since_change,
6494            in_warmup: telemetry.in_warmup,
6495            controller_reason: telemetry.decision_reason,
6496            load_governor: *load_snapshot,
6497            conformal,
6498        };
6499
6500        let conformal_snapshot = evidence
6501            .conformal
6502            .as_ref()
6503            .map(|snapshot| ConformalSnapshot {
6504                bucket_key: snapshot.bucket_key.clone(),
6505                sample_count: snapshot.n_b,
6506                upper_us: snapshot.upper_us,
6507                risk: snapshot.risk,
6508            });
6509        set_budget_snapshot(Some(BudgetDecisionSnapshot {
6510            frame_idx: evidence.frame_idx,
6511            decision: evidence.decision,
6512            controller_decision: evidence.controller_decision,
6513            degradation_before: evidence.degradation_before,
6514            degradation_after: evidence.degradation_after,
6515            frame_time_us: evidence.frame_time_us,
6516            budget_us: evidence.budget_us,
6517            pid_output: evidence.pid_output,
6518            e_value: evidence.e_value,
6519            frames_observed: evidence.frames_observed,
6520            frames_since_change: evidence.frames_since_change,
6521            in_warmup: evidence.in_warmup,
6522            conformal: conformal_snapshot,
6523        }));
6524
6525        if let Some(ref sink) = self.evidence_sink {
6526            let _ = sink.write_jsonl(&evidence.to_jsonl());
6527        }
6528    }
6529
6530    fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
6531        if !self.widget_refresh_config.enabled {
6532            self.widget_refresh_plan.clear();
6533            return;
6534        }
6535
6536        let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
6537        let degradation = self.budget.degradation();
6538        self.widget_refresh_plan.recompute(
6539            frame_idx,
6540            budget_us,
6541            degradation,
6542            &self.widget_signals,
6543            &self.widget_refresh_config,
6544        );
6545
6546        if let Some(ref sink) = self.evidence_sink {
6547            let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
6548        }
6549    }
6550
6551    fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
6552        // Reset the per-frame arena so widgets get fresh scratch space.
6553        self.frame_arena.reset();
6554
6555        // Note: Frame borrows the pool and links from writer.
6556        // We scope it so it drops before we call present_ui (which needs exclusive writer access).
6557        let buffer = self.writer.take_render_buffer(self.width, frame_height);
6558        let (pool, links) = self.writer.pool_and_links_mut();
6559        let mut frame = Frame::from_buffer(buffer, pool);
6560        frame.set_degradation(self.budget.degradation());
6561        frame.set_links(links);
6562        frame.set_widget_budget(self.widget_refresh_plan.as_budget());
6563        frame.set_arena(&self.frame_arena);
6564
6565        let view_start = Instant::now();
6566        let _view_span = debug_span!(
6567            "ftui.program.view",
6568            duration_us = tracing::field::Empty,
6569            widget_count = tracing::field::Empty
6570        )
6571        .entered();
6572        self.model.view(&mut frame);
6573        self.widget_signals = frame.take_widget_signals();
6574        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6575        // widget_count would require tracking in Frame
6576
6577        (frame.buffer, frame.cursor_position, frame.cursor_visible)
6578    }
6579
6580    fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
6581        let Some(ref sink) = self.evidence_sink else {
6582            return;
6583        };
6584
6585        let config = self.fairness_guard.config();
6586        if !self.fairness_config_logged {
6587            let config_entry = FairnessConfigEvidence {
6588                enabled: config.enabled,
6589                input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6590                dominance_threshold: config.dominance_threshold,
6591                fairness_threshold: config.fairness_threshold,
6592            };
6593            let _ = sink.write_jsonl(&config_entry.to_jsonl());
6594            self.fairness_config_logged = true;
6595        }
6596
6597        let evidence = FairnessDecisionEvidence {
6598            frame_idx: self.frame_idx,
6599            decision: if decision.should_process {
6600                "allow"
6601            } else {
6602                "yield"
6603            },
6604            reason: decision.reason.as_str(),
6605            pending_input_latency_ms: decision
6606                .pending_input_latency
6607                .map(|latency| latency.as_millis() as u64),
6608            jain_index: decision.jain_index,
6609            resize_dominance_count: dominance_count,
6610            dominance_threshold: config.dominance_threshold,
6611            fairness_threshold: config.fairness_threshold,
6612            input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6613        };
6614
6615        let _ = sink.write_jsonl(&evidence.to_jsonl());
6616    }
6617
6618    fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
6619        // Reset the per-frame arena for measurement pass.
6620        self.frame_arena.reset();
6621
6622        let pool = self.writer.pool_mut();
6623        let mut frame = Frame::new(self.width, frame_height, pool);
6624        frame.set_degradation(self.budget.degradation());
6625        frame.set_arena(&self.frame_arena);
6626
6627        let view_start = Instant::now();
6628        let _view_span = debug_span!(
6629            "ftui.program.view",
6630            duration_us = tracing::field::Empty,
6631            widget_count = tracing::field::Empty
6632        )
6633        .entered();
6634        self.model.view(&mut frame);
6635        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6636
6637        (frame.buffer, frame.cursor_position)
6638    }
6639
6640    /// Calculate the effective poll timeout.
6641    fn effective_timeout(&self) -> Duration {
6642        if let Some(tick_rate) = self.tick_rate {
6643            let elapsed = self.last_tick.elapsed();
6644            let mut timeout = tick_rate.saturating_sub(elapsed);
6645            if self.resize_behavior.uses_coalescer()
6646                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6647            {
6648                timeout = timeout.min(resize_timeout);
6649            }
6650            timeout
6651        } else {
6652            let mut timeout = self.poll_timeout;
6653            if self.resize_behavior.uses_coalescer()
6654                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6655            {
6656                timeout = timeout.min(resize_timeout);
6657            }
6658            timeout
6659        }
6660    }
6661
6662    /// Check if we should send a tick.
6663    fn should_tick(&mut self) -> bool {
6664        if let Some(tick_rate) = self.tick_rate
6665            && self.last_tick.elapsed() >= tick_rate
6666        {
6667            self.last_tick = Instant::now();
6668            return true;
6669        }
6670        false
6671    }
6672
6673    fn process_resize_coalescer(&mut self) -> io::Result<()> {
6674        if !self.resize_behavior.uses_coalescer() {
6675            return Ok(());
6676        }
6677
6678        // Check fairness: if input is starving, skip resize application this cycle.
6679        // This ensures input events are processed before resize is finalized.
6680        let dominance_count = self.fairness_guard.resize_dominance_count();
6681        let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
6682        self.emit_fairness_evidence(&fairness_decision, dominance_count);
6683        if !fairness_decision.should_process {
6684            debug!(
6685                reason = ?fairness_decision.reason,
6686                pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
6687                "Resize yielding to input for fairness"
6688            );
6689            // Skip resize application this cycle to allow input processing.
6690            return Ok(());
6691        }
6692
6693        let action = self.resize_coalescer.tick();
6694        let resize_snapshot =
6695            self.resize_coalescer
6696                .logs()
6697                .last()
6698                .map(|entry| ResizeDecisionSnapshot {
6699                    event_idx: entry.event_idx,
6700                    action: entry.action,
6701                    dt_ms: entry.dt_ms,
6702                    event_rate: entry.event_rate,
6703                    regime: entry.regime,
6704                    pending_size: entry.pending_size,
6705                    applied_size: entry.applied_size,
6706                    time_since_render_ms: entry.time_since_render_ms,
6707                    bocpd: self
6708                        .resize_coalescer
6709                        .bocpd()
6710                        .and_then(|detector| detector.last_evidence().cloned()),
6711                });
6712        set_resize_snapshot(resize_snapshot);
6713
6714        match action {
6715            CoalesceAction::ApplyResize {
6716                width,
6717                height,
6718                coalesce_time,
6719                forced_by_deadline,
6720            } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
6721            _ => Ok(()),
6722        }
6723    }
6724
6725    fn apply_resize(
6726        &mut self,
6727        width: u16,
6728        height: u16,
6729        coalesce_time: Duration,
6730        forced_by_deadline: bool,
6731    ) -> io::Result<()> {
6732        // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
6733        let width = width.max(1);
6734        let height = height.max(1);
6735        self.width = width;
6736        self.height = height;
6737        self.writer.set_size(width, height);
6738        info!(
6739            width = width,
6740            height = height,
6741            coalesce_ms = coalesce_time.as_millis() as u64,
6742            forced = forced_by_deadline,
6743            "Resize applied"
6744        );
6745
6746        let msg = M::Message::from(Event::Resize { width, height });
6747        let start = Instant::now();
6748        let cmd = self.model.update(msg);
6749        let elapsed_us = start.elapsed().as_micros() as u64;
6750        self.last_update_us = Some(elapsed_us);
6751        self.mark_dirty();
6752        self.execute_cmd(cmd)?;
6753        if self.running && self.dirty {
6754            self.reconcile_subscriptions();
6755        }
6756        Ok(())
6757    }
6758
6759    // removed: resize placeholder rendering (continuous reflow preferred)
6760
6761    /// Get a reference to the model.
6762    pub fn model(&self) -> &M {
6763        &self.model
6764    }
6765
6766    /// Get a mutable reference to the model.
6767    pub fn model_mut(&mut self) -> &mut M {
6768        &mut self.model
6769    }
6770
6771    /// Check if the program is running.
6772    pub fn is_running(&self) -> bool {
6773        self.running
6774    }
6775
6776    /// Get the current tick rate, if one has been installed.
6777    #[must_use]
6778    pub const fn tick_rate(&self) -> Option<Duration> {
6779        self.tick_rate
6780    }
6781
6782    /// Get the number of commands actually executed by the runtime.
6783    #[must_use]
6784    pub const fn executed_cmd_count(&self) -> usize {
6785        self.executed_cmd_count
6786    }
6787
6788    /// Request a quit.
6789    pub fn quit(&mut self) {
6790        self.running = false;
6791    }
6792
6793    /// Get a reference to the state registry, if configured.
6794    pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
6795        self.state_registry.as_ref()
6796    }
6797
6798    /// Check if state persistence is enabled.
6799    pub fn has_persistence(&self) -> bool {
6800        self.state_registry.is_some()
6801    }
6802
6803    /// Query the current tick strategy's debug statistics.
6804    ///
6805    /// Returns key-value pairs describing the strategy's internal state
6806    /// (e.g. strategy name, divisors, confidence, transition counts).
6807    /// Returns an empty vec if no tick strategy is configured.
6808    #[must_use]
6809    pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
6810        self.tick_strategy
6811            .as_ref()
6812            .map(|s| s.debug_stats())
6813            .unwrap_or_default()
6814    }
6815
6816    /// Trigger a manual save of widget state.
6817    ///
6818    /// Returns the result of the flush operation, or `Ok(false)` if
6819    /// persistence is not configured.
6820    pub fn trigger_save(&mut self) -> StorageResult<bool> {
6821        if let Some(registry) = &self.state_registry {
6822            registry.flush()
6823        } else {
6824            Ok(false)
6825        }
6826    }
6827
6828    /// Trigger a manual load of widget state.
6829    ///
6830    /// Returns the number of entries loaded, or `Ok(0)` if persistence
6831    /// is not configured.
6832    pub fn trigger_load(&mut self) -> StorageResult<usize> {
6833        if let Some(registry) = &self.state_registry {
6834            registry.load()
6835        } else {
6836            Ok(0)
6837        }
6838    }
6839
6840    fn mark_dirty(&mut self) {
6841        self.dirty = true;
6842    }
6843
6844    fn check_locale_change(&mut self) {
6845        let version = self.locale_context.version();
6846        if version != self.locale_version {
6847            self.locale_version = version;
6848            self.mark_dirty();
6849        }
6850    }
6851
6852    /// Mark the UI as needing redraw.
6853    pub fn request_redraw(&mut self) {
6854        self.mark_dirty();
6855    }
6856
6857    /// Request a re-measure of inline auto UI height on next render.
6858    pub fn request_ui_height_remeasure(&mut self) {
6859        if self.writer.inline_auto_bounds().is_some() {
6860            self.writer.clear_auto_ui_height();
6861            if let Some(state) = self.inline_auto_remeasure.as_mut() {
6862                state.reset();
6863            }
6864            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6865            self.mark_dirty();
6866        }
6867    }
6868
6869    /// Start recording events into a macro.
6870    ///
6871    /// If already recording, the current recording is discarded and a new one starts.
6872    /// The current terminal size is captured as metadata.
6873    pub fn start_recording(&mut self, name: impl Into<String>) {
6874        let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
6875        recorder.start();
6876        self.event_recorder = Some(recorder);
6877    }
6878
6879    /// Stop recording and return the recorded macro, if any.
6880    ///
6881    /// Returns `None` if not currently recording.
6882    pub fn stop_recording(&mut self) -> Option<InputMacro> {
6883        self.event_recorder.take().map(EventRecorder::finish)
6884    }
6885
6886    /// Check if event recording is active.
6887    pub fn is_recording(&self) -> bool {
6888        self.event_recorder
6889            .as_ref()
6890            .is_some_and(EventRecorder::is_recording)
6891    }
6892}
6893
6894/// Builder for creating and running programs.
6895pub struct App;
6896
6897impl App {
6898    /// Create a new app builder with the given model.
6899    #[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
6900    pub fn new<M: Model>(model: M) -> AppBuilder<M> {
6901        AppBuilder {
6902            model,
6903            config: ProgramConfig::default(),
6904        }
6905    }
6906
6907    /// Create a fullscreen app.
6908    pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
6909        AppBuilder {
6910            model,
6911            config: ProgramConfig::fullscreen(),
6912        }
6913    }
6914
6915    /// Create an inline app with the given height.
6916    pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
6917        AppBuilder {
6918            model,
6919            config: ProgramConfig::inline(height),
6920        }
6921    }
6922
6923    /// Create an inline app with automatic UI height.
6924    pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
6925        AppBuilder {
6926            model,
6927            config: ProgramConfig::inline_auto(min_height, max_height),
6928        }
6929    }
6930
6931    /// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
6932    ///
6933    /// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
6934    /// so that `view_string()` output is rendered through the standard kernel pipeline.
6935    pub fn string_model<S: crate::string_model::StringModel>(
6936        model: S,
6937    ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
6938        AppBuilder {
6939            model: crate::string_model::StringModelAdapter::new(model),
6940            config: ProgramConfig::fullscreen(),
6941        }
6942    }
6943}
6944
6945/// Builder for configuring and running programs.
6946#[must_use]
6947pub struct AppBuilder<M: Model> {
6948    model: M,
6949    config: ProgramConfig,
6950}
6951
6952impl<M: Model> AppBuilder<M> {
6953    /// Set the screen mode.
6954    pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6955        self.config.screen_mode = mode;
6956        self
6957    }
6958
6959    /// Set the UI anchor.
6960    pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6961        self.config.ui_anchor = anchor;
6962        self
6963    }
6964
6965    /// Force mouse capture on.
6966    pub fn with_mouse(mut self) -> Self {
6967        self.config.mouse_capture_policy = MouseCapturePolicy::On;
6968        self
6969    }
6970
6971    /// Set mouse capture policy for this app.
6972    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6973        self.config.mouse_capture_policy = policy;
6974        self
6975    }
6976
6977    /// Force mouse capture enabled/disabled for this app.
6978    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6979        self.config.mouse_capture_policy = if enabled {
6980            MouseCapturePolicy::On
6981        } else {
6982            MouseCapturePolicy::Off
6983        };
6984        self
6985    }
6986
6987    /// Set the frame budget configuration.
6988    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6989        self.config.budget = budget;
6990        self
6991    }
6992
6993    /// Set the runtime load-governor configuration.
6994    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
6995        self.config.load_governor = config;
6996        self
6997    }
6998
6999    /// Disable the adaptive load governor for this app.
7000    pub fn without_load_governor(mut self) -> Self {
7001        self.config.load_governor = LoadGovernorConfig::disabled();
7002        self
7003    }
7004
7005    /// Set the evidence JSONL sink configuration.
7006    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
7007        self.config.evidence_sink = config;
7008        self
7009    }
7010
7011    /// Set the render-trace recorder configuration.
7012    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
7013        self.config.render_trace = config;
7014        self
7015    }
7016
7017    /// Set the widget refresh selection configuration.
7018    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
7019        self.config.widget_refresh = config;
7020        self
7021    }
7022
7023    /// Set the effect queue scheduling configuration.
7024    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
7025        self.config.effect_queue = config;
7026        self
7027    }
7028
7029    /// Enable inline auto UI height remeasurement.
7030    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
7031        self.config.inline_auto_remeasure = Some(config);
7032        self
7033    }
7034
7035    /// Disable inline auto UI height remeasurement.
7036    pub fn without_inline_auto_remeasure(mut self) -> Self {
7037        self.config.inline_auto_remeasure = None;
7038        self
7039    }
7040
7041    /// Set the locale context used for rendering.
7042    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
7043        self.config.locale_context = locale_context;
7044        self
7045    }
7046
7047    /// Set the base locale used for rendering.
7048    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
7049        self.config.locale_context = LocaleContext::new(locale);
7050        self
7051    }
7052
7053    /// Set the resize coalescer configuration.
7054    pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
7055        self.config.resize_coalescer = config;
7056        self
7057    }
7058
7059    /// Set the resize handling behavior.
7060    pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
7061        self.config.resize_behavior = behavior;
7062        self
7063    }
7064
7065    /// Toggle legacy immediate-resize behavior for migration.
7066    pub fn legacy_resize(mut self, enabled: bool) -> Self {
7067        if enabled {
7068            self.config.resize_behavior = ResizeBehavior::Immediate;
7069        }
7070        self
7071    }
7072
7073    /// Set the tick strategy for selective background screen ticking.
7074    pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
7075        self.config.tick_strategy = Some(strategy);
7076        self
7077    }
7078
7079    /// Run the application using the legacy Crossterm backend.
7080    #[cfg(feature = "crossterm-compat")]
7081    pub fn run(self) -> io::Result<()>
7082    where
7083        M::Message: Send + 'static,
7084    {
7085        let mut program = Program::with_config(self.model, self.config)?;
7086        let result = program.run();
7087        if let Err(ref err) = result
7088            && let Some(signal) = signal_termination_from_error(err)
7089        {
7090            drop(program);
7091            std::process::exit(128 + signal);
7092        }
7093        result
7094    }
7095
7096    /// Run the application using the native TTY backend.
7097    #[cfg(all(feature = "native-backend", unix))]
7098    pub fn run_native(self) -> io::Result<()>
7099    where
7100        M::Message: Send + 'static,
7101    {
7102        let mut program = Program::with_native_backend(self.model, self.config)?;
7103        let result = program.run();
7104        if let Err(ref err) = result
7105            && let Some(signal) = signal_termination_from_error(err)
7106        {
7107            drop(program);
7108            std::process::exit(128 + signal);
7109        }
7110        result
7111    }
7112
7113    /// Run the application using the legacy Crossterm backend.
7114    #[cfg(not(feature = "crossterm-compat"))]
7115    pub fn run(self) -> io::Result<()>
7116    where
7117        M::Message: Send + 'static,
7118    {
7119        let _ = (self.model, self.config);
7120        Err(io::Error::new(
7121            io::ErrorKind::Unsupported,
7122            "enable `crossterm-compat` feature to use AppBuilder::run()",
7123        ))
7124    }
7125
7126    /// Run the application using the native TTY backend.
7127    ///
7128    /// On non-Unix targets the native backend is unavailable; call
7129    /// [`AppBuilder::run`] (with `crossterm-compat`) instead.
7130    #[cfg(any(not(feature = "native-backend"), not(unix)))]
7131    pub fn run_native(self) -> io::Result<()>
7132    where
7133        M::Message: Send + 'static,
7134    {
7135        let _ = (self.model, self.config);
7136        // Prefer the platform-level message: a Windows user without
7137        // `native-backend` enabled would otherwise be told to enable the
7138        // feature, only to discover after rebuilding that it's still
7139        // Unix-only. Pointing them at crossterm-compat up front avoids the
7140        // two-step debug.
7141        #[cfg(not(unix))]
7142        let msg = "AppBuilder::run_native() is Unix-only; use AppBuilder::run() (crossterm-compat) on this platform";
7143        #[cfg(all(unix, not(feature = "native-backend")))]
7144        let msg = "enable `native-backend` feature to use AppBuilder::run_native()";
7145        Err(io::Error::new(io::ErrorKind::Unsupported, msg))
7146    }
7147}
7148
7149// =============================================================================
7150// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
7151// =============================================================================
7152//
7153// # M/G/1 Queueing Model for Event Batching
7154//
7155// ## Problem
7156//
7157// The event loop must balance two objectives:
7158// 1. **Low latency**: Process events quickly (small batch window τ).
7159// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
7160//
7161// ## Model
7162//
7163// We model the event loop as an M/G/1 queue:
7164// - Events arrive at rate λ (Poisson process, reasonable for human input).
7165// - Service time S has mean E[S] and variance Var[S] (render + present).
7166// - Utilization ρ = λ·E[S] must be < 1 for stability.
7167//
7168// ## Pollaczek–Khinchine Mean Waiting Time
7169//
7170// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
7171// where E[S²] = Var[S] + E[S]².
7172//
7173// ## Optimal Batch Window τ
7174//
7175// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
7176// the per-frame render cost. The effective per-event latency is:
7177//
7178//   L(τ) = τ/2 + E[S]
7179//         (waiting in batch)  (service)
7180//
7181// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
7182// giving utilization ρ_eff = E[S]/τ.
7183//
7184// Minimizing L(τ) subject to ρ_eff < 1:
7185//   L(τ) = τ/2 + E[S]
7186//   dL/dτ = 1/2  (always positive, so smaller τ is always better for latency)
7187//
7188// But we need ρ_eff < 1, so τ > E[S].
7189//
7190// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
7191// where headroom_factor provides margin (typically 1.5–2.0).
7192//
7193// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
7194// where λ_target is the max frame rate we want to sustain.
7195//
7196// ## Failure Modes
7197//
7198// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
7199//    (degrade to lower frame rate), or drop stale events.
7200// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
7201//    exponential moving average of λ smooths this; high burst periods
7202//    temporarily increase τ.
7203// 3. **Variable service time**: Render complexity varies per frame. Using
7204//    EMA of E[S] tracks this adaptively.
7205//
7206// ## Observable Telemetry
7207//
7208// - λ_est: Exponential moving average of inter-arrival times.
7209// - es_est: Exponential moving average of service (render) times.
7210// - ρ_est: λ_est × es_est (estimated utilization).
7211
7212/// Adaptive batch window controller based on M/G/1 queueing model.
7213///
7214/// Estimates arrival rate λ and service time `E[S]` from observations,
7215/// then computes the optimal batch window τ to maintain stability
7216/// (ρ < 1) while minimizing latency.
7217#[derive(Debug, Clone)]
7218pub struct BatchController {
7219    /// Exponential moving average of inter-arrival time (seconds).
7220    ema_inter_arrival_s: f64,
7221    /// Exponential moving average of service time (seconds).
7222    ema_service_s: f64,
7223    /// EMA smoothing factor (0..1). Higher = more responsive.
7224    alpha: f64,
7225    /// Minimum batch window (floor).
7226    tau_min_s: f64,
7227    /// Maximum batch window (cap for responsiveness).
7228    tau_max_s: f64,
7229    /// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
7230    headroom: f64,
7231    /// Last event arrival timestamp.
7232    last_arrival: Option<Instant>,
7233    /// Number of observations.
7234    observations: u64,
7235}
7236
7237impl BatchController {
7238    /// Create a new controller with sensible defaults.
7239    ///
7240    /// - `alpha`: EMA smoothing (default 0.2)
7241    /// - `tau_min`: minimum batch window (default 1ms)
7242    /// - `tau_max`: maximum batch window (default 50ms)
7243    /// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
7244    pub fn new() -> Self {
7245        Self {
7246            ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
7247            ema_service_s: 0.002,     // assume 2ms render initially
7248            alpha: 0.2,
7249            tau_min_s: 0.001, // 1ms floor
7250            tau_max_s: 0.050, // 50ms cap
7251            headroom: 2.0,
7252            last_arrival: None,
7253            observations: 0,
7254        }
7255    }
7256
7257    /// Record an event arrival, updating the inter-arrival estimate.
7258    pub fn observe_arrival(&mut self, now: Instant) {
7259        if let Some(last) = self.last_arrival {
7260            let dt = now.saturating_duration_since(last).as_secs_f64();
7261            if dt > 0.0 && dt < 10.0 {
7262                // Guard against stale gaps (e.g., app was suspended)
7263                self.ema_inter_arrival_s =
7264                    self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
7265                self.observations += 1;
7266            }
7267        }
7268        self.last_arrival = Some(now);
7269    }
7270
7271    /// Record a service (render) time observation.
7272    pub fn observe_service(&mut self, duration: Duration) {
7273        let dt = duration.as_secs_f64();
7274        if (0.0..10.0).contains(&dt) {
7275            self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
7276        }
7277    }
7278
7279    /// Estimated arrival rate λ (events/second).
7280    #[inline]
7281    pub fn lambda_est(&self) -> f64 {
7282        if self.ema_inter_arrival_s > 0.0 {
7283            1.0 / self.ema_inter_arrival_s
7284        } else {
7285            0.0
7286        }
7287    }
7288
7289    /// Estimated service time `E[S]` (seconds).
7290    #[inline]
7291    pub fn service_est_s(&self) -> f64 {
7292        self.ema_service_s
7293    }
7294
7295    /// Estimated utilization ρ = λ × `E[S]`.
7296    #[inline]
7297    pub fn rho_est(&self) -> f64 {
7298        self.lambda_est() * self.ema_service_s
7299    }
7300
7301    /// Compute the optimal batch window τ (seconds).
7302    ///
7303    /// τ = clamp(`E[S]` × headroom, τ_min, τ_max)
7304    ///
7305    /// When ρ approaches 1, τ increases to maintain stability.
7306    pub fn tau_s(&self) -> f64 {
7307        let base = self.ema_service_s * self.headroom;
7308        base.clamp(self.tau_min_s, self.tau_max_s)
7309    }
7310
7311    /// Compute the optimal batch window as a Duration.
7312    pub fn tau(&self) -> Duration {
7313        Duration::from_secs_f64(self.tau_s())
7314    }
7315
7316    /// Check if the system is stable (ρ < 1).
7317    #[inline]
7318    pub fn is_stable(&self) -> bool {
7319        self.rho_est() < 1.0
7320    }
7321
7322    /// Number of observations recorded.
7323    #[inline]
7324    pub fn observations(&self) -> u64 {
7325        self.observations
7326    }
7327}
7328
7329impl Default for BatchController {
7330    fn default() -> Self {
7331        Self::new()
7332    }
7333}
7334
7335#[cfg(test)]
7336mod tests {
7337    use super::*;
7338    use ftui_core::terminal_capabilities::TerminalCapabilities;
7339    use ftui_layout::PaneDragResizeEffect;
7340    use ftui_render::buffer::Buffer;
7341    use ftui_render::cell::Cell;
7342    use ftui_render::diff_strategy::DiffStrategy;
7343    use ftui_render::frame::CostEstimateSource;
7344    use ftui_render::frame_guardrails::{MemoryBudgetConfig, QueueConfig};
7345    use serde_json::Value;
7346    use std::collections::{HashMap, VecDeque};
7347    use std::path::PathBuf;
7348    use std::sync::mpsc;
7349    use std::sync::{
7350        Arc,
7351        atomic::{AtomicUsize, Ordering},
7352    };
7353
7354    // Simple test model
7355    struct TestModel {
7356        value: i32,
7357    }
7358
7359    #[derive(Debug)]
7360    enum TestMsg {
7361        Increment,
7362        Decrement,
7363        Quit,
7364    }
7365
7366    impl From<Event> for TestMsg {
7367        fn from(_event: Event) -> Self {
7368            TestMsg::Increment
7369        }
7370    }
7371
7372    impl Model for TestModel {
7373        type Message = TestMsg;
7374
7375        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7376            match msg {
7377                TestMsg::Increment => {
7378                    self.value += 1;
7379                    Cmd::none()
7380                }
7381                TestMsg::Decrement => {
7382                    self.value -= 1;
7383                    Cmd::none()
7384                }
7385                TestMsg::Quit => Cmd::quit(),
7386            }
7387        }
7388
7389        fn view(&self, _frame: &mut Frame) {
7390            // No-op for tests
7391        }
7392    }
7393
7394    #[test]
7395    fn cmd_none() {
7396        let cmd: Cmd<TestMsg> = Cmd::none();
7397        assert!(matches!(cmd, Cmd::None));
7398    }
7399
7400    #[test]
7401    fn cmd_quit() {
7402        let cmd: Cmd<TestMsg> = Cmd::quit();
7403        assert!(matches!(cmd, Cmd::Quit));
7404    }
7405
7406    #[test]
7407    fn cmd_msg() {
7408        let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7409        assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
7410    }
7411
7412    #[test]
7413    fn cmd_batch_empty() {
7414        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
7415        assert!(matches!(cmd, Cmd::None));
7416    }
7417
7418    #[test]
7419    fn cmd_batch_single() {
7420        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
7421        assert!(matches!(cmd, Cmd::Quit));
7422    }
7423
7424    #[test]
7425    fn cmd_batch_multiple() {
7426        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
7427        assert!(matches!(cmd, Cmd::Batch(_)));
7428    }
7429
7430    #[test]
7431    fn cmd_sequence_empty() {
7432        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
7433        assert!(matches!(cmd, Cmd::None));
7434    }
7435
7436    #[test]
7437    fn cmd_tick() {
7438        let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
7439        assert!(matches!(cmd, Cmd::Tick(_)));
7440    }
7441
7442    #[test]
7443    fn cmd_task() {
7444        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7445        assert!(matches!(cmd, Cmd::Task(..)));
7446    }
7447
7448    #[test]
7449    fn cmd_debug_format() {
7450        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7451        let debug = format!("{cmd:?}");
7452        assert_eq!(
7453            debug,
7454            "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
7455        );
7456    }
7457
7458    #[test]
7459    fn model_subscriptions_default_empty() {
7460        let model = TestModel { value: 0 };
7461        let subs = model.subscriptions();
7462        assert!(subs.is_empty());
7463    }
7464
7465    #[test]
7466    fn program_config_default() {
7467        let config = ProgramConfig::default();
7468        assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
7469        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7470        assert!(!config.resolved_mouse_capture());
7471        assert!(config.bracketed_paste);
7472        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
7473        assert!(config.inline_auto_remeasure.is_none());
7474        assert!(config.conformal_config.is_none());
7475        assert!(config.diff_config.bayesian_enabled);
7476        assert!(config.diff_config.dirty_rows_enabled);
7477        assert!(!config.resize_coalescer.enable_bocpd);
7478        assert!(!config.effect_queue.enabled);
7479        assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
7480        assert_eq!(
7481            config.immediate_drain.max_burst_duration,
7482            Duration::from_millis(2)
7483        );
7484        assert_eq!(
7485            config.immediate_drain.backoff_timeout,
7486            Duration::from_millis(1)
7487        );
7488        assert_eq!(
7489            config.resize_coalescer.steady_delay_ms,
7490            CoalescerConfig::default().steady_delay_ms
7491        );
7492    }
7493
7494    #[test]
7495    fn program_config_with_immediate_drain() {
7496        let custom = ImmediateDrainConfig {
7497            max_zero_timeout_polls_per_burst: 7,
7498            max_burst_duration: Duration::from_millis(9),
7499            backoff_timeout: Duration::from_millis(3),
7500        };
7501        let config = ProgramConfig::default().with_immediate_drain(custom.clone());
7502        assert_eq!(
7503            config.immediate_drain.max_zero_timeout_polls_per_burst,
7504            custom.max_zero_timeout_polls_per_burst
7505        );
7506        assert_eq!(
7507            config.immediate_drain.max_burst_duration,
7508            custom.max_burst_duration
7509        );
7510        assert_eq!(
7511            config.immediate_drain.backoff_timeout,
7512            custom.backoff_timeout
7513        );
7514    }
7515
7516    #[test]
7517    fn program_config_fullscreen() {
7518        let config = ProgramConfig::fullscreen();
7519        assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
7520    }
7521
7522    #[test]
7523    fn program_config_inline() {
7524        let config = ProgramConfig::inline(10);
7525        assert!(matches!(
7526            config.screen_mode,
7527            ScreenMode::Inline { ui_height: 10 }
7528        ));
7529    }
7530
7531    #[test]
7532    fn program_config_inline_auto() {
7533        let config = ProgramConfig::inline_auto(3, 9);
7534        assert!(matches!(
7535            config.screen_mode,
7536            ScreenMode::InlineAuto {
7537                min_height: 3,
7538                max_height: 9
7539            }
7540        ));
7541        assert!(config.inline_auto_remeasure.is_some());
7542    }
7543
7544    #[test]
7545    fn program_config_with_mouse() {
7546        let config = ProgramConfig::default().with_mouse();
7547        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7548        assert!(config.resolved_mouse_capture());
7549    }
7550
7551    #[cfg(feature = "native-backend")]
7552    #[test]
7553    fn sanitize_backend_features_disables_unsupported_features() {
7554        let requested = BackendFeatures {
7555            mouse_capture: true,
7556            bracketed_paste: true,
7557            focus_events: true,
7558            kitty_keyboard: true,
7559        };
7560        let sanitized =
7561            sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
7562        assert_eq!(sanitized, BackendFeatures::default());
7563    }
7564
7565    #[cfg(feature = "native-backend")]
7566    #[test]
7567    fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
7568        let requested = BackendFeatures {
7569            mouse_capture: true,
7570            bracketed_paste: true,
7571            focus_events: true,
7572            kitty_keyboard: true,
7573        };
7574        let caps = TerminalCapabilities::builder()
7575            .mouse_sgr(true)
7576            .bracketed_paste(true)
7577            .focus_events(true)
7578            .kitty_keyboard(true)
7579            .in_wezterm_mux(true)
7580            .build();
7581        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7582
7583        assert!(sanitized.mouse_capture);
7584        assert!(sanitized.bracketed_paste);
7585        assert!(!sanitized.focus_events);
7586        assert!(!sanitized.kitty_keyboard);
7587    }
7588
7589    #[cfg(feature = "native-backend")]
7590    #[test]
7591    fn sanitize_backend_features_is_conservative_in_tmux() {
7592        let requested = BackendFeatures {
7593            mouse_capture: true,
7594            bracketed_paste: true,
7595            focus_events: true,
7596            kitty_keyboard: true,
7597        };
7598        let caps = TerminalCapabilities::builder()
7599            .mouse_sgr(true)
7600            .bracketed_paste(true)
7601            .focus_events(true)
7602            .kitty_keyboard(true)
7603            .in_tmux(true)
7604            .build();
7605        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7606
7607        assert!(sanitized.mouse_capture);
7608        assert!(sanitized.bracketed_paste);
7609        assert!(!sanitized.focus_events);
7610        assert!(!sanitized.kitty_keyboard);
7611    }
7612
7613    #[test]
7614    fn program_config_mouse_policy_auto_altscreen() {
7615        let config = ProgramConfig::fullscreen();
7616        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7617        assert!(config.resolved_mouse_capture());
7618    }
7619
7620    #[test]
7621    fn program_config_mouse_policy_force_off() {
7622        let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
7623        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
7624        assert!(!config.resolved_mouse_capture());
7625    }
7626
7627    #[test]
7628    fn program_config_mouse_policy_force_on_inline() {
7629        let config = ProgramConfig::inline(6).with_mouse_enabled(true);
7630        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7631        assert!(config.resolved_mouse_capture());
7632    }
7633
7634    fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
7635        PaneResizeTarget {
7636            split_id: ftui_layout::PaneId::MIN,
7637            axis,
7638        }
7639    }
7640
7641    fn pane_id(raw: u64) -> ftui_layout::PaneId {
7642        ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
7643    }
7644
7645    fn nested_pane_tree() -> ftui_layout::PaneTree {
7646        let root = pane_id(1);
7647        let left = pane_id(2);
7648        let right_split = pane_id(3);
7649        let right_top = pane_id(4);
7650        let right_bottom = pane_id(5);
7651        let snapshot = ftui_layout::PaneTreeSnapshot {
7652            schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
7653            root,
7654            next_id: pane_id(6),
7655            nodes: vec![
7656                ftui_layout::PaneNodeRecord::split(
7657                    root,
7658                    None,
7659                    ftui_layout::PaneSplit {
7660                        axis: SplitAxis::Horizontal,
7661                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7662                        first: left,
7663                        second: right_split,
7664                    },
7665                ),
7666                ftui_layout::PaneNodeRecord::leaf(
7667                    left,
7668                    Some(root),
7669                    ftui_layout::PaneLeaf::new("left"),
7670                ),
7671                ftui_layout::PaneNodeRecord::split(
7672                    right_split,
7673                    Some(root),
7674                    ftui_layout::PaneSplit {
7675                        axis: SplitAxis::Vertical,
7676                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7677                        first: right_top,
7678                        second: right_bottom,
7679                    },
7680                ),
7681                ftui_layout::PaneNodeRecord::leaf(
7682                    right_top,
7683                    Some(right_split),
7684                    ftui_layout::PaneLeaf::new("right_top"),
7685                ),
7686                ftui_layout::PaneNodeRecord::leaf(
7687                    right_bottom,
7688                    Some(right_split),
7689                    ftui_layout::PaneLeaf::new("right_bottom"),
7690                ),
7691            ],
7692            extensions: std::collections::BTreeMap::new(),
7693        };
7694        ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
7695    }
7696
7697    /// First-child share (in basis points) of a split node's ratio.
7698    fn root_first_share_bps(tree: &ftui_layout::PaneTree, split: ftui_layout::PaneId) -> u32 {
7699        match &tree.node(split).expect("split node present").kind {
7700            PaneNodeKind::Split(node) => {
7701                node.ratio.numerator() * 10_000
7702                    / (node.ratio.numerator() + node.ratio.denominator())
7703            }
7704            other => panic!("expected split node, got {other:?}"),
7705        }
7706    }
7707
7708    /// A fixed, timing-independent pressure-snap profile for deterministic tests.
7709    fn fixed_neutral_pressure() -> PanePressureSnapProfile {
7710        PanePressureSnapProfile {
7711            strength_bps: 5_000,
7712            hysteresis_bps: 100,
7713        }
7714    }
7715
7716    /// Apply a terminal dispatch's geometry-bearing transition to the live tree
7717    /// using a fixed, caller-supplied pressure profile.
7718    ///
7719    /// Unlike the realistic path (which derives pressure from pointer motion and
7720    /// therefore from wall-clock `speed`), this helper always uses `pressure`, so
7721    /// repeated runs of the same scripted event sequence are byte-for-byte
7722    /// deterministic. Returns the number of operations applied.
7723    fn apply_dispatch_fixed(
7724        tree: &mut ftui_layout::PaneTree,
7725        layout: &ftui_layout::PaneLayout,
7726        dispatch: &PaneTerminalDispatch,
7727        pressure: PanePressureSnapProfile,
7728        seed: &mut u64,
7729    ) -> usize {
7730        let Some(transition) = dispatch.primary_transition.as_ref() else {
7731            return 0;
7732        };
7733        let ops = tree.operations_for_transition(transition, layout, pressure);
7734        let applied = ops.len();
7735        for op in ops {
7736            tree.apply_operation(*seed, op).expect("operation applies");
7737            *seed += 1;
7738        }
7739        applied
7740    }
7741
7742    /// Drive a full scripted horizontal splitter drag through the live
7743    /// adapter -> bridge -> tree path with a fixed pressure profile. Press at
7744    /// `down_x`, then send Drag events for every position in `drag_xs` except the
7745    /// last, which is sent as the release (Up). Pointer x must increase across
7746    /// samples (the adapter tracks magnitude via saturating deltas). Returns the
7747    /// number of operations applied.
7748    #[allow(clippy::too_many_arguments)]
7749    fn drive_horizontal_drag_fixed(
7750        adapter: &mut PaneTerminalAdapter,
7751        tree: &mut ftui_layout::PaneTree,
7752        layout: &ftui_layout::PaneLayout,
7753        target: PaneResizeTarget,
7754        down_x: u16,
7755        y: u16,
7756        drag_xs: &[u16],
7757        pressure: PanePressureSnapProfile,
7758        seed: &mut u64,
7759    ) -> usize {
7760        let down = Event::Mouse(MouseEvent::new(
7761            MouseEventKind::Down(MouseButton::Left),
7762            down_x,
7763            y,
7764        ));
7765        let mut applied = apply_dispatch_fixed(
7766            tree,
7767            layout,
7768            &adapter.translate(&down, Some(target)),
7769            pressure,
7770            seed,
7771        );
7772
7773        let last = drag_xs.len().saturating_sub(1);
7774        for (idx, &x) in drag_xs.iter().enumerate() {
7775            let kind = if idx == last {
7776                MouseEventKind::Up(MouseButton::Left)
7777            } else {
7778                MouseEventKind::Drag(MouseButton::Left)
7779            };
7780            let event = Event::Mouse(MouseEvent::new(kind, x, y));
7781            applied += apply_dispatch_fixed(
7782                tree,
7783                layout,
7784                &adapter.translate(&event, None),
7785                pressure,
7786                seed,
7787            );
7788        }
7789        applied
7790    }
7791
7792    #[test]
7793    fn pane_terminal_splitter_resolution_is_deterministic() {
7794        let tree = nested_pane_tree();
7795        let layout = tree
7796            .solve_layout(Rect::new(0, 0, 50, 20))
7797            .expect("layout should solve");
7798        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7799        assert_eq!(handles.len(), 2);
7800
7801        // Intersection between root vertical splitter and right-side horizontal
7802        // splitter deterministically resolves to smaller split ID.
7803        let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
7804            .expect("overlap cell should resolve");
7805        assert_eq!(overlap.split_id, pane_id(1));
7806        assert_eq!(overlap.axis, SplitAxis::Horizontal);
7807
7808        let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
7809            .expect("right split should resolve");
7810        assert_eq!(right_only.split_id, pane_id(3));
7811        assert_eq!(right_only.axis, SplitAxis::Vertical);
7812    }
7813
7814    #[test]
7815    fn pane_terminal_splitter_hits_register_and_decode_target() {
7816        let tree = nested_pane_tree();
7817        let layout = tree
7818            .solve_layout(Rect::new(0, 0, 50, 20))
7819            .expect("layout should solve");
7820        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7821
7822        let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
7823        let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
7824        let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
7825        assert_eq!(registered, handles.len());
7826
7827        let root_hit = frame
7828            .hit_test(25, 2)
7829            .expect("root splitter should be hittable");
7830        assert_eq!(root_hit.1, HitRegion::Handle);
7831        let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
7832        assert_eq!(root_target.split_id, pane_id(1));
7833        assert_eq!(root_target.axis, SplitAxis::Horizontal);
7834
7835        let right_hit = frame
7836            .hit_test(40, 10)
7837            .expect("right splitter should be hittable");
7838        assert_eq!(right_hit.1, HitRegion::Handle);
7839        let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
7840        assert_eq!(right_target.split_id, pane_id(3));
7841        assert_eq!(right_target.axis, SplitAxis::Vertical);
7842    }
7843
7844    #[test]
7845    fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
7846        let mut adapter =
7847            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7848        let target = pane_target(SplitAxis::Horizontal);
7849
7850        let down = Event::Mouse(MouseEvent::new(
7851            MouseEventKind::Down(MouseButton::Left),
7852            10,
7853            4,
7854        ));
7855        let down_dispatch = adapter.translate(&down, Some(target));
7856        let down_event = down_dispatch
7857            .primary_event
7858            .as_ref()
7859            .expect("pointer down semantic event");
7860        assert_eq!(down_event.sequence, 1);
7861        assert!(matches!(
7862            down_event.kind,
7863            PaneSemanticInputEventKind::PointerDown {
7864                target: actual_target,
7865                pointer_id: 1,
7866                button: PanePointerButton::Primary,
7867                position
7868            } if actual_target == target && position == PanePointerPosition::new(10, 4)
7869        ));
7870        assert!(down_event.validate().is_ok());
7871
7872        let drag = Event::Mouse(MouseEvent::new(
7873            MouseEventKind::Drag(MouseButton::Left),
7874            14,
7875            4,
7876        ));
7877        let drag_dispatch = adapter.translate(&drag, None);
7878        let drag_event = drag_dispatch
7879            .primary_event
7880            .as_ref()
7881            .expect("pointer move semantic event");
7882        assert_eq!(drag_event.sequence, 2);
7883        assert!(matches!(
7884            drag_event.kind,
7885            PaneSemanticInputEventKind::PointerMove {
7886                target: actual_target,
7887                pointer_id: 1,
7888                position,
7889                delta_x: 4,
7890                delta_y: 0
7891            } if actual_target == target && position == PanePointerPosition::new(14, 4)
7892        ));
7893        let drag_motion = drag_dispatch
7894            .motion
7895            .expect("drag should emit motion metadata");
7896        assert_eq!(drag_motion.delta_x, 4);
7897        assert_eq!(drag_motion.delta_y, 0);
7898        assert_eq!(drag_motion.direction_changes, 0);
7899        assert!(drag_motion.speed > 0.0);
7900        assert!(drag_dispatch.pressure_snap_profile().is_some());
7901
7902        let up = Event::Mouse(MouseEvent::new(
7903            MouseEventKind::Up(MouseButton::Left),
7904            14,
7905            4,
7906        ));
7907        let up_dispatch = adapter.translate(&up, None);
7908        let up_event = up_dispatch
7909            .primary_event
7910            .as_ref()
7911            .expect("pointer up semantic event");
7912        assert_eq!(up_event.sequence, 3);
7913        assert!(matches!(
7914            up_event.kind,
7915            PaneSemanticInputEventKind::PointerUp {
7916                target: actual_target,
7917                pointer_id: 1,
7918                button: PanePointerButton::Primary,
7919                position
7920            } if actual_target == target && position == PanePointerPosition::new(14, 4)
7921        ));
7922        let up_motion = up_dispatch
7923            .motion
7924            .expect("up should emit final motion metadata");
7925        assert_eq!(up_motion.delta_x, 4);
7926        assert_eq!(up_motion.delta_y, 0);
7927        assert_eq!(up_motion.direction_changes, 0);
7928        let inertial_throw = up_dispatch
7929            .inertial_throw
7930            .expect("up should emit inertial throw metadata");
7931        assert_eq!(
7932            up_dispatch.projected_position,
7933            Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
7934        );
7935        assert_eq!(adapter.active_pointer_id(), None);
7936        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7937    }
7938
7939    #[test]
7940    fn pane_terminal_adapter_drives_live_tree_mutation() {
7941        // End-to-end proof that the terminal adapter actually mutates the live
7942        // pane tree. Raw crossterm events flow through PaneTerminalAdapter into
7943        // PaneDragResizeTransition values, the layout bridge
7944        // (PaneTree::operations_for_transition) turns each geometry-bearing
7945        // transition into PaneOperation values, and apply_operation moves the
7946        // live root split ratio. This closes the historical gap where the
7947        // adapter emitted transitions that no production path consumed.
7948        fn apply_dispatch(
7949            tree: &mut ftui_layout::PaneTree,
7950            layout: &ftui_layout::PaneLayout,
7951            dispatch: &PaneTerminalDispatch,
7952            neutral: PanePressureSnapProfile,
7953            seed: &mut u64,
7954        ) -> usize {
7955            let Some(transition) = dispatch.primary_transition.as_ref() else {
7956                return 0;
7957            };
7958            let pressure = dispatch.pressure_snap_profile().unwrap_or(neutral);
7959            let ops = tree.operations_for_transition(transition, layout, pressure);
7960            let applied = ops.len();
7961            for op in ops {
7962                tree.apply_operation(*seed, op).expect("operation applies");
7963                *seed += 1;
7964            }
7965            applied
7966        }
7967
7968        let mut tree = nested_pane_tree();
7969        // The root split's own rectangle spans the whole viewport regardless of
7970        // its ratio, so a single solve is sufficient for targeting it.
7971        let layout = tree
7972            .solve_layout(Rect::new(0, 0, 50, 20))
7973            .expect("layout should solve");
7974        let root_split = pane_id(1);
7975        let target = PaneResizeTarget {
7976            split_id: root_split,
7977            axis: SplitAxis::Horizontal,
7978        };
7979        let neutral = PanePressureSnapProfile {
7980            strength_bps: 5_000,
7981            hysteresis_bps: 100,
7982        };
7983
7984        let before_share = root_first_share_bps(&tree, root_split);
7985
7986        let mut adapter =
7987            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7988        let mut op_seed = 7_000u64;
7989        let mut geometry_transitions = 0usize;
7990
7991        // Press on the splitter (arms the machine; no geometry yet).
7992        let down = Event::Mouse(MouseEvent::new(
7993            MouseEventKind::Down(MouseButton::Left),
7994            25,
7995            10,
7996        ));
7997        let down_dispatch = adapter.translate(&down, Some(target));
7998        geometry_transitions +=
7999            apply_dispatch(&mut tree, &layout, &down_dispatch, neutral, &mut op_seed);
8000
8001        // Drag rightward across cells, applying each transition to the tree.
8002        for x in [30u16, 36, 42] {
8003            let drag = Event::Mouse(MouseEvent::new(
8004                MouseEventKind::Drag(MouseButton::Left),
8005                x,
8006                10,
8007            ));
8008            let dispatch = adapter.translate(&drag, None);
8009            geometry_transitions +=
8010                apply_dispatch(&mut tree, &layout, &dispatch, neutral, &mut op_seed);
8011        }
8012
8013        // Release.
8014        let up = Event::Mouse(MouseEvent::new(
8015            MouseEventKind::Up(MouseButton::Left),
8016            42,
8017            10,
8018        ));
8019        let up_dispatch = adapter.translate(&up, None);
8020        geometry_transitions +=
8021            apply_dispatch(&mut tree, &layout, &up_dispatch, neutral, &mut op_seed);
8022
8023        assert!(
8024            geometry_transitions > 0,
8025            "drag lifecycle should yield at least one geometry-bearing transition"
8026        );
8027        let after_share = root_first_share_bps(&tree, root_split);
8028        assert!(
8029            after_share > before_share,
8030            "rightward splitter drag should grow the first child: after={after_share} before={before_share}"
8031        );
8032        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8033    }
8034
8035    #[test]
8036    fn pane_terminal_adapter_resize_interrupt_cancels_drag_and_preserves_tree() {
8037        // Edge interruption: a terminal resize (SIGWINCH) arrives mid-drag. With
8038        // the default config (cancel_on_resize = true) the adapter must cancel the
8039        // in-flight gesture cleanly, leave the live tree valid and unmutated by the
8040        // interrupt itself, and remain reusable for a fresh gesture afterwards.
8041        let mut tree = nested_pane_tree();
8042        let layout = tree
8043            .solve_layout(Rect::new(0, 0, 50, 20))
8044            .expect("layout should solve");
8045        let root_split = pane_id(1);
8046        let target = PaneResizeTarget {
8047            split_id: root_split,
8048            axis: SplitAxis::Horizontal,
8049        };
8050        let pressure = fixed_neutral_pressure();
8051        let mut seed = 4_100u64;
8052
8053        let initial_hash = tree.state_hash();
8054
8055        let mut adapter =
8056            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8057
8058        // Arm + drag (without releasing) so a gesture is genuinely in flight.
8059        let _ = apply_dispatch_fixed(
8060            &mut tree,
8061            &layout,
8062            &adapter.translate(
8063                &Event::Mouse(MouseEvent::new(
8064                    MouseEventKind::Down(MouseButton::Left),
8065                    25,
8066                    10,
8067                )),
8068                Some(target),
8069            ),
8070            pressure,
8071            &mut seed,
8072        );
8073        for x in [30u16, 36] {
8074            let _ = apply_dispatch_fixed(
8075                &mut tree,
8076                &layout,
8077                &adapter.translate(
8078                    &Event::Mouse(MouseEvent::new(
8079                        MouseEventKind::Drag(MouseButton::Left),
8080                        x,
8081                        10,
8082                    )),
8083                    None,
8084                ),
8085                pressure,
8086                &mut seed,
8087            );
8088        }
8089        let mid_drag_hash = tree.state_hash();
8090        assert_ne!(
8091            mid_drag_hash, initial_hash,
8092            "drag should have mutated the tree before the interrupt"
8093        );
8094        assert!(tree.validate().is_ok());
8095        assert_eq!(adapter.active_pointer_id(), Some(1));
8096
8097        // Resize interrupt mid-drag.
8098        let resize_dispatch = adapter.translate(
8099            &Event::Resize {
8100                width: 60,
8101                height: 24,
8102            },
8103            None,
8104        );
8105        let cancel = resize_dispatch
8106            .primary_event
8107            .as_ref()
8108            .expect("resize interrupt should emit a cancel semantic event");
8109        assert!(matches!(
8110            cancel.kind,
8111            PaneSemanticInputEventKind::Cancel {
8112                target: Some(actual),
8113                reason: PaneCancelReason::Programmatic
8114            } if actual == target
8115        ));
8116        assert_eq!(
8117            resize_dispatch.log.phase,
8118            PaneTerminalLifecyclePhase::ResizeInterrupt
8119        );
8120        assert_eq!(
8121            resize_dispatch.log.outcome,
8122            PaneTerminalLogOutcome::SemanticForwarded
8123        );
8124
8125        // The cancel transition carries no geometry, so the bridge yields no ops
8126        // and the tree is left exactly as the last drag sample left it.
8127        let cancel_transition = resize_dispatch
8128            .primary_transition
8129            .as_ref()
8130            .expect("cancel transition present");
8131        let cancel_ops = tree.operations_for_transition(cancel_transition, &layout, pressure);
8132        assert!(
8133            cancel_ops.is_empty(),
8134            "cancel transition must not mutate the live tree"
8135        );
8136        assert_eq!(tree.state_hash(), mid_drag_hash);
8137        assert!(tree.validate().is_ok());
8138
8139        // Adapter is back to idle with no active pointer.
8140        assert_eq!(adapter.active_pointer_id(), None);
8141        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8142
8143        // Reusable: a fresh gesture at the new viewport mutates the tree again.
8144        let new_layout = tree
8145            .solve_layout(Rect::new(0, 0, 60, 24))
8146            .expect("layout should solve at new size");
8147        let before_reuse = tree.state_hash();
8148        let applied = drive_horizontal_drag_fixed(
8149            &mut adapter,
8150            &mut tree,
8151            &new_layout,
8152            target,
8153            30,
8154            12,
8155            &[36, 42, 48],
8156            pressure,
8157            &mut seed,
8158        );
8159        assert!(applied > 0, "fresh gesture should apply operations");
8160        assert_ne!(
8161            tree.state_hash(),
8162            before_reuse,
8163            "adapter must remain usable after a resize interrupt"
8164        );
8165        assert!(tree.validate().is_ok());
8166        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8167    }
8168
8169    #[test]
8170    fn pane_terminal_adapter_survives_resize_storm_and_is_deterministic() {
8171        // Resize-storm stability at the live-tree level. The default config
8172        // cancels in-flight gestures on resize (matching real SIGWINCH behavior),
8173        // so a storm rapidly re-grabs the splitter and re-drags across many
8174        // viewport sizes. After every step the tree must stay structurally valid
8175        // and within ratio bounds, and the whole scripted storm must be
8176        // byte-for-byte deterministic across repeated runs.
8177        fn run_storm() -> u64 {
8178            let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8179                .expect("valid adapter");
8180            let mut tree = nested_pane_tree();
8181            let root_split = pane_id(1);
8182            let target = PaneResizeTarget {
8183                split_id: root_split,
8184                axis: SplitAxis::Horizontal,
8185            };
8186            let pressure = fixed_neutral_pressure();
8187            let mut seed = 5_200u64;
8188
8189            // Sizes the "window manager" cycles through during the storm.
8190            let sizes: [(u16, u16); 6] =
8191                [(50, 20), (80, 24), (40, 16), (120, 40), (30, 12), (100, 30)];
8192
8193            for (idx, &(w, h)) in sizes.iter().enumerate() {
8194                let layout = tree
8195                    .solve_layout(Rect::new(0, 0, w, h))
8196                    .expect("layout should solve under storm");
8197
8198                // Re-grab near 1/5 width and drag rightward to 3/5 width (always
8199                // increasing x, comfortably mid-rail to avoid child minimums).
8200                let down_x = (w / 5).max(2);
8201                let drag_x = (3 * w / 5).max(down_x + 2);
8202                let _ = apply_dispatch_fixed(
8203                    &mut tree,
8204                    &layout,
8205                    &adapter.translate(
8206                        &Event::Mouse(MouseEvent::new(
8207                            MouseEventKind::Down(MouseButton::Left),
8208                            down_x,
8209                            h / 2,
8210                        )),
8211                        Some(target),
8212                    ),
8213                    pressure,
8214                    &mut seed,
8215                );
8216                let _ = apply_dispatch_fixed(
8217                    &mut tree,
8218                    &layout,
8219                    &adapter.translate(
8220                        &Event::Mouse(MouseEvent::new(
8221                            MouseEventKind::Drag(MouseButton::Left),
8222                            drag_x,
8223                            h / 2,
8224                        )),
8225                        None,
8226                    ),
8227                    pressure,
8228                    &mut seed,
8229                );
8230                assert!(
8231                    tree.validate().is_ok(),
8232                    "tree invalid after drag at step {idx}"
8233                );
8234                let share = root_first_share_bps(&tree, root_split);
8235                assert!(
8236                    share > 0 && share < 10_000,
8237                    "split ratio escaped bounds at step {idx}: {share}"
8238                );
8239
8240                // Storm: a resize arrives mid-gesture and cancels it cleanly.
8241                let resize = adapter.translate(
8242                    &Event::Resize {
8243                        width: w,
8244                        height: h,
8245                    },
8246                    None,
8247                );
8248                if let Some(cancel_transition) = resize.primary_transition.as_ref() {
8249                    let cancel_ops =
8250                        tree.operations_for_transition(cancel_transition, &layout, pressure);
8251                    assert!(
8252                        cancel_ops.is_empty(),
8253                        "resize cancel must not mutate the tree at step {idx}"
8254                    );
8255                }
8256                assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8257                assert_eq!(adapter.active_pointer_id(), None);
8258                assert!(
8259                    tree.validate().is_ok(),
8260                    "tree invalid after resize at step {idx}"
8261                );
8262            }
8263
8264            // A final complete gesture (with a real release) settles the tree.
8265            let (w, h) = (90u16, 30u16);
8266            let layout = tree
8267                .solve_layout(Rect::new(0, 0, w, h))
8268                .expect("final layout should solve");
8269            let _ = drive_horizontal_drag_fixed(
8270                &mut adapter,
8271                &mut tree,
8272                &layout,
8273                target,
8274                (w / 5).max(2),
8275                h / 2,
8276                &[w / 2, 3 * w / 5],
8277                pressure,
8278                &mut seed,
8279            );
8280            assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8281            assert!(tree.validate().is_ok());
8282
8283            tree.state_hash()
8284        }
8285
8286        let first = run_storm();
8287        let second = run_storm();
8288        assert_eq!(
8289            first, second,
8290            "resize storm with repeated re-grabs must be deterministic"
8291        );
8292    }
8293
8294    #[test]
8295    fn pane_terminal_adapter_geometry_is_capability_invariant() {
8296        // Capability variance: the terminal adapter consumes already-parsed
8297        // `Event` values and makes no terminal-capability queries, so the same
8298        // logical drag must produce identical live-tree geometry whether the host
8299        // is a dumb terminal, a modern emulator, or tmux. We assert both halves:
8300        // (1) the capability profiles genuinely differ (non-vacuous), and (2) the
8301        // resulting tree state is identical across all three. This locks the
8302        // invariant that pane resize is capability-independent and would catch any
8303        // future regression that silently couples geometry to terminal caps.
8304        fn drag_under(
8305            over: ftui_core::capability_override::CapabilityOverride,
8306        ) -> (bool, bool, bool, u64) {
8307            ftui_core::capability_override::with_capability_override(over, || {
8308                let caps = ftui_core::capability_override::current_capabilities();
8309                let mut tree = nested_pane_tree();
8310                let layout = tree
8311                    .solve_layout(Rect::new(0, 0, 50, 20))
8312                    .expect("layout should solve");
8313                let target = PaneResizeTarget {
8314                    split_id: pane_id(1),
8315                    axis: SplitAxis::Horizontal,
8316                };
8317                let pressure = fixed_neutral_pressure();
8318                let mut seed = 6_300u64;
8319                let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8320                    .expect("valid adapter");
8321                let applied = drive_horizontal_drag_fixed(
8322                    &mut adapter,
8323                    &mut tree,
8324                    &layout,
8325                    target,
8326                    25,
8327                    10,
8328                    &[30, 36, 42],
8329                    pressure,
8330                    &mut seed,
8331                );
8332                assert!(
8333                    applied > 0,
8334                    "drag should apply operations under every profile"
8335                );
8336                (
8337                    caps.mouse_sgr,
8338                    caps.supports_true_color(),
8339                    caps.in_tmux,
8340                    tree.state_hash(),
8341                )
8342            })
8343        }
8344
8345        let (dumb_sgr, dumb_truecolor, _dumb_tmux, dumb_hash) =
8346            drag_under(ftui_core::capability_override::CapabilityOverride::dumb());
8347        let (modern_sgr, modern_truecolor, modern_tmux, modern_hash) =
8348            drag_under(ftui_core::capability_override::CapabilityOverride::modern());
8349        let (_tmux_sgr, _tmux_truecolor, tmux_tmux, tmux_hash) =
8350            drag_under(ftui_core::capability_override::CapabilityOverride::tmux());
8351
8352        // Non-vacuous: the three profiles really do present different caps.
8353        assert!(
8354            dumb_sgr != modern_sgr || dumb_truecolor != modern_truecolor,
8355            "dumb and modern profiles must differ in capabilities"
8356        );
8357        assert!(
8358            modern_tmux != tmux_tmux,
8359            "modern and tmux profiles must differ in the in_tmux flag"
8360        );
8361
8362        // Invariant: identical geometry regardless of terminal capabilities.
8363        assert_eq!(
8364            dumb_hash, modern_hash,
8365            "geometry must not depend on terminal capabilities"
8366        );
8367        assert_eq!(
8368            modern_hash, tmux_hash,
8369            "geometry must not depend on terminal capabilities"
8370        );
8371    }
8372
8373    #[test]
8374    fn pane_terminal_adapter_dispatch_log_traces_routing_and_failures() {
8375        // Diagnostic logs must capture event routing (the lifecycle phase of each
8376        // translated event) and failure context (deterministic ignore reasons), so
8377        // operators can reconstruct what the adapter did from the dispatch log
8378        // alone. This exercises both the happy routing path and two failure paths.
8379        let mut adapter =
8380            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8381        let target = pane_target(SplitAxis::Horizontal);
8382
8383        // Failure path 1: wrong activation button is ignored with a precise reason.
8384        let wrong_button = adapter.translate(
8385            &Event::Mouse(MouseEvent::new(
8386                MouseEventKind::Down(MouseButton::Right),
8387                5,
8388                5,
8389            )),
8390            Some(target),
8391        );
8392        assert_eq!(
8393            wrong_button.log.phase,
8394            PaneTerminalLifecyclePhase::MouseDown
8395        );
8396        assert_eq!(
8397            wrong_button.log.outcome,
8398            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ActivationButtonRequired)
8399        );
8400        assert!(wrong_button.primary_event.is_none());
8401        assert_eq!(adapter.active_pointer_id(), None);
8402
8403        // Routing: a real gesture progresses MouseDown -> MouseDrag -> MouseUp,
8404        // each forwarded with a monotonically increasing sequence number.
8405        let down = adapter.translate(
8406            &Event::Mouse(MouseEvent::new(
8407                MouseEventKind::Down(MouseButton::Left),
8408                25,
8409                10,
8410            )),
8411            Some(target),
8412        );
8413        assert_eq!(down.log.phase, PaneTerminalLifecyclePhase::MouseDown);
8414        assert_eq!(down.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8415        assert_eq!(down.log.target, Some(target));
8416        assert_eq!(down.log.pointer_id, Some(1));
8417        let down_seq = down.log.sequence.expect("down sequence");
8418
8419        let drag = adapter.translate(
8420            &Event::Mouse(MouseEvent::new(
8421                MouseEventKind::Drag(MouseButton::Left),
8422                31,
8423                10,
8424            )),
8425            None,
8426        );
8427        assert_eq!(drag.log.phase, PaneTerminalLifecyclePhase::MouseDrag);
8428        assert_eq!(drag.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8429        let drag_seq = drag.log.sequence.expect("drag sequence");
8430        assert!(drag_seq > down_seq, "sequence numbers must be monotonic");
8431
8432        let up = adapter.translate(
8433            &Event::Mouse(MouseEvent::new(
8434                MouseEventKind::Up(MouseButton::Left),
8435                31,
8436                10,
8437            )),
8438            None,
8439        );
8440        assert_eq!(up.log.phase, PaneTerminalLifecyclePhase::MouseUp);
8441        assert_eq!(up.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8442        assert!(up.log.sequence.expect("up sequence") > drag_seq);
8443
8444        // Failure path 2: a resize with no active gesture is a clean no-op with a
8445        // precise reason rather than a spurious cancel.
8446        let idle_resize = adapter.translate(
8447            &Event::Resize {
8448                width: 80,
8449                height: 24,
8450            },
8451            None,
8452        );
8453        assert_eq!(
8454            idle_resize.log.phase,
8455            PaneTerminalLifecyclePhase::ResizeInterrupt
8456        );
8457        assert_eq!(
8458            idle_resize.log.outcome,
8459            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8460        );
8461        assert!(idle_resize.primary_event.is_none());
8462    }
8463
8464    #[test]
8465    fn pane_terminal_adapter_resize_preserves_gesture_when_cancel_disabled() {
8466        // Edge interruption, opposite branch: with cancel_on_resize disabled a
8467        // resize event is a deterministic no-op that leaves the active gesture
8468        // intact, so the drag can continue to completion afterwards.
8469        let config = PaneTerminalAdapterConfig {
8470            cancel_on_resize: false,
8471            ..PaneTerminalAdapterConfig::default()
8472        };
8473        let mut adapter = PaneTerminalAdapter::new(config).expect("valid adapter");
8474        let mut tree = nested_pane_tree();
8475        let layout = tree
8476            .solve_layout(Rect::new(0, 0, 50, 20))
8477            .expect("layout should solve");
8478        let root_split = pane_id(1);
8479        let target = PaneResizeTarget {
8480            split_id: root_split,
8481            axis: SplitAxis::Horizontal,
8482        };
8483        let pressure = fixed_neutral_pressure();
8484        let mut seed = 7_700u64;
8485
8486        // Arm + first drag sample.
8487        let _ = apply_dispatch_fixed(
8488            &mut tree,
8489            &layout,
8490            &adapter.translate(
8491                &Event::Mouse(MouseEvent::new(
8492                    MouseEventKind::Down(MouseButton::Left),
8493                    20,
8494                    10,
8495                )),
8496                Some(target),
8497            ),
8498            pressure,
8499            &mut seed,
8500        );
8501        let _ = apply_dispatch_fixed(
8502            &mut tree,
8503            &layout,
8504            &adapter.translate(
8505                &Event::Mouse(MouseEvent::new(
8506                    MouseEventKind::Drag(MouseButton::Left),
8507                    26,
8508                    10,
8509                )),
8510                None,
8511            ),
8512            pressure,
8513            &mut seed,
8514        );
8515        assert_eq!(adapter.active_pointer_id(), Some(1));
8516
8517        // Resize arrives mid-drag: ignored as a no-op, gesture survives.
8518        let resize = adapter.translate(
8519            &Event::Resize {
8520                width: 50,
8521                height: 20,
8522            },
8523            None,
8524        );
8525        assert!(resize.primary_event.is_none());
8526        assert!(resize.primary_transition.is_none());
8527        assert_eq!(
8528            resize.log.outcome,
8529            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8530        );
8531        assert_eq!(
8532            adapter.active_pointer_id(),
8533            Some(1),
8534            "gesture must survive the resize when cancel_on_resize is disabled"
8535        );
8536
8537        // The drag continues and a release commits cleanly.
8538        let before_finish = tree.state_hash();
8539        for x in [32u16, 38] {
8540            let _ = apply_dispatch_fixed(
8541                &mut tree,
8542                &layout,
8543                &adapter.translate(
8544                    &Event::Mouse(MouseEvent::new(
8545                        MouseEventKind::Drag(MouseButton::Left),
8546                        x,
8547                        10,
8548                    )),
8549                    None,
8550                ),
8551                pressure,
8552                &mut seed,
8553            );
8554        }
8555        let _ = apply_dispatch_fixed(
8556            &mut tree,
8557            &layout,
8558            &adapter.translate(
8559                &Event::Mouse(MouseEvent::new(
8560                    MouseEventKind::Up(MouseButton::Left),
8561                    38,
8562                    10,
8563                )),
8564                None,
8565            ),
8566            pressure,
8567            &mut seed,
8568        );
8569        assert_ne!(
8570            tree.state_hash(),
8571            before_finish,
8572            "the surviving gesture should keep mutating the tree"
8573        );
8574        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8575        assert!(tree.validate().is_ok());
8576    }
8577
8578    #[test]
8579    fn pane_terminal_adapter_focus_loss_emits_cancel() {
8580        let mut adapter =
8581            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8582        let target = pane_target(SplitAxis::Vertical);
8583
8584        let down = Event::Mouse(MouseEvent::new(
8585            MouseEventKind::Down(MouseButton::Left),
8586            3,
8587            9,
8588        ));
8589        let _ = adapter.translate(&down, Some(target));
8590        assert_eq!(adapter.active_pointer_id(), Some(1));
8591
8592        let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
8593        let cancel_event = cancel_dispatch
8594            .primary_event
8595            .as_ref()
8596            .expect("focus-loss cancel event");
8597        assert!(matches!(
8598            cancel_event.kind,
8599            PaneSemanticInputEventKind::Cancel {
8600                target: Some(actual_target),
8601                reason: PaneCancelReason::FocusLost
8602            } if actual_target == target
8603        ));
8604        assert_eq!(adapter.active_pointer_id(), None);
8605        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8606    }
8607
8608    #[test]
8609    fn pane_terminal_adapter_recovers_missing_mouse_up() {
8610        let mut adapter =
8611            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8612        let first_target = pane_target(SplitAxis::Horizontal);
8613        let second_target = pane_target(SplitAxis::Vertical);
8614
8615        let first_down = Event::Mouse(MouseEvent::new(
8616            MouseEventKind::Down(MouseButton::Left),
8617            5,
8618            5,
8619        ));
8620        let _ = adapter.translate(&first_down, Some(first_target));
8621
8622        let second_down = Event::Mouse(MouseEvent::new(
8623            MouseEventKind::Down(MouseButton::Left),
8624            8,
8625            11,
8626        ));
8627        let dispatch = adapter.translate(&second_down, Some(second_target));
8628        let recovery = dispatch
8629            .recovery_event
8630            .as_ref()
8631            .expect("recovery cancel expected");
8632        assert!(matches!(
8633            recovery.kind,
8634            PaneSemanticInputEventKind::Cancel {
8635                target: Some(actual_target),
8636                reason: PaneCancelReason::PointerCancel
8637            } if actual_target == first_target
8638        ));
8639        let primary = dispatch
8640            .primary_event
8641            .as_ref()
8642            .expect("second pointer down expected");
8643        assert!(matches!(
8644            primary.kind,
8645            PaneSemanticInputEventKind::PointerDown {
8646                target: actual_target,
8647                pointer_id: 1,
8648                button: PanePointerButton::Primary,
8649                position
8650            } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
8651        ));
8652        assert_eq!(recovery.sequence, 2);
8653        assert_eq!(primary.sequence, 3);
8654        assert!(matches!(
8655            dispatch.log.outcome,
8656            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
8657        ));
8658        assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
8659    }
8660
8661    #[test]
8662    fn pane_terminal_adapter_modifier_parity() {
8663        let mut adapter =
8664            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8665        let target = pane_target(SplitAxis::Horizontal);
8666
8667        let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
8668            .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
8669        let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
8670        let event = dispatch.primary_event.expect("semantic event");
8671        assert!(event.modifiers.shift);
8672        assert!(event.modifiers.alt);
8673        assert!(event.modifiers.ctrl);
8674        assert!(event.modifiers.meta);
8675    }
8676
8677    #[test]
8678    fn pane_terminal_adapter_keyboard_resize_mapping() {
8679        let mut adapter =
8680            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8681        let target = pane_target(SplitAxis::Horizontal);
8682
8683        let key = KeyEvent::new(KeyCode::Right);
8684        let dispatch = adapter.translate(&Event::Key(key), Some(target));
8685        let event = dispatch.primary_event.expect("keyboard resize event");
8686        assert!(matches!(
8687            event.kind,
8688            PaneSemanticInputEventKind::KeyboardResize {
8689                target: actual_target,
8690                direction: PaneResizeDirection::Increase,
8691                units: 1
8692            } if actual_target == target
8693        ));
8694
8695        let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
8696        let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
8697        let shifted_event = shifted_dispatch
8698            .primary_event
8699            .expect("shifted resize event");
8700        assert!(matches!(
8701            shifted_event.kind,
8702            PaneSemanticInputEventKind::KeyboardResize {
8703                direction: PaneResizeDirection::Increase,
8704                units: 5,
8705                ..
8706            }
8707        ));
8708        assert!(shifted_event.modifiers.shift);
8709    }
8710
8711    #[test]
8712    fn pane_terminal_adapter_keyboard_resize_requires_focus() {
8713        let mut adapter =
8714            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8715        let target = pane_target(SplitAxis::Horizontal);
8716
8717        let _ = adapter.translate(&Event::Focus(false), None);
8718        assert!(!adapter.window_focused());
8719
8720        let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8721        assert!(unfocused.primary_event.is_none());
8722        assert!(matches!(
8723            unfocused.log.outcome,
8724            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
8725        ));
8726
8727        let _ = adapter.translate(&Event::Focus(true), None);
8728        assert!(adapter.window_focused());
8729
8730        let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8731        assert!(focused.primary_event.is_some());
8732    }
8733
8734    #[test]
8735    fn pane_terminal_adapter_drag_updates_are_coalesced() {
8736        let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
8737            drag_update_coalesce_distance: 2,
8738            ..PaneTerminalAdapterConfig::default()
8739        })
8740        .expect("valid adapter");
8741        let target = pane_target(SplitAxis::Horizontal);
8742
8743        let down = Event::Mouse(MouseEvent::new(
8744            MouseEventKind::Down(MouseButton::Left),
8745            10,
8746            4,
8747        ));
8748        let _ = adapter.translate(&down, Some(target));
8749
8750        let drag_start = Event::Mouse(MouseEvent::new(
8751            MouseEventKind::Drag(MouseButton::Left),
8752            14,
8753            4,
8754        ));
8755        let started = adapter.translate(&drag_start, None);
8756        assert!(started.primary_event.is_some());
8757        assert!(matches!(
8758            adapter.machine_state(),
8759            PaneDragResizeState::Dragging { .. }
8760        ));
8761
8762        let coalesced = Event::Mouse(MouseEvent::new(
8763            MouseEventKind::Drag(MouseButton::Left),
8764            15,
8765            4,
8766        ));
8767        let coalesced_dispatch = adapter.translate(&coalesced, None);
8768        assert!(coalesced_dispatch.primary_event.is_none());
8769        assert!(matches!(
8770            coalesced_dispatch.log.outcome,
8771            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
8772        ));
8773
8774        let forwarded = Event::Mouse(MouseEvent::new(
8775            MouseEventKind::Drag(MouseButton::Left),
8776            16,
8777            4,
8778        ));
8779        let forwarded_dispatch = adapter.translate(&forwarded, None);
8780        let forwarded_event = forwarded_dispatch
8781            .primary_event
8782            .as_ref()
8783            .expect("coalesced movement should flush once threshold reached");
8784        assert!(matches!(
8785            forwarded_event.kind,
8786            PaneSemanticInputEventKind::PointerMove {
8787                delta_x: 2,
8788                delta_y: 0,
8789                ..
8790            }
8791        ));
8792    }
8793
8794    #[test]
8795    fn pane_terminal_adapter_motion_tracks_direction_changes() {
8796        let mut adapter =
8797            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8798        let target = pane_target(SplitAxis::Horizontal);
8799
8800        let down = Event::Mouse(MouseEvent::new(
8801            MouseEventKind::Down(MouseButton::Left),
8802            10,
8803            4,
8804        ));
8805        let _ = adapter.translate(&down, Some(target));
8806
8807        let drag_forward = Event::Mouse(MouseEvent::new(
8808            MouseEventKind::Drag(MouseButton::Left),
8809            14,
8810            4,
8811        ));
8812        let forward_dispatch = adapter.translate(&drag_forward, None);
8813        let forward_motion = forward_dispatch
8814            .motion
8815            .expect("forward drag should emit motion metadata");
8816        assert_eq!(forward_motion.direction_changes, 0);
8817
8818        let drag_reverse = Event::Mouse(MouseEvent::new(
8819            MouseEventKind::Drag(MouseButton::Left),
8820            12,
8821            4,
8822        ));
8823        let reverse_dispatch = adapter.translate(&drag_reverse, None);
8824        let reverse_motion = reverse_dispatch
8825            .motion
8826            .expect("reverse drag should emit motion metadata");
8827        assert_eq!(reverse_motion.direction_changes, 1);
8828
8829        let up = Event::Mouse(MouseEvent::new(
8830            MouseEventKind::Up(MouseButton::Left),
8831            12,
8832            4,
8833        ));
8834        let up_dispatch = adapter.translate(&up, None);
8835        let up_motion = up_dispatch
8836            .motion
8837            .expect("release should include cumulative motion metadata");
8838        assert_eq!(up_motion.direction_changes, 1);
8839    }
8840
8841    #[test]
8842    fn pane_terminal_adapter_drag_and_moved_share_motion_bookkeeping() {
8843        // `Drag` and `Moved` forward identical `PointerMove` semantics through
8844        // the same `apply_pointer_motion` path; only the lifecycle phase differs.
8845        // Driving the identical motion sequence through each arm must therefore
8846        // produce identical motion metadata at every step (the extraction's
8847        // equivalence guarantee).
8848        let run = |moved: bool| {
8849            let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8850                .expect("valid adapter");
8851            let target = pane_target(SplitAxis::Horizontal);
8852            let down = Event::Mouse(MouseEvent::new(
8853                MouseEventKind::Down(MouseButton::Left),
8854                10,
8855                4,
8856            ));
8857            let _ = adapter.translate(&down, Some(target));
8858            let mut motions = Vec::new();
8859            for (x, y) in [(14u16, 4u16), (12, 4), (16, 4)] {
8860                let kind = if moved {
8861                    MouseEventKind::Moved
8862                } else {
8863                    MouseEventKind::Drag(MouseButton::Left)
8864                };
8865                let dispatch = adapter.translate(&Event::Mouse(MouseEvent::new(kind, x, y)), None);
8866                assert!(dispatch.primary_event.is_some());
8867                motions.push(dispatch.motion.expect("motion metadata present"));
8868            }
8869            motions
8870        };
8871
8872        let via_drag = run(false);
8873        let via_moved = run(true);
8874        assert_eq!(via_drag, via_moved);
8875        // Sanity: the reverse step (14->12) registered a direction change in both.
8876        assert_eq!(via_drag[1].direction_changes, 1);
8877    }
8878
8879    #[test]
8880    fn pane_terminal_adapter_translate_with_handles_resolves_target() {
8881        let tree = nested_pane_tree();
8882        let layout = tree
8883            .solve_layout(Rect::new(0, 0, 50, 20))
8884            .expect("layout should solve");
8885        let handles =
8886            pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
8887        let mut adapter =
8888            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8889
8890        let down = Event::Mouse(MouseEvent::new(
8891            MouseEventKind::Down(MouseButton::Left),
8892            25,
8893            10,
8894        ));
8895        let dispatch = adapter.translate_with_handles(&down, &handles);
8896        let event = dispatch
8897            .primary_event
8898            .as_ref()
8899            .expect("pointer down should be routed from handles");
8900        assert!(matches!(
8901            event.kind,
8902            PaneSemanticInputEventKind::PointerDown {
8903                target:
8904                    PaneResizeTarget {
8905                        split_id,
8906                        axis: SplitAxis::Horizontal
8907                    },
8908                ..
8909            } if split_id == pane_id(1)
8910        ));
8911    }
8912
8913    #[test]
8914    fn model_update() {
8915        let mut model = TestModel { value: 0 };
8916        model.update(TestMsg::Increment);
8917        assert_eq!(model.value, 1);
8918        model.update(TestMsg::Decrement);
8919        assert_eq!(model.value, 0);
8920        assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
8921    }
8922
8923    #[test]
8924    fn model_init_default() {
8925        let mut model = TestModel { value: 0 };
8926        let cmd = model.init();
8927        assert!(matches!(cmd, Cmd::None));
8928    }
8929
8930    // Resize coalescer behavior is covered by resize_coalescer.rs tests.
8931
8932    // =========================================================================
8933    // DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
8934    // =========================================================================
8935
8936    #[test]
8937    fn cmd_sequence_executes_in_order() {
8938        // Verify that Cmd::Sequence executes commands in declared order
8939        use crate::simulator::ProgramSimulator;
8940
8941        struct SeqModel {
8942            trace: Vec<i32>,
8943        }
8944
8945        #[derive(Debug)]
8946        enum SeqMsg {
8947            Append(i32),
8948            TriggerSequence,
8949        }
8950
8951        impl From<Event> for SeqMsg {
8952            fn from(_: Event) -> Self {
8953                SeqMsg::Append(0)
8954            }
8955        }
8956
8957        impl Model for SeqModel {
8958            type Message = SeqMsg;
8959
8960            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8961                match msg {
8962                    SeqMsg::Append(n) => {
8963                        self.trace.push(n);
8964                        Cmd::none()
8965                    }
8966                    SeqMsg::TriggerSequence => Cmd::sequence(vec![
8967                        Cmd::msg(SeqMsg::Append(1)),
8968                        Cmd::msg(SeqMsg::Append(2)),
8969                        Cmd::msg(SeqMsg::Append(3)),
8970                    ]),
8971                }
8972            }
8973
8974            fn view(&self, _frame: &mut Frame) {}
8975        }
8976
8977        let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
8978        sim.init();
8979        sim.send(SeqMsg::TriggerSequence);
8980
8981        assert_eq!(sim.model().trace, vec![1, 2, 3]);
8982    }
8983
8984    #[test]
8985    fn cmd_batch_executes_all_regardless_of_order() {
8986        // Verify that Cmd::Batch executes all commands
8987        use crate::simulator::ProgramSimulator;
8988
8989        struct BatchModel {
8990            values: Vec<i32>,
8991        }
8992
8993        #[derive(Debug)]
8994        enum BatchMsg {
8995            Add(i32),
8996            TriggerBatch,
8997        }
8998
8999        impl From<Event> for BatchMsg {
9000            fn from(_: Event) -> Self {
9001                BatchMsg::Add(0)
9002            }
9003        }
9004
9005        impl Model for BatchModel {
9006            type Message = BatchMsg;
9007
9008            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9009                match msg {
9010                    BatchMsg::Add(n) => {
9011                        self.values.push(n);
9012                        Cmd::none()
9013                    }
9014                    BatchMsg::TriggerBatch => Cmd::batch(vec![
9015                        Cmd::msg(BatchMsg::Add(10)),
9016                        Cmd::msg(BatchMsg::Add(20)),
9017                        Cmd::msg(BatchMsg::Add(30)),
9018                    ]),
9019                }
9020            }
9021
9022            fn view(&self, _frame: &mut Frame) {}
9023        }
9024
9025        let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
9026        sim.init();
9027        sim.send(BatchMsg::TriggerBatch);
9028
9029        // All values should be present
9030        assert_eq!(sim.model().values.len(), 3);
9031        assert!(sim.model().values.contains(&10));
9032        assert!(sim.model().values.contains(&20));
9033        assert!(sim.model().values.contains(&30));
9034    }
9035
9036    #[test]
9037    fn cmd_sequence_stops_on_quit() {
9038        // Verify that Cmd::Sequence stops processing after Quit
9039        use crate::simulator::ProgramSimulator;
9040
9041        struct SeqQuitModel {
9042            trace: Vec<i32>,
9043        }
9044
9045        #[derive(Debug)]
9046        enum SeqQuitMsg {
9047            Append(i32),
9048            TriggerSequenceWithQuit,
9049        }
9050
9051        impl From<Event> for SeqQuitMsg {
9052            fn from(_: Event) -> Self {
9053                SeqQuitMsg::Append(0)
9054            }
9055        }
9056
9057        impl Model for SeqQuitModel {
9058            type Message = SeqQuitMsg;
9059
9060            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9061                match msg {
9062                    SeqQuitMsg::Append(n) => {
9063                        self.trace.push(n);
9064                        Cmd::none()
9065                    }
9066                    SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
9067                        Cmd::msg(SeqQuitMsg::Append(1)),
9068                        Cmd::quit(),
9069                        Cmd::msg(SeqQuitMsg::Append(2)), // Should not execute
9070                    ]),
9071                }
9072            }
9073
9074            fn view(&self, _frame: &mut Frame) {}
9075        }
9076
9077        let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
9078        sim.init();
9079        sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
9080
9081        assert_eq!(sim.model().trace, vec![1]);
9082        assert!(!sim.is_running());
9083    }
9084
9085    #[test]
9086    fn identical_input_produces_identical_state() {
9087        // Verify deterministic state transitions
9088        use crate::simulator::ProgramSimulator;
9089
9090        fn run_scenario() -> Vec<i32> {
9091            struct DetModel {
9092                values: Vec<i32>,
9093            }
9094
9095            #[derive(Debug, Clone)]
9096            enum DetMsg {
9097                Add(i32),
9098                Double,
9099            }
9100
9101            impl From<Event> for DetMsg {
9102                fn from(_: Event) -> Self {
9103                    DetMsg::Add(1)
9104                }
9105            }
9106
9107            impl Model for DetModel {
9108                type Message = DetMsg;
9109
9110                fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9111                    match msg {
9112                        DetMsg::Add(n) => {
9113                            self.values.push(n);
9114                            Cmd::none()
9115                        }
9116                        DetMsg::Double => {
9117                            if let Some(&last) = self.values.last() {
9118                                self.values.push(last * 2);
9119                            }
9120                            Cmd::none()
9121                        }
9122                    }
9123                }
9124
9125                fn view(&self, _frame: &mut Frame) {}
9126            }
9127
9128            let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
9129            sim.init();
9130            sim.send(DetMsg::Add(5));
9131            sim.send(DetMsg::Double);
9132            sim.send(DetMsg::Add(3));
9133            sim.send(DetMsg::Double);
9134
9135            sim.model().values.clone()
9136        }
9137
9138        // Run the same scenario multiple times
9139        let run1 = run_scenario();
9140        let run2 = run_scenario();
9141        let run3 = run_scenario();
9142
9143        assert_eq!(run1, run2);
9144        assert_eq!(run2, run3);
9145        assert_eq!(run1, vec![5, 10, 3, 6]);
9146    }
9147
9148    #[test]
9149    fn identical_state_produces_identical_render() {
9150        // Verify consistent render outputs for identical inputs
9151        use crate::simulator::ProgramSimulator;
9152
9153        struct RenderModel {
9154            counter: i32,
9155        }
9156
9157        #[derive(Debug)]
9158        enum RenderMsg {
9159            Set(i32),
9160        }
9161
9162        impl From<Event> for RenderMsg {
9163            fn from(_: Event) -> Self {
9164                RenderMsg::Set(0)
9165            }
9166        }
9167
9168        impl Model for RenderModel {
9169            type Message = RenderMsg;
9170
9171            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9172                match msg {
9173                    RenderMsg::Set(n) => {
9174                        self.counter = n;
9175                        Cmd::none()
9176                    }
9177                }
9178            }
9179
9180            fn view(&self, frame: &mut Frame) {
9181                let text = format!("Value: {}", self.counter);
9182                for (i, c) in text.chars().enumerate() {
9183                    if (i as u16) < frame.width() {
9184                        use ftui_render::cell::Cell;
9185                        frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
9186                    }
9187                }
9188            }
9189        }
9190
9191        // Create two simulators with the same state
9192        let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
9193        let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
9194
9195        let buf1 = sim1.capture_frame(80, 24);
9196        let buf2 = sim2.capture_frame(80, 24);
9197
9198        // Compare buffer contents
9199        for y in 0..24 {
9200            for x in 0..80 {
9201                let cell1 = buf1.get(x, y).unwrap();
9202                let cell2 = buf2.get(x, y).unwrap();
9203                assert_eq!(
9204                    cell1.content.as_char(),
9205                    cell2.content.as_char(),
9206                    "Mismatch at ({}, {})",
9207                    x,
9208                    y
9209                );
9210            }
9211        }
9212    }
9213
9214    // Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
9215
9216    #[test]
9217    fn cmd_log_creates_log_command() {
9218        let cmd: Cmd<TestMsg> = Cmd::log("test message");
9219        assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
9220    }
9221
9222    #[test]
9223    fn cmd_log_from_string() {
9224        let msg = String::from("dynamic message");
9225        let cmd: Cmd<TestMsg> = Cmd::log(msg);
9226        assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
9227    }
9228
9229    #[test]
9230    fn program_simulator_logs_jsonl_with_seed_and_run_id() {
9231        // Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
9232        use crate::simulator::ProgramSimulator;
9233
9234        struct LogModel {
9235            run_id: &'static str,
9236            seed: u64,
9237        }
9238
9239        #[derive(Debug)]
9240        enum LogMsg {
9241            Emit,
9242        }
9243
9244        impl From<Event> for LogMsg {
9245            fn from(_: Event) -> Self {
9246                LogMsg::Emit
9247            }
9248        }
9249
9250        impl Model for LogModel {
9251            type Message = LogMsg;
9252
9253            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9254                let line = format!(
9255                    r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
9256                    self.run_id, self.seed
9257                );
9258                Cmd::log(line)
9259            }
9260
9261            fn view(&self, _frame: &mut Frame) {}
9262        }
9263
9264        let mut sim = ProgramSimulator::new(LogModel {
9265            run_id: "test-run-001",
9266            seed: 4242,
9267        });
9268        sim.init();
9269        sim.send(LogMsg::Emit);
9270
9271        let logs = sim.logs();
9272        assert_eq!(logs.len(), 1);
9273        assert!(logs[0].contains(r#""run_id":"test-run-001""#));
9274        assert!(logs[0].contains(r#""seed":4242"#));
9275    }
9276
9277    #[test]
9278    fn cmd_sequence_single_unwraps() {
9279        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
9280        // Single element sequence should unwrap to the inner command
9281        assert!(matches!(cmd, Cmd::Quit));
9282    }
9283
9284    #[test]
9285    fn cmd_sequence_multiple() {
9286        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
9287        assert!(matches!(cmd, Cmd::Sequence(_)));
9288    }
9289
9290    #[test]
9291    fn cmd_default_is_none() {
9292        let cmd: Cmd<TestMsg> = Cmd::default();
9293        assert!(matches!(cmd, Cmd::None));
9294    }
9295
9296    #[test]
9297    fn cmd_debug_all_variants() {
9298        // Test Debug impl for all variants
9299        let none: Cmd<TestMsg> = Cmd::none();
9300        assert_eq!(format!("{none:?}"), "None");
9301
9302        let quit: Cmd<TestMsg> = Cmd::quit();
9303        assert_eq!(format!("{quit:?}"), "Quit");
9304
9305        let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
9306        assert!(format!("{msg:?}").starts_with("Msg("));
9307
9308        let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
9309        assert!(format!("{batch:?}").starts_with("Batch("));
9310
9311        let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
9312        assert!(format!("{seq:?}").starts_with("Sequence("));
9313
9314        let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
9315        assert!(format!("{tick:?}").starts_with("Tick("));
9316
9317        let log: Cmd<TestMsg> = Cmd::log("test");
9318        assert!(format!("{log:?}").starts_with("Log("));
9319    }
9320
9321    #[test]
9322    fn program_config_with_budget() {
9323        let budget = FrameBudgetConfig {
9324            total: Duration::from_millis(50),
9325            ..Default::default()
9326        };
9327        let config = ProgramConfig::default().with_budget(budget);
9328        assert_eq!(config.budget.total, Duration::from_millis(50));
9329    }
9330
9331    #[test]
9332    fn load_governor_default_is_enabled() {
9333        let config = LoadGovernorConfig::default();
9334        assert!(config.enabled);
9335        assert_eq!(
9336            config.budget_controller.degradation_floor,
9337            DegradationLevel::SimpleBorders
9338        );
9339    }
9340
9341    #[test]
9342    fn program_config_load_governor_builders() {
9343        let governor = LoadGovernorConfig::disabled().with_enabled(true);
9344        let config = ProgramConfig::default().with_load_governor(governor);
9345        assert!(config.load_governor.enabled);
9346
9347        let config = config.without_load_governor();
9348        assert!(!config.load_governor.enabled);
9349    }
9350
9351    fn governor_observation(
9352        frame_time_ms: f64,
9353        in_flight: u64,
9354        dropped: u64,
9355        degradation: DegradationLevel,
9356        resize_coalescing_active: bool,
9357        strict_semantics_violation: bool,
9358    ) -> LoadGovernorObservation {
9359        LoadGovernorObservation {
9360            frame_time_us: frame_time_ms * 1_000.0,
9361            budget_us: 16_000.0,
9362            degradation,
9363            queue: crate::effect_system::QueueTelemetry {
9364                // Invariant: in_flight = enqueued - processed (drops are
9365                // rejected before ever being counted as enqueued).
9366                enqueued: in_flight,
9367                processed: 0,
9368                dropped,
9369                high_water: in_flight,
9370                in_flight,
9371            },
9372            resize_coalescing_active,
9373            strict_semantics_violation,
9374        }
9375    }
9376
9377    fn test_load_governor(max_queue_depth: usize, recovery_intervals: u8) -> LoadGovernorState {
9378        let policy = LoadGovernorPolicy {
9379            recovery_intervals,
9380            ..Default::default()
9381        };
9382        LoadGovernorState::new(
9383            LoadGovernorConfig::enabled().with_policy(policy),
9384            max_queue_depth,
9385        )
9386    }
9387
9388    #[test]
9389    fn load_governor_policy_defaults_match_runtime_contract() {
9390        let policy = LoadGovernorPolicy::default().normalized();
9391
9392        assert_eq!(policy.stressed_queue_watermark, 0.5);
9393        assert_eq!(policy.degraded_queue_watermark, 0.8);
9394        assert_eq!(policy.recovery_queue_watermark, 0.25);
9395        assert_eq!(policy.recovery_intervals, 3);
9396        assert_eq!(policy.budget_overrun_soft_ratio, 1.0);
9397    }
9398
9399    #[test]
9400    fn load_governor_classifies_queue_watermarks_and_recovery() {
9401        let mut governor = test_load_governor(100, 2);
9402
9403        let steady = governor.observe(governor_observation(
9404            8.0,
9405            0,
9406            0,
9407            DegradationLevel::Full,
9408            false,
9409            false,
9410        ));
9411        assert_eq!(steady.mode, RuntimeLoadMode::Healthy);
9412        assert_eq!(steady.pressure_class, RuntimePressureClass::SteadyState);
9413        assert_eq!(steady.disposition, RuntimeWorkDisposition::AdmitAll);
9414
9415        let stressed = governor.observe(governor_observation(
9416            8.0,
9417            50,
9418            0,
9419            DegradationLevel::Full,
9420            false,
9421            false,
9422        ));
9423        assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9424        assert_eq!(stressed.pressure_class, RuntimePressureClass::SoftOverload);
9425        assert_eq!(
9426            stressed.disposition,
9427            RuntimeWorkDisposition::CoalesceVisibleDeferBackground
9428        );
9429        assert_eq!(stressed.reason_code, "queue_stressed_watermark");
9430
9431        let degraded = governor.observe(governor_observation(
9432            8.0,
9433            80,
9434            0,
9435            DegradationLevel::Full,
9436            false,
9437            false,
9438        ));
9439        assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9440        assert_eq!(degraded.pressure_class, RuntimePressureClass::HardOverload);
9441        assert_eq!(
9442            degraded.disposition,
9443            RuntimeWorkDisposition::DeferBackgroundDropBestEffort
9444        );
9445        assert_eq!(degraded.reason_code, "queue_degraded_watermark");
9446
9447        let recovery_pending = governor.observe(governor_observation(
9448            8.0,
9449            10,
9450            0,
9451            DegradationLevel::Full,
9452            false,
9453            false,
9454        ));
9455        assert_eq!(recovery_pending.mode, RuntimeLoadMode::Degraded);
9456        assert_eq!(recovery_pending.reason_code, "recovery_hysteresis_pending");
9457        assert_eq!(recovery_pending.recovery_intervals_observed, 1);
9458
9459        let recovered = governor.observe(governor_observation(
9460            8.0,
9461            10,
9462            0,
9463            DegradationLevel::Full,
9464            false,
9465            false,
9466        ));
9467        assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9468        assert_eq!(recovered.reason_code, "recovery_hysteresis_satisfied");
9469        assert_eq!(
9470            recovered.disposition,
9471            RuntimeWorkDisposition::ReadmitAfterHysteresis
9472        );
9473
9474        let healthy = governor.observe(governor_observation(
9475            8.0,
9476            10,
9477            0,
9478            DegradationLevel::Full,
9479            false,
9480            false,
9481        ));
9482        assert_eq!(healthy.mode, RuntimeLoadMode::Healthy);
9483        assert_eq!(healthy.reason_code, "recovered_interval_closed");
9484    }
9485
9486    #[test]
9487    fn load_governor_uses_uncapped_budget_pressure_fallback() {
9488        let mut governor = test_load_governor(0, 2);
9489
9490        let stressed = governor.observe(governor_observation(
9491            20.0,
9492            0,
9493            0,
9494            DegradationLevel::Full,
9495            false,
9496            false,
9497        ));
9498        assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9499        assert_eq!(stressed.reason_code, "frame_budget_overrun");
9500        assert_eq!(stressed.queue_max_depth, None);
9501
9502        let degraded = governor.observe(governor_observation(
9503            8.0,
9504            0,
9505            0,
9506            DegradationLevel::SimpleBorders,
9507            false,
9508            false,
9509        ));
9510        assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9511        assert_eq!(degraded.reason_code, "budget_degradation_active");
9512    }
9513
9514    #[test]
9515    fn load_governor_strict_semantics_failure_is_terminal() {
9516        let mut governor = test_load_governor(100, 2);
9517
9518        let unsafe_snapshot = governor.observe(governor_observation(
9519            8.0,
9520            0,
9521            0,
9522            DegradationLevel::Full,
9523            false,
9524            true,
9525        ));
9526
9527        assert_eq!(unsafe_snapshot.mode, RuntimeLoadMode::Unsafe);
9528        assert_eq!(unsafe_snapshot.pressure_class, RuntimePressureClass::Unsafe);
9529        assert_eq!(
9530            unsafe_snapshot.disposition,
9531            RuntimeWorkDisposition::FailFastStrictGuarantee
9532        );
9533        assert!(!unsafe_snapshot.strict_semantics_preserved);
9534        assert_eq!(unsafe_snapshot.reason_code, "strict_semantics_violation");
9535    }
9536
9537    #[test]
9538    fn load_governor_unsafe_latches_through_later_pressure() {
9539        let mut governor = test_load_governor(100, 2);
9540
9541        // Enter Unsafe via a strict-semantics violation.
9542        let entered = governor.observe(governor_observation(
9543            8.0,
9544            0,
9545            0,
9546            DegradationLevel::Full,
9547            false,
9548            true,
9549        ));
9550        assert_eq!(entered.mode, RuntimeLoadMode::Unsafe);
9551
9552        // A later hard-overload interval (queue ratio >= degraded watermark) with
9553        // NO fresh violation must not downgrade the terminal Unsafe state.
9554        let hard = governor.observe(governor_observation(
9555            8.0,
9556            90,
9557            0,
9558            DegradationLevel::SimpleBorders,
9559            false,
9560            false,
9561        ));
9562        assert_eq!(hard.mode, RuntimeLoadMode::Unsafe);
9563        assert_eq!(
9564            hard.disposition,
9565            RuntimeWorkDisposition::FailFastStrictGuarantee
9566        );
9567        assert!(!hard.strict_semantics_preserved);
9568        assert_eq!(hard.reason_code, "strict_semantics_violation");
9569
9570        // A soft-overload interval (would otherwise classify as Stressed) must
9571        // also keep Unsafe latched rather than escaping downward.
9572        let soft = governor.observe(governor_observation(
9573            8.0,
9574            60,
9575            0,
9576            DegradationLevel::Full,
9577            true,
9578            false,
9579        ));
9580        assert_eq!(soft.mode, RuntimeLoadMode::Unsafe);
9581
9582        // And a fully steady interval never recovers out of Unsafe.
9583        let steady = governor.observe(governor_observation(
9584            8.0,
9585            0,
9586            0,
9587            DegradationLevel::Full,
9588            false,
9589            false,
9590        ));
9591        assert_eq!(steady.mode, RuntimeLoadMode::Unsafe);
9592        assert!(!steady.strict_semantics_preserved);
9593    }
9594
9595    #[test]
9596    fn load_governor_hysteresis_prevents_single_sample_recovery() {
9597        let mut governor = test_load_governor(100, 3);
9598
9599        governor.observe(governor_observation(
9600            8.0,
9601            90,
9602            0,
9603            DegradationLevel::Full,
9604            false,
9605            false,
9606        ));
9607
9608        for expected in 1..=2 {
9609            let snapshot = governor.observe(governor_observation(
9610                8.0,
9611                0,
9612                0,
9613                DegradationLevel::Full,
9614                false,
9615                false,
9616            ));
9617            assert_eq!(snapshot.mode, RuntimeLoadMode::Degraded);
9618            assert_eq!(snapshot.recovery_intervals_observed, expected);
9619            assert_eq!(snapshot.reason_code, "recovery_hysteresis_pending");
9620        }
9621
9622        let recovered = governor.observe(governor_observation(
9623            8.0,
9624            0,
9625            0,
9626            DegradationLevel::Full,
9627            false,
9628            false,
9629        ));
9630        assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9631    }
9632
9633    #[test]
9634    fn load_governor_stress_e2e_evidence_shows_degraded_and_recovery() {
9635        let mut governor = test_load_governor(50, 2);
9636        let scenario = [
9637            governor_observation(8.0, 0, 0, DegradationLevel::Full, false, false),
9638            governor_observation(18.0, 25, 0, DegradationLevel::Full, true, false),
9639            governor_observation(22.0, 45, 1, DegradationLevel::SimpleBorders, true, false),
9640            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9641            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9642            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9643        ];
9644
9645        let mut modes = Vec::new();
9646        for observation in scenario {
9647            let snapshot = governor.observe(observation);
9648            modes.push(snapshot.mode);
9649            println!(
9650                "{{\"test\":\"load_governor_stress_e2e\",\"mode\":\"{}\",\"pressure_class\":\"{}\",\"work_disposition\":\"{}\",\"reason\":\"{}\",\"transition\":{},\"deferred\":{},\"coalesced\":{},\"dropped\":{}}}",
9651                snapshot.mode.as_str(),
9652                snapshot.pressure_class.as_str(),
9653                snapshot.disposition.as_str(),
9654                snapshot.reason_code,
9655                snapshot.transition,
9656                snapshot.deferred_work_total,
9657                snapshot.coalesced_work_total,
9658                snapshot.dropped_work_total
9659            );
9660        }
9661
9662        assert!(modes.contains(&RuntimeLoadMode::Stressed));
9663        assert!(modes.contains(&RuntimeLoadMode::Degraded));
9664        assert!(modes.contains(&RuntimeLoadMode::Recovered));
9665        assert_eq!(modes.last(), Some(&RuntimeLoadMode::Healthy));
9666    }
9667
9668    #[test]
9669    fn headless_program_default_load_governor_attaches_controller() {
9670        let program =
9671            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9672        assert!(program.budget.controller().is_some());
9673    }
9674
9675    #[test]
9676    fn headless_program_load_governor_target_tracks_frame_budget() {
9677        let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
9678            total: Duration::from_millis(50),
9679            ..Default::default()
9680        });
9681        let program = headless_program_with_config(TestModel { value: 0 }, config);
9682        assert_eq!(
9683            program.budget.controller().unwrap().config().target,
9684            Duration::from_millis(50)
9685        );
9686    }
9687
9688    #[test]
9689    fn headless_program_without_load_governor_uses_legacy_budget() {
9690        let program = headless_program_with_config(
9691            TestModel { value: 0 },
9692            ProgramConfig::default().without_load_governor(),
9693        );
9694        assert!(program.budget.controller().is_none());
9695    }
9696
9697    #[test]
9698    fn app_builder_without_load_governor_sets_config() {
9699        let builder = App::new(TestModel { value: 0 }).without_load_governor();
9700        assert!(!builder.config.load_governor.enabled);
9701    }
9702
9703    #[test]
9704    fn program_config_with_conformal() {
9705        let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
9706            alpha: 0.2,
9707            ..Default::default()
9708        });
9709        assert!(config.conformal_config.is_some());
9710        assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
9711    }
9712
9713    #[test]
9714    fn program_config_forced_size_clamps_minimums() {
9715        let config = ProgramConfig::default().with_forced_size(0, 0);
9716        assert_eq!(config.forced_size, Some((1, 1)));
9717
9718        let cleared = config.without_forced_size();
9719        assert!(cleared.forced_size.is_none());
9720    }
9721
9722    #[test]
9723    fn effect_queue_config_defaults_are_safe() {
9724        let config = EffectQueueConfig::default();
9725        assert!(!config.enabled);
9726        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
9727        assert!(config.scheduler.smith_enabled);
9728        assert!(!config.scheduler.preemptive);
9729        assert_eq!(config.scheduler.aging_factor, 0.0);
9730        assert_eq!(config.scheduler.wait_starve_ms, 0.0);
9731    }
9732
9733    #[test]
9734    fn handle_effect_command_enqueues_or_executes_inline() {
9735        let (result_tx, result_rx) = mpsc::channel::<u32>();
9736        let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
9737        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9738
9739        let ran = Arc::new(AtomicUsize::new(0));
9740        let ran_task = ran.clone();
9741        let cmd = EffectCommand::Enqueue(
9742            TaskSpec::default(),
9743            Box::new(move || {
9744                ran_task.fetch_add(1, Ordering::SeqCst);
9745                7
9746            }),
9747        );
9748
9749        let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
9750        assert_eq!(shutdown, EffectLoopControl::Continue);
9751        assert_eq!(ran.load(Ordering::SeqCst), 0);
9752        assert_eq!(tasks.len(), 1);
9753        assert!(result_rx.try_recv().is_err());
9754
9755        let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
9756            max_queue_size: 0,
9757            ..Default::default()
9758        });
9759        let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9760        let ran_full = Arc::new(AtomicUsize::new(0));
9761        let ran_full_task = ran_full.clone();
9762        let cmd_full = EffectCommand::Enqueue(
9763            TaskSpec::default(),
9764            Box::new(move || {
9765                ran_full_task.fetch_add(1, Ordering::SeqCst);
9766                42
9767            }),
9768        );
9769
9770        let shutdown_full = handle_effect_command(
9771            cmd_full,
9772            &mut full_scheduler,
9773            &mut full_tasks,
9774            &result_tx,
9775            None,
9776            0,
9777        );
9778        assert_eq!(shutdown_full, EffectLoopControl::Continue);
9779        assert!(full_tasks.is_empty());
9780        assert_eq!(ran_full.load(Ordering::SeqCst), 1);
9781        assert_eq!(
9782            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9783            42
9784        );
9785
9786        let shutdown = handle_effect_command(
9787            EffectCommand::Shutdown,
9788            &mut full_scheduler,
9789            &mut full_tasks,
9790            &result_tx,
9791            None,
9792            0,
9793        );
9794        assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
9795    }
9796
9797    #[test]
9798    fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
9799        let evidence_path = temp_evidence_path("task_executor_backpressure");
9800        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
9801        let sink = EvidenceSink::from_config(&sink_config)
9802            .expect("evidence sink config")
9803            .expect("evidence sink enabled");
9804        let (result_tx, result_rx) = mpsc::channel::<u32>();
9805        let mut scheduler = QueueingScheduler::new(SchedulerConfig {
9806            max_queue_size: 0,
9807            ..Default::default()
9808        });
9809        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9810
9811        let shutdown = handle_effect_command(
9812            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
9813            &mut scheduler,
9814            &mut tasks,
9815            &result_tx,
9816            Some(&sink),
9817            0,
9818        );
9819
9820        assert_eq!(shutdown, EffectLoopControl::Continue);
9821        assert!(tasks.is_empty());
9822        assert_eq!(
9823            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9824            7
9825        );
9826
9827        let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
9828        assert_eq!(backpressure_line["backend"], "queued");
9829        assert_eq!(backpressure_line["action"], "inline_fallback");
9830        assert_eq!(backpressure_line["max_queue_size"], 0);
9831        assert_eq!(backpressure_line["total_rejected"], 1);
9832
9833        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
9834        assert_eq!(completion_line["backend"], "queued-inline-fallback");
9835        assert!(completion_line["duration_us"].is_number());
9836    }
9837
9838    #[test]
9839    fn effect_queue_loop_executes_tasks_and_shutdowns() {
9840        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9841        let (result_tx, result_rx) = mpsc::channel::<u32>();
9842        let config = EffectQueueConfig {
9843            enabled: true,
9844            backend: TaskExecutorBackend::EffectQueue,
9845            scheduler: SchedulerConfig {
9846                preemptive: false,
9847                ..Default::default()
9848            },
9849            explicit_backend: true,
9850            ..Default::default()
9851        };
9852
9853        let handle = std::thread::spawn(move || {
9854            effect_queue_loop(config, cmd_rx, result_tx, None);
9855        });
9856
9857        cmd_tx
9858            .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
9859            .unwrap();
9860        cmd_tx
9861            .send(EffectCommand::Enqueue(
9862                TaskSpec::new(2.0, 5.0).with_name("second"),
9863                Box::new(|| 20),
9864            ))
9865            .unwrap();
9866
9867        let mut results = vec![
9868            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9869            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9870        ];
9871        results.sort_unstable();
9872        assert_eq!(results, vec![10, 20]);
9873
9874        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9875        let _ = handle.join();
9876    }
9877
9878    #[test]
9879    fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
9880        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9881        let (result_tx, result_rx) = mpsc::channel::<u32>();
9882        let config = EffectQueueConfig {
9883            enabled: true,
9884            backend: TaskExecutorBackend::EffectQueue,
9885            scheduler: SchedulerConfig {
9886                preemptive: false,
9887                ..Default::default()
9888            },
9889            explicit_backend: true,
9890            ..Default::default()
9891        };
9892
9893        let handle = std::thread::spawn(move || {
9894            effect_queue_loop(config, cmd_rx, result_tx, None);
9895        });
9896
9897        cmd_tx
9898            .send(EffectCommand::Enqueue(
9899                TaskSpec::default().with_name("slow"),
9900                Box::new(|| {
9901                    std::thread::sleep(Duration::from_millis(20));
9902                    10
9903                }),
9904            ))
9905            .unwrap();
9906        cmd_tx
9907            .send(EffectCommand::Enqueue(
9908                TaskSpec::new(2.0, 5.0).with_name("fast"),
9909                Box::new(|| 20),
9910            ))
9911            .unwrap();
9912        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9913
9914        let mut results = vec![
9915            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9916            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9917        ];
9918        results.sort_unstable();
9919        assert_eq!(results, vec![10, 20]);
9920
9921        handle
9922            .join()
9923            .expect("effect queue thread joins after draining");
9924    }
9925
9926    #[test]
9927    fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
9928        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9929        let (result_tx, result_rx) = mpsc::channel::<u32>();
9930        let config = EffectQueueConfig {
9931            enabled: true,
9932            backend: TaskExecutorBackend::EffectQueue,
9933            scheduler: SchedulerConfig {
9934                preemptive: false,
9935                ..Default::default()
9936            },
9937            explicit_backend: true,
9938            ..Default::default()
9939        };
9940
9941        let handle = std::thread::spawn(move || {
9942            effect_queue_loop(config, cmd_rx, result_tx, None);
9943        });
9944
9945        cmd_tx
9946            .send(EffectCommand::Enqueue(
9947                TaskSpec::new(3.0, 1.0).with_name("panic"),
9948                Box::new(|| panic!("queued panic")),
9949            ))
9950            .unwrap();
9951        cmd_tx
9952            .send(EffectCommand::Enqueue(
9953                TaskSpec::new(1.0, 5.0).with_name("after"),
9954                Box::new(|| 99),
9955            ))
9956            .unwrap();
9957
9958        assert_eq!(
9959            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9960            99
9961        );
9962
9963        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9964        handle
9965            .join()
9966            .expect("effect queue thread survives task panic");
9967    }
9968
9969    #[test]
9970    fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
9971        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9972        let (result_tx, result_rx) = mpsc::channel::<u32>();
9973        let config = EffectQueueConfig {
9974            enabled: true,
9975            backend: TaskExecutorBackend::EffectQueue,
9976            scheduler: SchedulerConfig {
9977                preemptive: false,
9978                ..Default::default()
9979            },
9980            explicit_backend: true,
9981            ..Default::default()
9982        };
9983
9984        let handle = std::thread::spawn(move || {
9985            effect_queue_loop(config, cmd_rx, result_tx, None);
9986        });
9987
9988        cmd_tx
9989            .send(EffectCommand::Enqueue(
9990                TaskSpec::default().with_name("slow"),
9991                Box::new(|| {
9992                    std::thread::sleep(Duration::from_millis(20));
9993                    10
9994                }),
9995            ))
9996            .unwrap();
9997        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9998        cmd_tx
9999            .send(EffectCommand::Enqueue(
10000                TaskSpec::new(1.0, 1.0).with_name("late"),
10001                Box::new(|| 99),
10002            ))
10003            .unwrap();
10004
10005        assert_eq!(
10006            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
10007            10
10008        );
10009        assert!(
10010            result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
10011            "post-shutdown enqueue should not execute"
10012        );
10013
10014        handle
10015            .join()
10016            .expect("effect queue thread joins after rejecting post-shutdown work");
10017    }
10018
10019    #[test]
10020    fn effect_queue_enqueue_after_shutdown_records_drop() {
10021        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
10022        drop(rx);
10023
10024        let queue = EffectQueue {
10025            sender: tx,
10026            handle: None,
10027            closed: true,
10028        };
10029        let runs = Arc::new(AtomicUsize::new(0));
10030        let before = crate::effect_system::effects_queue_dropped();
10031
10032        queue.enqueue(
10033            TaskSpec::default(),
10034            Box::new({
10035                let runs = Arc::clone(&runs);
10036                move || {
10037                    runs.fetch_add(1, Ordering::SeqCst);
10038                    7
10039                }
10040            }),
10041        );
10042
10043        let after = crate::effect_system::effects_queue_dropped();
10044        assert_eq!(runs.load(Ordering::SeqCst), 0);
10045        assert!(
10046            after > before,
10047            "enqueue after shutdown should increment dropped counter"
10048        );
10049    }
10050
10051    #[test]
10052    fn effect_queue_enqueue_with_closed_channel_records_drop() {
10053        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
10054        drop(rx);
10055
10056        let queue = EffectQueue {
10057            sender: tx,
10058            handle: None,
10059            closed: false,
10060        };
10061        let runs = Arc::new(AtomicUsize::new(0));
10062        let before = crate::effect_system::effects_queue_dropped();
10063
10064        queue.enqueue(
10065            TaskSpec::default(),
10066            Box::new({
10067                let runs = Arc::clone(&runs);
10068                move || {
10069                    runs.fetch_add(1, Ordering::SeqCst);
10070                    9
10071                }
10072            }),
10073        );
10074
10075        let after = crate::effect_system::effects_queue_dropped();
10076        assert_eq!(runs.load(Ordering::SeqCst), 0);
10077        assert!(
10078            after > before,
10079            "enqueue into a closed queue channel should increment dropped counter"
10080        );
10081    }
10082
10083    // =========================================================================
10084    // Backpressure tests (bd-2zd0a)
10085    // =========================================================================
10086
10087    #[test]
10088    fn backpressure_drops_tasks_beyond_max_depth() {
10089        let (result_tx, _result_rx) = mpsc::channel::<u32>();
10090        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
10091        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
10092
10093        // Enqueue 2 tasks with max_depth=2 — should succeed
10094        let r1 = handle_effect_command(
10095            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
10096            &mut scheduler,
10097            &mut tasks,
10098            &result_tx,
10099            None,
10100            2,
10101        );
10102        assert_eq!(r1, EffectLoopControl::Continue);
10103        assert_eq!(tasks.len(), 1);
10104
10105        let r2 = handle_effect_command(
10106            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
10107            &mut scheduler,
10108            &mut tasks,
10109            &result_tx,
10110            None,
10111            2,
10112        );
10113        assert_eq!(r2, EffectLoopControl::Continue);
10114        assert_eq!(tasks.len(), 2);
10115
10116        // 3rd task should be dropped (depth=2 >= max_depth=2)
10117        let dropped_before = crate::effect_system::effects_queue_dropped();
10118        let r3 = handle_effect_command(
10119            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
10120            &mut scheduler,
10121            &mut tasks,
10122            &result_tx,
10123            None,
10124            2,
10125        );
10126        assert_eq!(r3, EffectLoopControl::Continue);
10127        assert_eq!(
10128            tasks.len(),
10129            2,
10130            "task should have been dropped, not enqueued"
10131        );
10132        assert!(
10133            crate::effect_system::effects_queue_dropped() > dropped_before,
10134            "dropped counter should increment"
10135        );
10136    }
10137
10138    #[test]
10139    fn backpressure_zero_depth_means_unbounded() {
10140        let (result_tx, _result_rx) = mpsc::channel::<u32>();
10141        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
10142        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
10143
10144        // With max_depth=0, can enqueue many tasks
10145        for i in 0..20 {
10146            let r = handle_effect_command(
10147                EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
10148                &mut scheduler,
10149                &mut tasks,
10150                &result_tx,
10151                None,
10152                0,
10153            );
10154            assert_eq!(r, EffectLoopControl::Continue);
10155        }
10156        // All should be enqueued (some may have been inlined by scheduler, but none dropped)
10157    }
10158
10159    #[test]
10160    fn inline_auto_remeasure_reset_clears_decision() {
10161        let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
10162        state.sampler.decide(Instant::now());
10163        assert!(state.sampler.last_decision().is_some());
10164
10165        state.reset();
10166        assert!(state.sampler.last_decision().is_none());
10167    }
10168
10169    #[test]
10170    fn budget_decision_jsonl_contains_required_fields() {
10171        let evidence = BudgetDecisionEvidence {
10172            frame_idx: 7,
10173            decision: BudgetDecision::Degrade,
10174            controller_decision: BudgetDecision::Hold,
10175            degradation_before: DegradationLevel::Full,
10176            degradation_after: DegradationLevel::NoStyling,
10177            frame_time_us: 12_345.678,
10178            budget_us: 16_000.0,
10179            pid_output: 1.25,
10180            pid_p: 0.5,
10181            pid_i: 0.25,
10182            pid_d: 0.5,
10183            e_value: 2.0,
10184            frames_observed: 42,
10185            frames_since_change: 3,
10186            in_warmup: false,
10187            controller_reason: BudgetDecisionReason::OverloadEvidencePassed,
10188            load_governor: LoadGovernorSnapshot {
10189                mode: RuntimeLoadMode::Degraded,
10190                mode_before: RuntimeLoadMode::Stressed,
10191                pressure_class: RuntimePressureClass::HardOverload,
10192                disposition: RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
10193                reason_code: "budget_degradation_active",
10194                transition: true,
10195                strict_semantics_preserved: true,
10196                queue_in_flight: 8,
10197                queue_max_depth: Some(10),
10198                queue_dropped_delta: 0,
10199                resize_coalescing_active: false,
10200                recovery_intervals_observed: 0,
10201                recovery_intervals_required: 3,
10202                deferred_work_total: 2,
10203                coalesced_work_total: 1,
10204                dropped_work_total: 0,
10205            },
10206            conformal: Some(ConformalEvidence {
10207                bucket_key: "inline:dirty:10".to_string(),
10208                n_b: 32,
10209                alpha: 0.05,
10210                q_b: 1000.0,
10211                y_hat: 12_000.0,
10212                upper_us: 13_000.0,
10213                risk: true,
10214                fallback_level: 1,
10215                window_size: 256,
10216                reset_count: 2,
10217            }),
10218        };
10219
10220        let jsonl = evidence.to_jsonl();
10221        assert!(jsonl.contains("\"event\":\"budget_decision\""));
10222        assert!(jsonl.contains("\"decision\":\"degrade\""));
10223        assert!(jsonl.contains("\"decision_controller\":\"stay\""));
10224        assert!(jsonl.contains("\"decision_controller_reason\":\"overload_evidence_passed\""));
10225        assert!(jsonl.contains("\"degradation_before\":\"Full\""));
10226        assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
10227        assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
10228        assert!(jsonl.contains("\"budget_us\":16000.000000"));
10229        assert!(jsonl.contains("\"pid_output\":1.250000"));
10230        assert!(jsonl.contains("\"e_value\":2.000000"));
10231        assert!(jsonl.contains("\"runtime_mode\":\"degraded\""));
10232        assert!(jsonl.contains("\"runtime_mode_before\":\"stressed\""));
10233        assert!(jsonl.contains("\"pressure_class\":\"hard_overload\""));
10234        assert!(jsonl.contains("\"work_disposition\":\"defer_background_drop_best_effort\""));
10235        assert!(jsonl.contains("\"governor_reason\":\"budget_degradation_active\""));
10236        assert!(jsonl.contains("\"governor_transition\":true"));
10237        assert!(jsonl.contains("\"strict_semantics_preserved\":true"));
10238        assert!(jsonl.contains("\"queue_in_flight\":8"));
10239        assert!(jsonl.contains("\"queue_max_depth\":10"));
10240        assert!(jsonl.contains("\"deferred_work_total\":2"));
10241        assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
10242        assert!(jsonl.contains("\"n_b\":32"));
10243        assert!(jsonl.contains("\"alpha\":0.050000"));
10244        assert!(jsonl.contains("\"q_b\":1000.000000"));
10245        assert!(jsonl.contains("\"y_hat\":12000.000000"));
10246        assert!(jsonl.contains("\"upper_us\":13000.000000"));
10247        assert!(jsonl.contains("\"risk\":true"));
10248        assert!(jsonl.contains("\"fallback_level\":1"));
10249        assert!(jsonl.contains("\"window_size\":256"));
10250        assert!(jsonl.contains("\"reset_count\":2"));
10251    }
10252
10253    fn make_signal(
10254        widget_id: u64,
10255        essential: bool,
10256        priority: f32,
10257        staleness_ms: u64,
10258        cost_us: f32,
10259    ) -> WidgetSignal {
10260        WidgetSignal {
10261            widget_id,
10262            essential,
10263            priority,
10264            staleness_ms,
10265            focus_boost: 0.0,
10266            interaction_boost: 0.0,
10267            area_cells: 1,
10268            cost_estimate_us: cost_us,
10269            recent_cost_us: 0.0,
10270            estimate_source: CostEstimateSource::FixedDefault,
10271        }
10272    }
10273
10274    fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
10275        let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
10276        let staleness_window = config.staleness_window_ms.max(1) as f32;
10277        let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
10278        let mut value = config.weight_priority * signal.priority
10279            + config.weight_staleness * staleness_score
10280            + config.weight_focus * signal.focus_boost
10281            + config.weight_interaction * signal.interaction_boost;
10282        if starved {
10283            value += config.starve_boost;
10284        }
10285        let raw_cost = if signal.recent_cost_us > 0.0 {
10286            signal.recent_cost_us
10287        } else {
10288            signal.cost_estimate_us
10289        };
10290        let cost_us = raw_cost.max(config.min_cost_us);
10291        (value, cost_us, starved)
10292    }
10293
10294    fn fifo_select(
10295        signals: &[WidgetSignal],
10296        budget_us: f64,
10297        config: &WidgetRefreshConfig,
10298    ) -> (Vec<u64>, f64, usize) {
10299        let mut selected = Vec::new();
10300        let mut total_value = 0.0f64;
10301        let mut starved_selected = 0usize;
10302        let mut remaining = budget_us;
10303
10304        for signal in signals {
10305            if !signal.essential {
10306                continue;
10307            }
10308            let (value, cost_us, starved) = signal_value_cost(signal, config);
10309            remaining -= cost_us as f64;
10310            total_value += value as f64;
10311            if starved {
10312                starved_selected = starved_selected.saturating_add(1);
10313            }
10314            selected.push(signal.widget_id);
10315        }
10316        for signal in signals {
10317            if signal.essential {
10318                continue;
10319            }
10320            let (value, cost_us, starved) = signal_value_cost(signal, config);
10321            if remaining >= cost_us as f64 {
10322                remaining -= cost_us as f64;
10323                total_value += value as f64;
10324                if starved {
10325                    starved_selected = starved_selected.saturating_add(1);
10326                }
10327                selected.push(signal.widget_id);
10328            }
10329        }
10330
10331        (selected, total_value, starved_selected)
10332    }
10333
10334    fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
10335        if signals.is_empty() {
10336            return Vec::new();
10337        }
10338        let mut rotated = Vec::with_capacity(signals.len());
10339        for idx in 0..signals.len() {
10340            rotated.push(signals[(idx + offset) % signals.len()].clone());
10341        }
10342        rotated
10343    }
10344
10345    #[test]
10346    fn widget_refresh_selects_essentials_first() {
10347        let signals = vec![
10348            make_signal(1, true, 0.6, 0, 5.0),
10349            make_signal(2, false, 0.9, 0, 4.0),
10350        ];
10351        let mut plan = WidgetRefreshPlan::new();
10352        let config = WidgetRefreshConfig::default();
10353        plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
10354        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10355        assert_eq!(selected, vec![1]);
10356        assert!(!plan.over_budget);
10357    }
10358
10359    #[test]
10360    fn widget_refresh_degradation_essential_only_skips_nonessential() {
10361        let signals = vec![
10362            make_signal(1, true, 0.5, 0, 2.0),
10363            make_signal(2, false, 1.0, 0, 1.0),
10364        ];
10365        let mut plan = WidgetRefreshPlan::new();
10366        let config = WidgetRefreshConfig::default();
10367        plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
10368        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10369        assert_eq!(selected, vec![1]);
10370        assert_eq!(plan.skipped_count, 1);
10371    }
10372
10373    #[test]
10374    fn widget_refresh_starvation_guard_forces_one_starved() {
10375        let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
10376        let mut plan = WidgetRefreshPlan::new();
10377        let config = WidgetRefreshConfig {
10378            starve_ms: 1_000,
10379            max_starved_per_frame: 1,
10380            ..Default::default()
10381        };
10382        plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
10383        assert_eq!(plan.selected.len(), 1);
10384        assert!(plan.selected[0].starved);
10385        assert!(plan.over_budget);
10386    }
10387
10388    #[test]
10389    fn widget_refresh_budget_blocks_when_no_selection() {
10390        let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
10391        let mut plan = WidgetRefreshPlan::new();
10392        let config = WidgetRefreshConfig {
10393            starve_ms: 0,
10394            max_starved_per_frame: 0,
10395            ..Default::default()
10396        };
10397        plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
10398        let budget = plan.as_budget();
10399        assert!(!budget.allows(42, false));
10400    }
10401
10402    #[test]
10403    fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
10404        let signals = vec![
10405            make_signal(1, false, 0.4, 0, 10.0),
10406            make_signal(2, false, 0.4, 0, 10.0),
10407            make_signal(3, false, 0.4, 0, 10.0),
10408            make_signal(4, false, 0.4, 0, 10.0),
10409        ];
10410        let mut plan = WidgetRefreshPlan::new();
10411        let config = WidgetRefreshConfig {
10412            starve_ms: 0,
10413            max_starved_per_frame: 0,
10414            max_drop_fraction: 0.5,
10415            ..Default::default()
10416        };
10417        plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
10418        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10419        assert_eq!(selected, vec![1, 2]);
10420    }
10421
10422    #[test]
10423    fn widget_refresh_greedy_beats_fifo_and_round_robin() {
10424        let signals = vec![
10425            make_signal(1, false, 0.1, 0, 6.0),
10426            make_signal(2, false, 0.2, 0, 6.0),
10427            make_signal(3, false, 1.0, 0, 4.0),
10428            make_signal(4, false, 0.9, 0, 3.0),
10429            make_signal(5, false, 0.8, 0, 3.0),
10430            make_signal(6, false, 0.1, 4_000, 2.0),
10431        ];
10432        let budget_us = 10.0;
10433        let config = WidgetRefreshConfig::default();
10434
10435        let mut plan = WidgetRefreshPlan::new();
10436        plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
10437        let greedy_value = plan.selected_value;
10438        let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10439
10440        let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
10441        let rotated = rotate_signals(&signals, 2);
10442        let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
10443
10444        assert!(
10445            greedy_value > fifo_value,
10446            "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
10447            greedy_selected,
10448            fifo_selected
10449        );
10450        assert!(
10451            greedy_value > rr_value,
10452            "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
10453            greedy_selected,
10454            rr_selected
10455        );
10456        assert!(
10457            plan.starved_selected > 0,
10458            "greedy did not select starved widget; greedy={:?}",
10459            greedy_selected
10460        );
10461    }
10462
10463    #[test]
10464    fn widget_refresh_jsonl_contains_required_fields() {
10465        let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
10466        let mut plan = WidgetRefreshPlan::new();
10467        let config = WidgetRefreshConfig::default();
10468        plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
10469        let jsonl = plan.to_jsonl();
10470        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
10471        assert!(jsonl.contains("\"frame_idx\":9"));
10472        assert!(jsonl.contains("\"selected_count\":1"));
10473        assert!(jsonl.contains("\"id\":7"));
10474    }
10475
10476    #[test]
10477    fn program_config_with_resize_coalescer() {
10478        let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
10479            steady_delay_ms: 8,
10480            burst_delay_ms: 20,
10481            hard_deadline_ms: 80,
10482            burst_enter_rate: 12.0,
10483            burst_exit_rate: 6.0,
10484            cooldown_frames: 2,
10485            rate_window_size: 6,
10486            enable_logging: true,
10487            enable_bocpd: false,
10488            bocpd_config: None,
10489        });
10490        assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
10491        assert!(config.resize_coalescer.enable_logging);
10492    }
10493
10494    #[test]
10495    fn program_config_with_resize_behavior() {
10496        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
10497        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10498    }
10499
10500    #[test]
10501    fn program_config_with_legacy_resize_enabled() {
10502        let config = ProgramConfig::default().with_legacy_resize(true);
10503        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10504    }
10505
10506    #[test]
10507    fn program_config_with_legacy_resize_disabled_keeps_default() {
10508        let config = ProgramConfig::default().with_legacy_resize(false);
10509        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
10510    }
10511
10512    fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
10513        let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
10514        let mut writer = TerminalWriter::with_diff_config(
10515            Vec::<u8>::new(),
10516            ScreenMode::AltScreen,
10517            UiAnchor::Bottom,
10518            TerminalCapabilities::basic(),
10519            config,
10520        );
10521        writer.set_size(8, 4);
10522
10523        let mut buffer = Buffer::new(8, 4);
10524        let mut trace = Vec::new();
10525
10526        writer.present_ui(&buffer, None, false).unwrap();
10527        trace.push(
10528            writer
10529                .last_diff_strategy()
10530                .unwrap_or(DiffStrategy::FullRedraw),
10531        );
10532
10533        buffer.set_raw(0, 0, Cell::from_char('A'));
10534        writer.present_ui(&buffer, None, false).unwrap();
10535        trace.push(
10536            writer
10537                .last_diff_strategy()
10538                .unwrap_or(DiffStrategy::FullRedraw),
10539        );
10540
10541        buffer.set_raw(1, 1, Cell::from_char('B'));
10542        writer.present_ui(&buffer, None, false).unwrap();
10543        trace.push(
10544            writer
10545                .last_diff_strategy()
10546                .unwrap_or(DiffStrategy::FullRedraw),
10547        );
10548
10549        trace
10550    }
10551
10552    fn coalescer_checksum(enable_bocpd: bool) -> String {
10553        let mut config = CoalescerConfig::default().with_logging(true);
10554        if enable_bocpd {
10555            config = config.with_bocpd();
10556        }
10557
10558        let base = Instant::now();
10559        let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
10560
10561        let events = [
10562            (0_u64, (82_u16, 24_u16)),
10563            (10, (83, 25)),
10564            (20, (84, 26)),
10565            (35, (90, 28)),
10566            (55, (92, 30)),
10567        ];
10568
10569        let mut idx = 0usize;
10570        for t_ms in (0_u64..=160).step_by(8) {
10571            let now = base + Duration::from_millis(t_ms);
10572            while idx < events.len() && events[idx].0 == t_ms {
10573                let (w, h) = events[idx].1;
10574                coalescer.handle_resize_at(w, h, now);
10575                idx += 1;
10576            }
10577            coalescer.tick_at(now);
10578        }
10579
10580        coalescer.decision_checksum_hex()
10581    }
10582
10583    fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
10584        if !enabled {
10585            return Vec::new();
10586        }
10587
10588        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
10589        let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
10590        let mut trace = Vec::new();
10591
10592        for i in 0..30 {
10593            let y_hat = 16_000.0 + (i as f64) * 15.0;
10594            let observed = y_hat + (i % 7) as f64 * 120.0;
10595            predictor.observe(key, y_hat, observed);
10596            let prediction = predictor.predict(key, y_hat, 20_000.0);
10597            trace.push((prediction.upper_us, prediction.risk));
10598        }
10599
10600        trace
10601    }
10602
10603    #[test]
10604    fn policy_toggle_matrix_determinism() {
10605        for &bayesian in &[false, true] {
10606            for &bocpd in &[false, true] {
10607                for &conformal in &[false, true] {
10608                    let diff_a = diff_strategy_trace(bayesian);
10609                    let diff_b = diff_strategy_trace(bayesian);
10610                    assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
10611
10612                    let checksum_a = coalescer_checksum(bocpd);
10613                    let checksum_b = coalescer_checksum(bocpd);
10614                    assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
10615
10616                    let conf_a = conformal_trace(conformal);
10617                    let conf_b = conformal_trace(conformal);
10618                    assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
10619
10620                    if conformal {
10621                        assert!(!conf_a.is_empty(), "conformal trace should be populated");
10622                    } else {
10623                        assert!(conf_a.is_empty(), "conformal trace should be empty");
10624                    }
10625                }
10626            }
10627        }
10628    }
10629
10630    #[test]
10631    fn resize_behavior_uses_coalescer_flag() {
10632        assert!(ResizeBehavior::Throttled.uses_coalescer());
10633        assert!(!ResizeBehavior::Immediate.uses_coalescer());
10634    }
10635
10636    #[test]
10637    fn nested_cmd_msg_executes_recursively() {
10638        // Verify that Cmd::Msg triggers recursive update
10639        use crate::simulator::ProgramSimulator;
10640
10641        struct NestedModel {
10642            depth: usize,
10643        }
10644
10645        #[derive(Debug)]
10646        enum NestedMsg {
10647            Nest(usize),
10648        }
10649
10650        impl From<Event> for NestedMsg {
10651            fn from(_: Event) -> Self {
10652                NestedMsg::Nest(0)
10653            }
10654        }
10655
10656        impl Model for NestedModel {
10657            type Message = NestedMsg;
10658
10659            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10660                match msg {
10661                    NestedMsg::Nest(n) => {
10662                        self.depth += 1;
10663                        if n > 0 {
10664                            Cmd::msg(NestedMsg::Nest(n - 1))
10665                        } else {
10666                            Cmd::none()
10667                        }
10668                    }
10669                }
10670            }
10671
10672            fn view(&self, _frame: &mut Frame) {}
10673        }
10674
10675        let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
10676        sim.init();
10677        sim.send(NestedMsg::Nest(3));
10678
10679        // Should have recursed 4 times (3, 2, 1, 0)
10680        assert_eq!(sim.model().depth, 4);
10681    }
10682
10683    #[test]
10684    fn task_executes_synchronously_in_simulator() {
10685        // In simulator, tasks execute synchronously
10686        use crate::simulator::ProgramSimulator;
10687
10688        struct TaskModel {
10689            completed: bool,
10690        }
10691
10692        #[derive(Debug)]
10693        enum TaskMsg {
10694            Complete,
10695            SpawnTask,
10696        }
10697
10698        impl From<Event> for TaskMsg {
10699            fn from(_: Event) -> Self {
10700                TaskMsg::Complete
10701            }
10702        }
10703
10704        impl Model for TaskModel {
10705            type Message = TaskMsg;
10706
10707            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10708                match msg {
10709                    TaskMsg::Complete => {
10710                        self.completed = true;
10711                        Cmd::none()
10712                    }
10713                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
10714                }
10715            }
10716
10717            fn view(&self, _frame: &mut Frame) {}
10718        }
10719
10720        let mut sim = ProgramSimulator::new(TaskModel { completed: false });
10721        sim.init();
10722        sim.send(TaskMsg::SpawnTask);
10723
10724        // Task should have completed synchronously
10725        assert!(sim.model().completed);
10726    }
10727
10728    #[test]
10729    fn multiple_updates_accumulate_correctly() {
10730        // Verify state accumulates correctly across multiple updates
10731        use crate::simulator::ProgramSimulator;
10732
10733        struct AccumModel {
10734            sum: i32,
10735        }
10736
10737        #[derive(Debug)]
10738        enum AccumMsg {
10739            Add(i32),
10740            Multiply(i32),
10741        }
10742
10743        impl From<Event> for AccumMsg {
10744            fn from(_: Event) -> Self {
10745                AccumMsg::Add(1)
10746            }
10747        }
10748
10749        impl Model for AccumModel {
10750            type Message = AccumMsg;
10751
10752            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10753                match msg {
10754                    AccumMsg::Add(n) => {
10755                        self.sum += n;
10756                        Cmd::none()
10757                    }
10758                    AccumMsg::Multiply(n) => {
10759                        self.sum *= n;
10760                        Cmd::none()
10761                    }
10762                }
10763            }
10764
10765            fn view(&self, _frame: &mut Frame) {}
10766        }
10767
10768        let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
10769        sim.init();
10770
10771        // (0 + 5) * 2 + 3 = 13
10772        sim.send(AccumMsg::Add(5));
10773        sim.send(AccumMsg::Multiply(2));
10774        sim.send(AccumMsg::Add(3));
10775
10776        assert_eq!(sim.model().sum, 13);
10777    }
10778
10779    #[test]
10780    fn init_command_executes_before_first_update() {
10781        // Verify init() command executes before any update
10782        use crate::simulator::ProgramSimulator;
10783
10784        struct InitModel {
10785            initialized: bool,
10786            updates: usize,
10787        }
10788
10789        #[derive(Debug)]
10790        enum InitMsg {
10791            Update,
10792            MarkInit,
10793        }
10794
10795        impl From<Event> for InitMsg {
10796            fn from(_: Event) -> Self {
10797                InitMsg::Update
10798            }
10799        }
10800
10801        impl Model for InitModel {
10802            type Message = InitMsg;
10803
10804            fn init(&mut self) -> Cmd<Self::Message> {
10805                Cmd::msg(InitMsg::MarkInit)
10806            }
10807
10808            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10809                match msg {
10810                    InitMsg::MarkInit => {
10811                        self.initialized = true;
10812                        Cmd::none()
10813                    }
10814                    InitMsg::Update => {
10815                        self.updates += 1;
10816                        Cmd::none()
10817                    }
10818                }
10819            }
10820
10821            fn view(&self, _frame: &mut Frame) {}
10822        }
10823
10824        let mut sim = ProgramSimulator::new(InitModel {
10825            initialized: false,
10826            updates: 0,
10827        });
10828        sim.init();
10829
10830        assert!(sim.model().initialized);
10831        sim.send(InitMsg::Update);
10832        assert_eq!(sim.model().updates, 1);
10833    }
10834
10835    // =========================================================================
10836    // INLINE MODE FRAME SIZING TESTS (bd-20vg)
10837    // =========================================================================
10838
10839    #[test]
10840    fn ui_height_returns_correct_value_inline_mode() {
10841        // Verify TerminalWriter.ui_height() returns ui_height in inline mode
10842        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10843        use ftui_core::terminal_capabilities::TerminalCapabilities;
10844
10845        let output = Vec::new();
10846        let writer = TerminalWriter::new(
10847            output,
10848            ScreenMode::Inline { ui_height: 10 },
10849            UiAnchor::Bottom,
10850            TerminalCapabilities::basic(),
10851        );
10852        assert_eq!(writer.ui_height(), 10);
10853    }
10854
10855    #[test]
10856    fn ui_height_returns_term_height_altscreen_mode() {
10857        // Verify TerminalWriter.ui_height() returns full terminal height in alt-screen mode
10858        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10859        use ftui_core::terminal_capabilities::TerminalCapabilities;
10860
10861        let output = Vec::new();
10862        let mut writer = TerminalWriter::new(
10863            output,
10864            ScreenMode::AltScreen,
10865            UiAnchor::Bottom,
10866            TerminalCapabilities::basic(),
10867        );
10868        writer.set_size(80, 24);
10869        assert_eq!(writer.ui_height(), 24);
10870    }
10871
10872    #[test]
10873    fn inline_mode_frame_uses_ui_height_not_terminal_height() {
10874        // Verify that in inline mode, the model receives a frame with ui_height,
10875        // not the full terminal height. This is the core fix for bd-20vg.
10876        use crate::simulator::ProgramSimulator;
10877        use std::cell::Cell as StdCell;
10878
10879        thread_local! {
10880            static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
10881        }
10882
10883        struct FrameSizeTracker;
10884
10885        #[derive(Debug)]
10886        enum SizeMsg {
10887            Check,
10888        }
10889
10890        impl From<Event> for SizeMsg {
10891            fn from(_: Event) -> Self {
10892                SizeMsg::Check
10893            }
10894        }
10895
10896        impl Model for FrameSizeTracker {
10897            type Message = SizeMsg;
10898
10899            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10900                Cmd::none()
10901            }
10902
10903            fn view(&self, frame: &mut Frame) {
10904                // Capture the frame height we receive
10905                CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
10906            }
10907        }
10908
10909        // Use simulator to verify frame dimension handling
10910        let mut sim = ProgramSimulator::new(FrameSizeTracker);
10911        sim.init();
10912
10913        // Capture with specific dimensions (simulates inline mode ui_height=10)
10914        let buf = sim.capture_frame(80, 10);
10915        assert_eq!(buf.height(), 10);
10916        assert_eq!(buf.width(), 80);
10917
10918        // Verify the frame has the correct dimensions
10919        // In inline mode with ui_height=10, the frame should be 10 rows tall,
10920        // NOT the full terminal height (e.g., 24).
10921    }
10922
10923    #[test]
10924    fn altscreen_frame_uses_full_terminal_height() {
10925        // Regression test: in alt-screen mode, frame should use full terminal height.
10926        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10927        use ftui_core::terminal_capabilities::TerminalCapabilities;
10928
10929        let output = Vec::new();
10930        let mut writer = TerminalWriter::new(
10931            output,
10932            ScreenMode::AltScreen,
10933            UiAnchor::Bottom,
10934            TerminalCapabilities::basic(),
10935        );
10936        writer.set_size(80, 40);
10937
10938        // In alt-screen, ui_height equals terminal height
10939        assert_eq!(writer.ui_height(), 40);
10940    }
10941
10942    #[test]
10943    fn ui_height_clamped_to_terminal_height() {
10944        // Verify ui_height doesn't exceed terminal height
10945        // (This is handled in present_inline, but ui_height() returns the configured value)
10946        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10947        use ftui_core::terminal_capabilities::TerminalCapabilities;
10948
10949        let output = Vec::new();
10950        let mut writer = TerminalWriter::new(
10951            output,
10952            ScreenMode::Inline { ui_height: 100 },
10953            UiAnchor::Bottom,
10954            TerminalCapabilities::basic(),
10955        );
10956        writer.set_size(80, 10);
10957
10958        // ui_height() returns configured value, but present_inline clamps
10959        // The Frame should be created with ui_height (100), which is later
10960        // clamped during presentation. For safety, we should use the min.
10961        // Note: This documents current behavior. A stricter fix might
10962        // have ui_height() return min(ui_height, term_height).
10963        assert_eq!(writer.ui_height(), 100);
10964    }
10965
10966    // =========================================================================
10967    // TICK DELIVERY TESTS (bd-3ufh)
10968    // =========================================================================
10969
10970    #[test]
10971    fn tick_event_delivered_to_model_update() {
10972        // Verify that Event::Tick is delivered to model.update()
10973        // This is the core fix: ticks now flow through the update pipeline.
10974        use crate::simulator::ProgramSimulator;
10975
10976        struct TickTracker {
10977            tick_count: usize,
10978        }
10979
10980        #[derive(Debug)]
10981        enum TickMsg {
10982            Tick,
10983            Other,
10984        }
10985
10986        impl From<Event> for TickMsg {
10987            fn from(event: Event) -> Self {
10988                match event {
10989                    Event::Tick => TickMsg::Tick,
10990                    _ => TickMsg::Other,
10991                }
10992            }
10993        }
10994
10995        impl Model for TickTracker {
10996            type Message = TickMsg;
10997
10998            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10999                match msg {
11000                    TickMsg::Tick => {
11001                        self.tick_count += 1;
11002                        Cmd::none()
11003                    }
11004                    TickMsg::Other => Cmd::none(),
11005                }
11006            }
11007
11008            fn view(&self, _frame: &mut Frame) {}
11009        }
11010
11011        let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
11012        sim.init();
11013
11014        // Manually inject tick event to simulate what the runtime does
11015        sim.inject_event(Event::Tick);
11016        assert_eq!(sim.model().tick_count, 1);
11017
11018        sim.inject_event(Event::Tick);
11019        sim.inject_event(Event::Tick);
11020        assert_eq!(sim.model().tick_count, 3);
11021    }
11022
11023    #[test]
11024    fn tick_command_sets_tick_rate() {
11025        // Verify Cmd::tick() sets the tick rate in the simulator
11026        use crate::simulator::{CmdRecord, ProgramSimulator};
11027
11028        struct TickModel;
11029
11030        #[derive(Debug)]
11031        enum Msg {
11032            SetTick,
11033            Noop,
11034        }
11035
11036        impl From<Event> for Msg {
11037            fn from(_: Event) -> Self {
11038                Msg::Noop
11039            }
11040        }
11041
11042        impl Model for TickModel {
11043            type Message = Msg;
11044
11045            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11046                match msg {
11047                    Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
11048                    Msg::Noop => Cmd::none(),
11049                }
11050            }
11051
11052            fn view(&self, _frame: &mut Frame) {}
11053        }
11054
11055        let mut sim = ProgramSimulator::new(TickModel);
11056        sim.init();
11057        sim.send(Msg::SetTick);
11058
11059        // Check that tick was recorded
11060        let commands = sim.command_log();
11061        assert!(
11062            commands
11063                .iter()
11064                .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
11065        );
11066    }
11067
11068    #[test]
11069    fn tick_can_trigger_further_commands() {
11070        // Verify that tick handling can return commands that are executed
11071        use crate::simulator::ProgramSimulator;
11072
11073        struct ChainModel {
11074            stage: usize,
11075        }
11076
11077        #[derive(Debug)]
11078        enum ChainMsg {
11079            Tick,
11080            Advance,
11081            Noop,
11082        }
11083
11084        impl From<Event> for ChainMsg {
11085            fn from(event: Event) -> Self {
11086                match event {
11087                    Event::Tick => ChainMsg::Tick,
11088                    _ => ChainMsg::Noop,
11089                }
11090            }
11091        }
11092
11093        impl Model for ChainModel {
11094            type Message = ChainMsg;
11095
11096            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11097                match msg {
11098                    ChainMsg::Tick => {
11099                        self.stage += 1;
11100                        // Return another message to be processed
11101                        Cmd::msg(ChainMsg::Advance)
11102                    }
11103                    ChainMsg::Advance => {
11104                        self.stage += 10;
11105                        Cmd::none()
11106                    }
11107                    ChainMsg::Noop => Cmd::none(),
11108                }
11109            }
11110
11111            fn view(&self, _frame: &mut Frame) {}
11112        }
11113
11114        let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
11115        sim.init();
11116        sim.inject_event(Event::Tick);
11117
11118        // Tick increments by 1, then Advance increments by 10
11119        assert_eq!(sim.model().stage, 11);
11120    }
11121
11122    #[test]
11123    fn tick_disabled_with_zero_duration() {
11124        // Verify that Duration::ZERO disables ticks (no busy loop)
11125        use crate::simulator::ProgramSimulator;
11126
11127        struct ZeroTickModel {
11128            disabled: bool,
11129        }
11130
11131        #[derive(Debug)]
11132        enum ZeroMsg {
11133            DisableTick,
11134            Noop,
11135        }
11136
11137        impl From<Event> for ZeroMsg {
11138            fn from(_: Event) -> Self {
11139                ZeroMsg::Noop
11140            }
11141        }
11142
11143        impl Model for ZeroTickModel {
11144            type Message = ZeroMsg;
11145
11146            fn init(&mut self) -> Cmd<Self::Message> {
11147                // Start with a tick enabled
11148                Cmd::tick(Duration::from_millis(100))
11149            }
11150
11151            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11152                match msg {
11153                    ZeroMsg::DisableTick => {
11154                        self.disabled = true;
11155                        // Setting tick to ZERO should effectively disable
11156                        Cmd::tick(Duration::ZERO)
11157                    }
11158                    ZeroMsg::Noop => Cmd::none(),
11159                }
11160            }
11161
11162            fn view(&self, _frame: &mut Frame) {}
11163        }
11164
11165        let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
11166        sim.init();
11167
11168        // Verify initial tick rate is set
11169        assert!(sim.tick_rate().is_some());
11170        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
11171
11172        // Disable ticks
11173        sim.send(ZeroMsg::DisableTick);
11174        assert!(sim.model().disabled);
11175
11176        // Note: The simulator still records the ZERO tick, but the runtime's
11177        // should_tick() handles ZERO duration appropriately
11178        assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
11179    }
11180
11181    #[test]
11182    fn tick_event_distinguishable_from_other_events() {
11183        // Verify Event::Tick can be distinguished in pattern matching
11184        let tick = Event::Tick;
11185        let key = Event::Key(ftui_core::event::KeyEvent::new(
11186            ftui_core::event::KeyCode::Char('a'),
11187        ));
11188
11189        assert!(matches!(tick, Event::Tick));
11190        assert!(!matches!(key, Event::Tick));
11191    }
11192
11193    #[test]
11194    fn tick_event_clone_and_eq() {
11195        // Verify Event::Tick implements Clone and Eq correctly
11196        let tick1 = Event::Tick;
11197        let tick2 = tick1.clone();
11198        assert_eq!(tick1, tick2);
11199    }
11200
11201    #[test]
11202    fn model_receives_tick_and_input_events() {
11203        // Verify model can handle both tick and input events correctly
11204        use crate::simulator::ProgramSimulator;
11205
11206        struct MixedModel {
11207            ticks: usize,
11208            keys: usize,
11209        }
11210
11211        #[derive(Debug)]
11212        enum MixedMsg {
11213            Tick,
11214            Key,
11215        }
11216
11217        impl From<Event> for MixedMsg {
11218            fn from(event: Event) -> Self {
11219                match event {
11220                    Event::Tick => MixedMsg::Tick,
11221                    _ => MixedMsg::Key,
11222                }
11223            }
11224        }
11225
11226        impl Model for MixedModel {
11227            type Message = MixedMsg;
11228
11229            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11230                match msg {
11231                    MixedMsg::Tick => {
11232                        self.ticks += 1;
11233                        Cmd::none()
11234                    }
11235                    MixedMsg::Key => {
11236                        self.keys += 1;
11237                        Cmd::none()
11238                    }
11239                }
11240            }
11241
11242            fn view(&self, _frame: &mut Frame) {}
11243        }
11244
11245        let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
11246        sim.init();
11247
11248        // Interleave tick and input events
11249        sim.inject_event(Event::Tick);
11250        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11251            ftui_core::event::KeyCode::Char('a'),
11252        )));
11253        sim.inject_event(Event::Tick);
11254        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11255            ftui_core::event::KeyCode::Char('b'),
11256        )));
11257        sim.inject_event(Event::Tick);
11258
11259        assert_eq!(sim.model().ticks, 3);
11260        assert_eq!(sim.model().keys, 2);
11261    }
11262
11263    // =========================================================================
11264    // HEADLESS PROGRAM TESTS (bd-1av4o.2)
11265    // =========================================================================
11266
11267    fn headless_program_with_resolved_config<M: Model>(
11268        model: M,
11269        config: ProgramConfig,
11270    ) -> Program<M, HeadlessEventSource, Vec<u8>>
11271    where
11272        M::Message: Send + 'static,
11273    {
11274        clear_termination_signal();
11275        let effect_queue_config = config.resolved_effect_queue_config();
11276        let capabilities = TerminalCapabilities::basic();
11277        let mut writer = TerminalWriter::with_diff_config(
11278            Vec::new(),
11279            config.screen_mode,
11280            config.ui_anchor,
11281            capabilities,
11282            config.diff_config.clone(),
11283        );
11284        let frame_timing = config.frame_timing.clone();
11285        writer.set_timing_enabled(frame_timing.is_some());
11286
11287        let (width, height) = config.forced_size.unwrap_or((80, 24));
11288        let width = width.max(1);
11289        let height = height.max(1);
11290        writer.set_size(width, height);
11291
11292        let mouse_capture = config.resolved_mouse_capture();
11293        let initial_features = BackendFeatures {
11294            mouse_capture,
11295            bracketed_paste: config.bracketed_paste,
11296            focus_events: config.focus_reporting,
11297            kitty_keyboard: config.kitty_keyboard,
11298        };
11299        let events = HeadlessEventSource::new(width, height, initial_features);
11300        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
11301            .expect("headless evidence sink config");
11302
11303        let budget = render_budget_from_program_config(&config);
11304        let load_governor = LoadGovernorState::new(
11305            config.load_governor.clone(),
11306            effect_queue_config.max_queue_depth,
11307        );
11308        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
11309        let locale_context = config.locale_context.clone();
11310        let locale_version = locale_context.version();
11311        let mut resize_coalescer =
11312            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
11313        if let Some(ref sink) = evidence_sink {
11314            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
11315        }
11316        let subscriptions = SubscriptionManager::new();
11317        let (task_sender, task_receiver) = std::sync::mpsc::channel();
11318        let inline_auto_remeasure = config
11319            .inline_auto_remeasure
11320            .clone()
11321            .map(InlineAutoRemeasureState::new);
11322        let guardrails = FrameGuardrails::new(config.guardrails);
11323        let task_executor = TaskExecutor::new(
11324            &effect_queue_config,
11325            task_sender.clone(),
11326            evidence_sink.clone(),
11327        )
11328        .expect("task executor");
11329
11330        Program {
11331            model,
11332            writer,
11333            events,
11334            backend_features: initial_features,
11335            running: true,
11336            shutdown_complete: false,
11337            tick_rate: None,
11338            executed_cmd_count: 0,
11339            last_tick: Instant::now(),
11340            dirty: true,
11341            frame_idx: 0,
11342            tick_count: 0,
11343            widget_signals: Vec::new(),
11344            widget_refresh_config: config.widget_refresh,
11345            widget_refresh_plan: WidgetRefreshPlan::new(),
11346            width,
11347            height,
11348            forced_size: config.forced_size,
11349            poll_timeout: config.poll_timeout,
11350            intercept_signals: config.intercept_signals,
11351            immediate_drain_config: config.immediate_drain,
11352            immediate_drain_stats: ImmediateDrainStats::default(),
11353            budget,
11354            load_governor,
11355            conformal_predictor,
11356            last_frame_time_us: None,
11357            last_update_us: None,
11358            frame_timing,
11359            locale_context,
11360            locale_version,
11361            resize_coalescer,
11362            evidence_sink,
11363            fairness_config_logged: false,
11364            resize_behavior: config.resize_behavior,
11365            fairness_guard: InputFairnessGuard::new(),
11366            event_recorder: None,
11367            subscriptions,
11368            #[cfg(test)]
11369            task_sender,
11370            task_receiver,
11371            task_executor,
11372            state_registry: config.persistence.registry.clone(),
11373            persistence_config: config.persistence,
11374            last_checkpoint: Instant::now(),
11375            inline_auto_remeasure,
11376            frame_arena: FrameArena::default(),
11377            guardrails,
11378            last_soft_trim_frame: None,
11379            tick_strategy: config
11380                .tick_strategy
11381                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
11382            last_active_screen_for_strategy: None,
11383        }
11384    }
11385
11386    fn headless_program_with_config<M: Model>(
11387        model: M,
11388        config: ProgramConfig,
11389    ) -> Program<M, HeadlessEventSource, Vec<u8>>
11390    where
11391        M::Message: Send + 'static,
11392    {
11393        // Headless unit tests should not observe process-global shutdown state
11394        // unless they explicitly opt into signal interception.
11395        headless_program_with_resolved_config(model, config.with_signal_interception(false))
11396    }
11397
11398    fn headless_signal_program_with_config<M: Model>(
11399        model: M,
11400        config: ProgramConfig,
11401    ) -> Program<M, HeadlessEventSource, Vec<u8>>
11402    where
11403        M::Message: Send + 'static,
11404    {
11405        headless_program_with_resolved_config(model, config)
11406    }
11407
11408    fn temp_evidence_path(label: &str) -> PathBuf {
11409        static COUNTER: AtomicUsize = AtomicUsize::new(0);
11410        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
11411        let pid = std::process::id();
11412        let mut path = std::env::temp_dir();
11413        path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
11414        path
11415    }
11416
11417    fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
11418        let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
11419        let needle = format!("\"event\":\"{event}\"");
11420        let missing_msg = format!("missing {event} line");
11421        let line = jsonl
11422            .lines()
11423            .find(|line| line.contains(&needle))
11424            .expect(&missing_msg);
11425        serde_json::from_str(line).expect("valid evidence json")
11426    }
11427
11428    #[test]
11429    fn headless_apply_resize_updates_model_and_dimensions() {
11430        struct ResizeModel {
11431            last_size: Option<(u16, u16)>,
11432        }
11433
11434        #[derive(Debug)]
11435        enum ResizeMsg {
11436            Resize(u16, u16),
11437            Other,
11438        }
11439
11440        impl From<Event> for ResizeMsg {
11441            fn from(event: Event) -> Self {
11442                match event {
11443                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11444                    _ => ResizeMsg::Other,
11445                }
11446            }
11447        }
11448
11449        impl Model for ResizeModel {
11450            type Message = ResizeMsg;
11451
11452            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11453                if let ResizeMsg::Resize(w, h) = msg {
11454                    self.last_size = Some((w, h));
11455                }
11456                Cmd::none()
11457            }
11458
11459            fn view(&self, _frame: &mut Frame) {}
11460        }
11461
11462        let mut program =
11463            headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
11464        program.dirty = false;
11465
11466        program
11467            .apply_resize(0, 0, Duration::ZERO, false)
11468            .expect("resize");
11469
11470        assert_eq!(program.width, 1);
11471        assert_eq!(program.height, 1);
11472        assert_eq!(program.model().last_size, Some((1, 1)));
11473        assert!(program.dirty);
11474    }
11475
11476    #[test]
11477    fn headless_apply_resize_reconciles_subscriptions() {
11478        use crate::subscription::{StopSignal, SubId, Subscription};
11479
11480        struct ResizeSubModel {
11481            subscribed: bool,
11482        }
11483
11484        #[derive(Debug)]
11485        enum ResizeSubMsg {
11486            Resize,
11487            Other,
11488        }
11489
11490        impl From<Event> for ResizeSubMsg {
11491            fn from(event: Event) -> Self {
11492                match event {
11493                    Event::Resize { .. } => Self::Resize,
11494                    _ => Self::Other,
11495                }
11496            }
11497        }
11498
11499        impl Model for ResizeSubModel {
11500            type Message = ResizeSubMsg;
11501
11502            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11503                if matches!(msg, ResizeSubMsg::Resize) {
11504                    self.subscribed = true;
11505                }
11506                Cmd::none()
11507            }
11508
11509            fn view(&self, _frame: &mut Frame) {}
11510
11511            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11512                if self.subscribed {
11513                    vec![Box::new(ResizeSubscription)]
11514                } else {
11515                    vec![]
11516                }
11517            }
11518        }
11519
11520        struct ResizeSubscription;
11521
11522        impl Subscription<ResizeSubMsg> for ResizeSubscription {
11523            fn id(&self) -> SubId {
11524                1
11525            }
11526
11527            fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
11528        }
11529
11530        let mut program = headless_program_with_config(
11531            ResizeSubModel { subscribed: false },
11532            ProgramConfig::default(),
11533        );
11534
11535        assert_eq!(program.subscriptions.active_count(), 0);
11536        program
11537            .apply_resize(120, 40, Duration::ZERO, false)
11538            .expect("resize");
11539
11540        assert!(program.model().subscribed);
11541        assert_eq!(program.subscriptions.active_count(), 1);
11542    }
11543
11544    #[test]
11545    fn headless_execute_cmd_log_writes_output() {
11546        let mut program =
11547            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11548        program.execute_cmd(Cmd::log("hello world")).expect("log");
11549
11550        let bytes = program.writer.into_inner().expect("writer output");
11551        let output = String::from_utf8_lossy(&bytes);
11552        assert!(output.contains("hello world"));
11553    }
11554
11555    #[test]
11556    fn headless_process_task_results_updates_model() {
11557        struct TaskModel {
11558            updates: usize,
11559        }
11560
11561        #[derive(Debug)]
11562        enum TaskMsg {
11563            Done,
11564        }
11565
11566        impl From<Event> for TaskMsg {
11567            fn from(_: Event) -> Self {
11568                TaskMsg::Done
11569            }
11570        }
11571
11572        impl Model for TaskModel {
11573            type Message = TaskMsg;
11574
11575            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11576                self.updates += 1;
11577                Cmd::none()
11578            }
11579
11580            fn view(&self, _frame: &mut Frame) {}
11581        }
11582
11583        let mut program =
11584            headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
11585        program.dirty = false;
11586        program.task_sender.send(TaskMsg::Done).unwrap();
11587
11588        program
11589            .process_task_results()
11590            .expect("process task results");
11591        assert_eq!(program.model().updates, 1);
11592        assert!(program.dirty);
11593    }
11594
11595    #[test]
11596    fn run_invokes_on_shutdown_after_quit() {
11597        use std::sync::{
11598            Arc,
11599            atomic::{AtomicUsize, Ordering},
11600        };
11601
11602        struct ShutdownModel {
11603            shutdowns: Arc<AtomicUsize>,
11604        }
11605
11606        #[derive(Debug, Clone, Copy)]
11607        enum ShutdownMsg {
11608            Quit,
11609            ShutdownRan,
11610        }
11611
11612        impl From<Event> for ShutdownMsg {
11613            fn from(_: Event) -> Self {
11614                ShutdownMsg::Quit
11615            }
11616        }
11617
11618        impl Model for ShutdownModel {
11619            type Message = ShutdownMsg;
11620
11621            fn init(&mut self) -> Cmd<Self::Message> {
11622                Cmd::quit()
11623            }
11624
11625            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11626                match msg {
11627                    ShutdownMsg::Quit => Cmd::quit(),
11628                    ShutdownMsg::ShutdownRan => {
11629                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11630                        Cmd::none()
11631                    }
11632                }
11633            }
11634
11635            fn view(&self, _frame: &mut Frame) {}
11636
11637            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11638                Cmd::msg(ShutdownMsg::ShutdownRan)
11639            }
11640        }
11641
11642        let shutdowns = Arc::new(AtomicUsize::new(0));
11643        let mut program = headless_program_with_config(
11644            ShutdownModel {
11645                shutdowns: Arc::clone(&shutdowns),
11646            },
11647            ProgramConfig::default(),
11648        );
11649
11650        program.run().expect("program run");
11651
11652        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11653    }
11654
11655    #[test]
11656    fn run_processes_shutdown_task_results_before_exit() {
11657        use std::sync::{
11658            Arc,
11659            atomic::{AtomicUsize, Ordering},
11660        };
11661
11662        struct ShutdownTaskModel {
11663            shutdowns: Arc<AtomicUsize>,
11664        }
11665
11666        #[derive(Debug, Clone, Copy)]
11667        enum ShutdownTaskMsg {
11668            Quit,
11669            ShutdownRan,
11670        }
11671
11672        impl From<Event> for ShutdownTaskMsg {
11673            fn from(_: Event) -> Self {
11674                ShutdownTaskMsg::Quit
11675            }
11676        }
11677
11678        impl Model for ShutdownTaskModel {
11679            type Message = ShutdownTaskMsg;
11680
11681            fn init(&mut self) -> Cmd<Self::Message> {
11682                Cmd::quit()
11683            }
11684
11685            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11686                match msg {
11687                    ShutdownTaskMsg::Quit => Cmd::quit(),
11688                    ShutdownTaskMsg::ShutdownRan => {
11689                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11690                        Cmd::none()
11691                    }
11692                }
11693            }
11694
11695            fn view(&self, _frame: &mut Frame) {}
11696
11697            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11698                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11699            }
11700        }
11701
11702        let shutdowns = Arc::new(AtomicUsize::new(0));
11703        let mut program = headless_program_with_config(
11704            ShutdownTaskModel {
11705                shutdowns: Arc::clone(&shutdowns),
11706            },
11707            ProgramConfig::default(),
11708        );
11709
11710        program.run().expect("program run");
11711
11712        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11713    }
11714
11715    #[test]
11716    fn run_processes_shutdown_task_results_with_effect_queue_backend() {
11717        use std::sync::{
11718            Arc,
11719            atomic::{AtomicUsize, Ordering},
11720        };
11721
11722        struct ShutdownTaskModel {
11723            shutdowns: Arc<AtomicUsize>,
11724        }
11725
11726        #[derive(Debug, Clone, Copy)]
11727        enum ShutdownTaskMsg {
11728            Quit,
11729            ShutdownRan,
11730        }
11731
11732        impl From<Event> for ShutdownTaskMsg {
11733            fn from(_: Event) -> Self {
11734                ShutdownTaskMsg::Quit
11735            }
11736        }
11737
11738        impl Model for ShutdownTaskModel {
11739            type Message = ShutdownTaskMsg;
11740
11741            fn init(&mut self) -> Cmd<Self::Message> {
11742                Cmd::quit()
11743            }
11744
11745            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11746                match msg {
11747                    ShutdownTaskMsg::Quit => Cmd::quit(),
11748                    ShutdownTaskMsg::ShutdownRan => {
11749                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11750                        Cmd::none()
11751                    }
11752                }
11753            }
11754
11755            fn view(&self, _frame: &mut Frame) {}
11756
11757            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11758                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11759            }
11760        }
11761
11762        let shutdowns = Arc::new(AtomicUsize::new(0));
11763        let mut program = headless_program_with_config(
11764            ShutdownTaskModel {
11765                shutdowns: Arc::clone(&shutdowns),
11766            },
11767            ProgramConfig::default().with_effect_queue(
11768                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
11769            ),
11770        );
11771
11772        program.run().expect("program run");
11773
11774        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11775    }
11776
11777    #[test]
11778    fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
11779        use std::sync::{
11780            Arc,
11781            atomic::{AtomicUsize, Ordering},
11782        };
11783
11784        struct ShutdownTaskModel {
11785            shutdowns: Arc<AtomicUsize>,
11786            follow_up_runs: Arc<AtomicUsize>,
11787        }
11788
11789        #[derive(Debug, Clone, Copy)]
11790        enum ShutdownTaskMsg {
11791            Quit,
11792            ShutdownRan,
11793            FollowUp,
11794        }
11795
11796        impl From<Event> for ShutdownTaskMsg {
11797            fn from(_: Event) -> Self {
11798                ShutdownTaskMsg::Quit
11799            }
11800        }
11801
11802        impl Model for ShutdownTaskModel {
11803            type Message = ShutdownTaskMsg;
11804
11805            fn init(&mut self) -> Cmd<Self::Message> {
11806                Cmd::quit()
11807            }
11808
11809            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11810                match msg {
11811                    ShutdownTaskMsg::Quit => Cmd::quit(),
11812                    ShutdownTaskMsg::ShutdownRan => {
11813                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11814                        let follow_up_runs = Arc::clone(&self.follow_up_runs);
11815                        Cmd::task(move || {
11816                            follow_up_runs.fetch_add(1, Ordering::SeqCst);
11817                            ShutdownTaskMsg::FollowUp
11818                        })
11819                    }
11820                    ShutdownTaskMsg::FollowUp => {
11821                        self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
11822                        Cmd::none()
11823                    }
11824                }
11825            }
11826
11827            fn view(&self, _frame: &mut Frame) {}
11828
11829            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11830                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11831            }
11832        }
11833
11834        let shutdowns = Arc::new(AtomicUsize::new(0));
11835        let follow_up_runs = Arc::new(AtomicUsize::new(0));
11836        let mut program = headless_program_with_config(
11837            ShutdownTaskModel {
11838                shutdowns: Arc::clone(&shutdowns),
11839                follow_up_runs: Arc::clone(&follow_up_runs),
11840            },
11841            ProgramConfig::default(),
11842        );
11843
11844        program.run().expect("program run");
11845
11846        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11847        assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
11848    }
11849
11850    #[test]
11851    fn run_quit_from_init_skips_initial_render_and_subscription_start() {
11852        use crate::subscription::{StopSignal, SubId, Subscription};
11853
11854        struct InitQuitModel {
11855            render_calls: Arc<AtomicUsize>,
11856            subscription_starts: Arc<AtomicUsize>,
11857        }
11858
11859        #[derive(Debug, Clone, Copy)]
11860        enum InitQuitMsg {
11861            Noop,
11862        }
11863
11864        impl From<Event> for InitQuitMsg {
11865            fn from(_: Event) -> Self {
11866                Self::Noop
11867            }
11868        }
11869
11870        impl Model for InitQuitModel {
11871            type Message = InitQuitMsg;
11872
11873            fn init(&mut self) -> Cmd<Self::Message> {
11874                Cmd::quit()
11875            }
11876
11877            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
11878                Cmd::none()
11879            }
11880
11881            fn view(&self, _frame: &mut Frame) {
11882                self.render_calls.fetch_add(1, Ordering::SeqCst);
11883            }
11884
11885            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11886                vec![Box::new(InitQuitSubscription {
11887                    starts: Arc::clone(&self.subscription_starts),
11888                })]
11889            }
11890        }
11891
11892        struct InitQuitSubscription {
11893            starts: Arc<AtomicUsize>,
11894        }
11895
11896        impl Subscription<InitQuitMsg> for InitQuitSubscription {
11897            fn id(&self) -> SubId {
11898                1
11899            }
11900
11901            fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
11902                self.starts.fetch_add(1, Ordering::SeqCst);
11903                let _ = stop.wait_timeout(Duration::from_millis(10));
11904            }
11905        }
11906
11907        let render_calls = Arc::new(AtomicUsize::new(0));
11908        let subscription_starts = Arc::new(AtomicUsize::new(0));
11909        let mut program = headless_program_with_config(
11910            InitQuitModel {
11911                render_calls: Arc::clone(&render_calls),
11912                subscription_starts: Arc::clone(&subscription_starts),
11913            },
11914            ProgramConfig::default(),
11915        );
11916
11917        program.run().expect("program run");
11918
11919        assert_eq!(render_calls.load(Ordering::SeqCst), 0);
11920        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11921    }
11922
11923    #[test]
11924    fn run_invokes_on_shutdown_before_returning_signal_error() {
11925        use std::sync::{
11926            Arc,
11927            atomic::{AtomicUsize, Ordering},
11928        };
11929
11930        struct ShutdownModel {
11931            shutdowns: Arc<AtomicUsize>,
11932        }
11933
11934        #[derive(Debug, Clone, Copy)]
11935        enum ShutdownMsg {
11936            Noop,
11937            ShutdownRan,
11938        }
11939
11940        impl From<Event> for ShutdownMsg {
11941            fn from(_: Event) -> Self {
11942                ShutdownMsg::Noop
11943            }
11944        }
11945
11946        impl Model for ShutdownModel {
11947            type Message = ShutdownMsg;
11948
11949            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11950                match msg {
11951                    ShutdownMsg::Noop => Cmd::none(),
11952                    ShutdownMsg::ShutdownRan => {
11953                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11954                        Cmd::none()
11955                    }
11956                }
11957            }
11958
11959            fn view(&self, _frame: &mut Frame) {}
11960
11961            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11962                Cmd::msg(ShutdownMsg::ShutdownRan)
11963            }
11964        }
11965
11966        let shutdowns = Arc::new(AtomicUsize::new(0));
11967        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
11968            let mut program = headless_signal_program_with_config(
11969                ShutdownModel {
11970                    shutdowns: Arc::clone(&shutdowns),
11971                },
11972                ProgramConfig::default().with_signal_interception(true),
11973            );
11974
11975            ftui_core::shutdown_signal::record_pending_termination_signal(2);
11976            let err = program.run().expect_err("signal should stop runtime");
11977
11978            assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11979            assert_eq!(signal_termination_from_error(&err), Some(2));
11980            assert_eq!(check_termination_signal(), None);
11981        });
11982    }
11983
11984    #[test]
11985    fn run_pending_signal_skips_initial_render_and_subscription_start() {
11986        use crate::subscription::{StopSignal, SubId, Subscription};
11987
11988        struct SignalStopModel {
11989            render_calls: Arc<AtomicUsize>,
11990            subscription_starts: Arc<AtomicUsize>,
11991        }
11992
11993        #[derive(Debug, Clone, Copy)]
11994        enum SignalStopMsg {
11995            Noop,
11996        }
11997
11998        impl From<Event> for SignalStopMsg {
11999            fn from(_: Event) -> Self {
12000                Self::Noop
12001            }
12002        }
12003
12004        impl Model for SignalStopModel {
12005            type Message = SignalStopMsg;
12006
12007            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
12008                Cmd::none()
12009            }
12010
12011            fn view(&self, _frame: &mut Frame) {
12012                self.render_calls.fetch_add(1, Ordering::SeqCst);
12013            }
12014
12015            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12016                vec![Box::new(SignalStopSubscription {
12017                    starts: Arc::clone(&self.subscription_starts),
12018                })]
12019            }
12020        }
12021
12022        struct SignalStopSubscription {
12023            starts: Arc<AtomicUsize>,
12024        }
12025
12026        impl Subscription<SignalStopMsg> for SignalStopSubscription {
12027            fn id(&self) -> SubId {
12028                11
12029            }
12030
12031            fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
12032                self.starts.fetch_add(1, Ordering::SeqCst);
12033                let _ = stop.wait_timeout(Duration::from_millis(10));
12034            }
12035        }
12036
12037        let render_calls = Arc::new(AtomicUsize::new(0));
12038        let subscription_starts = Arc::new(AtomicUsize::new(0));
12039        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
12040            let mut program = headless_signal_program_with_config(
12041                SignalStopModel {
12042                    render_calls: Arc::clone(&render_calls),
12043                    subscription_starts: Arc::clone(&subscription_starts),
12044                },
12045                ProgramConfig::default().with_signal_interception(true),
12046            );
12047
12048            ftui_core::shutdown_signal::record_pending_termination_signal(15);
12049            let err = program.run().expect_err("signal should stop runtime");
12050
12051            assert_eq!(signal_termination_from_error(&err), Some(15));
12052            assert_eq!(render_calls.load(Ordering::SeqCst), 0);
12053            assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
12054            assert_eq!(check_termination_signal(), None);
12055        });
12056    }
12057
12058    #[test]
12059    fn headless_should_tick_and_timeout_behaviors() {
12060        let mut program =
12061            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12062        program.tick_rate = Some(Duration::from_millis(5));
12063        program.last_tick = Instant::now() - Duration::from_millis(10);
12064
12065        assert!(program.should_tick());
12066        assert!(!program.should_tick());
12067
12068        let timeout = program.effective_timeout();
12069        assert!(timeout <= Duration::from_millis(5));
12070
12071        program.tick_rate = None;
12072        program.poll_timeout = Duration::from_millis(33);
12073        assert_eq!(program.effective_timeout(), Duration::from_millis(33));
12074    }
12075
12076    #[test]
12077    fn headless_effective_timeout_respects_resize_coalescer() {
12078        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12079        config.resize_coalescer.steady_delay_ms = 0;
12080        config.resize_coalescer.burst_delay_ms = 0;
12081
12082        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12083        program.tick_rate = Some(Duration::from_millis(50));
12084
12085        program.resize_coalescer.handle_resize(120, 40);
12086        assert!(program.resize_coalescer.has_pending());
12087
12088        let timeout = program.effective_timeout();
12089        assert_eq!(timeout, Duration::ZERO);
12090    }
12091
12092    #[test]
12093    fn headless_ui_height_remeasure_clears_auto_height() {
12094        let mut config = ProgramConfig::inline_auto(2, 6);
12095        config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
12096
12097        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12098        program.dirty = false;
12099        program.writer.set_auto_ui_height(5);
12100
12101        assert_eq!(program.writer.auto_ui_height(), Some(5));
12102        program.request_ui_height_remeasure();
12103
12104        assert_eq!(program.writer.auto_ui_height(), None);
12105        assert!(program.dirty);
12106    }
12107
12108    #[test]
12109    fn headless_recording_lifecycle_and_locale_change() {
12110        let mut program =
12111            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12112        program.dirty = false;
12113
12114        program.start_recording("demo");
12115        assert!(program.is_recording());
12116        let recorded = program.stop_recording();
12117        assert!(recorded.is_some());
12118        assert!(!program.is_recording());
12119
12120        let prev_dirty = program.dirty;
12121        program.locale_context.set_locale("fr");
12122        program.check_locale_change();
12123        assert!(program.dirty || prev_dirty);
12124    }
12125
12126    #[test]
12127    fn headless_render_frame_marks_clean_and_sets_diff() {
12128        struct RenderModel;
12129
12130        #[derive(Debug)]
12131        enum RenderMsg {
12132            Noop,
12133        }
12134
12135        impl From<Event> for RenderMsg {
12136            fn from(_: Event) -> Self {
12137                RenderMsg::Noop
12138            }
12139        }
12140
12141        impl Model for RenderModel {
12142            type Message = RenderMsg;
12143
12144            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12145                Cmd::none()
12146            }
12147
12148            fn view(&self, frame: &mut Frame) {
12149                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
12150            }
12151        }
12152
12153        let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
12154        program.render_frame().expect("render frame");
12155
12156        assert!(!program.dirty);
12157        assert!(program.writer.last_diff_strategy().is_some());
12158        assert_eq!(program.frame_idx, 1);
12159    }
12160
12161    /// CONTRACT (bd-1za0z F2): when a guardrail alert fires during
12162    /// render_frame, a `guardrail_snapshot` evidence row is exported through
12163    /// the configured sink — GuardrailSnapshot::to_jsonl is live production
12164    /// observability, not dead code.
12165    #[test]
12166    fn headless_render_frame_emits_guardrail_snapshot_evidence_on_alert() {
12167        struct AlertModel;
12168
12169        #[derive(Debug)]
12170        enum AlertMsg {
12171            Noop,
12172        }
12173
12174        impl From<Event> for AlertMsg {
12175            fn from(_: Event) -> Self {
12176                AlertMsg::Noop
12177            }
12178        }
12179
12180        impl Model for AlertModel {
12181            type Message = AlertMsg;
12182
12183            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12184                Cmd::none()
12185            }
12186
12187            fn view(&self, frame: &mut Frame) {
12188                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
12189            }
12190        }
12191
12192        let evidence_path = temp_evidence_path("guardrail_snapshot");
12193        let config = ProgramConfig {
12194            guardrails: GuardrailsConfig {
12195                // Soft limit of one byte: the very first frame alerts, so
12196                // no memory pressure needs to be manufactured.
12197                memory: MemoryBudgetConfig {
12198                    soft_limit_bytes: 1,
12199                    ..MemoryBudgetConfig::default()
12200                },
12201                queue: QueueConfig::default(),
12202            },
12203            evidence_sink: EvidenceSinkConfig::enabled_file(&evidence_path),
12204            ..Default::default()
12205        };
12206
12207        let mut program = headless_program_with_config(AlertModel, config);
12208        program.dirty = true;
12209        program.render_frame().expect("render frame with alert");
12210
12211        let contents =
12212            std::fs::read_to_string(&evidence_path).expect("guardrail evidence file written");
12213        let _ = std::fs::remove_file(&evidence_path);
12214        assert!(
12215            contents.contains(r#""event":"guardrail_snapshot""#),
12216            "alerting frame must export a guardrail_snapshot row; got: {contents}"
12217        );
12218        assert!(
12219            contents.contains(r#""mem_soft_violations":"#),
12220            "snapshot row must carry the violation counters"
12221        );
12222    }
12223
12224    /// CONTRACT (bd-1za0z): a soft memory alert must trigger a capacity trim
12225    /// (arena rebuild + pool gc) so the degradation actuator can actually
12226    /// move the sensor — retained capacity alone must not pin alerts forever.
12227    #[test]
12228    fn headless_render_frame_soft_alert_trims_retained_capacity() {
12229        struct AlertModel;
12230
12231        #[derive(Debug)]
12232        enum AlertMsg {
12233            Noop,
12234        }
12235
12236        impl From<Event> for AlertMsg {
12237            fn from(_: Event) -> Self {
12238                AlertMsg::Noop
12239            }
12240        }
12241
12242        impl Model for AlertModel {
12243            type Message = AlertMsg;
12244
12245            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12246                Cmd::none()
12247            }
12248
12249            fn view(&self, _frame: &mut Frame) {}
12250        }
12251
12252        let config = ProgramConfig {
12253            guardrails: GuardrailsConfig {
12254                memory: MemoryBudgetConfig {
12255                    soft_limit_bytes: 4096,
12256                    ..MemoryBudgetConfig::default()
12257                },
12258                queue: QueueConfig::default(),
12259            },
12260            ..Default::default()
12261        };
12262
12263        let mut program = headless_program_with_config(AlertModel, config.clone());
12264
12265        // Control: same config, same render, no injected capacity — its
12266        // post-frame arena size is the floor a successful trim must return
12267        // to (the render itself allocates a deterministic working set).
12268        let mut control = headless_program_with_config(AlertModel, config);
12269        control.dirty = true;
12270        control.render_frame().expect("control render");
12271        let control_bytes = control.frame_arena.allocated_bytes();
12272
12273        // Simulate retained capacity well above the soft limit: 256 KiB of
12274        // arena allocations that a reset-less sensor can never shed.
12275        let filler = vec![0u8; 256 * 1024];
12276        program.frame_arena.alloc_slice(&filler);
12277        assert!(program.frame_arena.allocated_bytes() >= 256 * 1024);
12278
12279        program.dirty = true;
12280        program
12281            .render_frame()
12282            .expect("render frame with soft alert");
12283        assert_eq!(
12284            program.last_soft_trim_frame,
12285            Some(program.frame_idx),
12286            "soft alert must trigger the capacity trim"
12287        );
12288
12289        // The trim released the injected chunk: the arena is back at the
12290        // control's size instead of carrying the extra 256 KiB.
12291        let after = program.frame_arena.allocated_bytes();
12292        assert!(
12293            after <= control_bytes + 1024,
12294            "soft alert must trim retained capacity: after={after}, control={control_bytes}"
12295        );
12296    }
12297
12298    #[test]
12299    fn headless_render_frame_skips_when_budget_exhausted() {
12300        let config = ProgramConfig {
12301            budget: FrameBudgetConfig::with_total(Duration::ZERO),
12302            ..Default::default()
12303        };
12304
12305        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12306        program.dirty = true;
12307        program.render_frame().expect("render frame");
12308
12309        // Dirty state is preserved when frame is skipped — the UI update
12310        // was never presented and must be retried.
12311        assert!(program.dirty);
12312        assert_eq!(program.frame_idx, 1);
12313    }
12314
12315    #[test]
12316    fn headless_render_frame_emits_budget_evidence_with_controller() {
12317        use ftui_render::budget::BudgetControllerConfig;
12318
12319        struct RenderModel;
12320
12321        #[derive(Debug)]
12322        enum RenderMsg {
12323            Noop,
12324        }
12325
12326        impl From<Event> for RenderMsg {
12327            fn from(_: Event) -> Self {
12328                RenderMsg::Noop
12329            }
12330        }
12331
12332        impl Model for RenderModel {
12333            type Message = RenderMsg;
12334
12335            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12336                Cmd::none()
12337            }
12338
12339            fn view(&self, frame: &mut Frame) {
12340                frame.buffer.set_raw(0, 0, Cell::from_char('E'));
12341            }
12342        }
12343
12344        let config =
12345            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
12346        let mut program = headless_program_with_config(RenderModel, config);
12347        program.budget = program
12348            .budget
12349            .with_controller(BudgetControllerConfig::default());
12350
12351        program.render_frame().expect("render frame");
12352        assert!(program.budget.telemetry().is_some());
12353        assert_eq!(program.frame_idx, 1);
12354    }
12355
12356    #[test]
12357    fn headless_handle_event_updates_model() {
12358        struct EventModel {
12359            events: usize,
12360            last_resize: Option<(u16, u16)>,
12361        }
12362
12363        #[derive(Debug)]
12364        enum EventMsg {
12365            Resize(u16, u16),
12366            Other,
12367        }
12368
12369        impl From<Event> for EventMsg {
12370            fn from(event: Event) -> Self {
12371                match event {
12372                    Event::Resize { width, height } => EventMsg::Resize(width, height),
12373                    _ => EventMsg::Other,
12374                }
12375            }
12376        }
12377
12378        impl Model for EventModel {
12379            type Message = EventMsg;
12380
12381            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12382                self.events += 1;
12383                if let EventMsg::Resize(w, h) = msg {
12384                    self.last_resize = Some((w, h));
12385                }
12386                Cmd::none()
12387            }
12388
12389            fn view(&self, _frame: &mut Frame) {}
12390        }
12391
12392        let mut program = headless_program_with_config(
12393            EventModel {
12394                events: 0,
12395                last_resize: None,
12396            },
12397            ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
12398        );
12399
12400        program
12401            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12402                ftui_core::event::KeyCode::Char('x'),
12403            )))
12404            .expect("handle key");
12405        assert_eq!(program.model().events, 1);
12406
12407        program
12408            .handle_event(Event::Resize {
12409                width: 10,
12410                height: 5,
12411            })
12412            .expect("handle resize");
12413        assert_eq!(program.model().events, 2);
12414        assert_eq!(program.model().last_resize, Some((10, 5)));
12415        assert_eq!(program.width, 10);
12416        assert_eq!(program.height, 5);
12417    }
12418
12419    #[test]
12420    fn headless_handle_event_quit_skips_subscription_reconcile() {
12421        use crate::subscription::{StopSignal, SubId, Subscription};
12422
12423        struct QuitSubModel {
12424            quitting: bool,
12425            subscription_starts: Arc<AtomicUsize>,
12426        }
12427
12428        #[derive(Debug)]
12429        enum QuitSubMsg {
12430            Quit,
12431            Other,
12432        }
12433
12434        impl From<Event> for QuitSubMsg {
12435            fn from(event: Event) -> Self {
12436                match event {
12437                    Event::Key(_) => Self::Quit,
12438                    _ => Self::Other,
12439                }
12440            }
12441        }
12442
12443        impl Model for QuitSubModel {
12444            type Message = QuitSubMsg;
12445
12446            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12447                match msg {
12448                    QuitSubMsg::Quit => {
12449                        self.quitting = true;
12450                        Cmd::quit()
12451                    }
12452                    QuitSubMsg::Other => Cmd::none(),
12453                }
12454            }
12455
12456            fn view(&self, _frame: &mut Frame) {}
12457
12458            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12459                if self.quitting {
12460                    vec![Box::new(QuitSubSubscription {
12461                        starts: Arc::clone(&self.subscription_starts),
12462                    })]
12463                } else {
12464                    vec![]
12465                }
12466            }
12467        }
12468
12469        struct QuitSubSubscription {
12470            starts: Arc<AtomicUsize>,
12471        }
12472
12473        impl Subscription<QuitSubMsg> for QuitSubSubscription {
12474            fn id(&self) -> SubId {
12475                7
12476            }
12477
12478            fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
12479                self.starts.fetch_add(1, Ordering::SeqCst);
12480                let _ = stop.wait_timeout(Duration::from_millis(10));
12481            }
12482        }
12483
12484        let subscription_starts = Arc::new(AtomicUsize::new(0));
12485        let mut program = headless_program_with_config(
12486            QuitSubModel {
12487                quitting: false,
12488                subscription_starts: Arc::clone(&subscription_starts),
12489            },
12490            ProgramConfig::default(),
12491        );
12492
12493        program
12494            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12495                ftui_core::event::KeyCode::Char('q'),
12496            )))
12497            .expect("handle event");
12498
12499        assert!(!program.is_running());
12500        assert_eq!(program.subscriptions.active_count(), 0);
12501        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
12502    }
12503
12504    #[test]
12505    fn headless_handle_resize_ignored_when_forced_size() {
12506        struct ResizeModel {
12507            resized: bool,
12508        }
12509
12510        #[derive(Debug)]
12511        enum ResizeMsg {
12512            Resize,
12513            Other,
12514        }
12515
12516        impl From<Event> for ResizeMsg {
12517            fn from(event: Event) -> Self {
12518                match event {
12519                    Event::Resize { .. } => ResizeMsg::Resize,
12520                    _ => ResizeMsg::Other,
12521                }
12522            }
12523        }
12524
12525        impl Model for ResizeModel {
12526            type Message = ResizeMsg;
12527
12528            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12529                if matches!(msg, ResizeMsg::Resize) {
12530                    self.resized = true;
12531                }
12532                Cmd::none()
12533            }
12534
12535            fn view(&self, _frame: &mut Frame) {}
12536        }
12537
12538        let config = ProgramConfig::default().with_forced_size(80, 24);
12539        let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
12540
12541        program
12542            .handle_event(Event::Resize {
12543                width: 120,
12544                height: 40,
12545            })
12546            .expect("handle resize");
12547
12548        assert_eq!(program.width, 80);
12549        assert_eq!(program.height, 24);
12550        assert!(!program.model().resized);
12551    }
12552
12553    #[test]
12554    fn headless_execute_cmd_batch_sequence_and_quit() {
12555        struct BatchModel {
12556            count: usize,
12557        }
12558
12559        #[derive(Debug)]
12560        enum BatchMsg {
12561            Inc,
12562        }
12563
12564        impl From<Event> for BatchMsg {
12565            fn from(_: Event) -> Self {
12566                BatchMsg::Inc
12567            }
12568        }
12569
12570        impl Model for BatchModel {
12571            type Message = BatchMsg;
12572
12573            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12574                match msg {
12575                    BatchMsg::Inc => {
12576                        self.count += 1;
12577                        Cmd::none()
12578                    }
12579                }
12580            }
12581
12582            fn view(&self, _frame: &mut Frame) {}
12583        }
12584
12585        let mut program =
12586            headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
12587
12588        program
12589            .execute_cmd(Cmd::Batch(vec![
12590                Cmd::msg(BatchMsg::Inc),
12591                Cmd::Sequence(vec![
12592                    Cmd::msg(BatchMsg::Inc),
12593                    Cmd::quit(),
12594                    Cmd::msg(BatchMsg::Inc),
12595                ]),
12596            ]))
12597            .expect("batch cmd");
12598
12599        assert_eq!(program.model().count, 2);
12600        assert!(!program.running);
12601    }
12602
12603    #[test]
12604    fn headless_process_subscription_messages_updates_model() {
12605        use crate::subscription::{StopSignal, SubId, Subscription};
12606
12607        struct SubModel {
12608            pings: usize,
12609            ready_tx: mpsc::Sender<()>,
12610        }
12611
12612        #[derive(Debug)]
12613        enum SubMsg {
12614            Ping,
12615            Other,
12616        }
12617
12618        impl From<Event> for SubMsg {
12619            fn from(_: Event) -> Self {
12620                SubMsg::Other
12621            }
12622        }
12623
12624        impl Model for SubModel {
12625            type Message = SubMsg;
12626
12627            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12628                if let SubMsg::Ping = msg {
12629                    self.pings += 1;
12630                }
12631                Cmd::none()
12632            }
12633
12634            fn view(&self, _frame: &mut Frame) {}
12635
12636            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12637                vec![Box::new(TestSubscription {
12638                    ready_tx: self.ready_tx.clone(),
12639                })]
12640            }
12641        }
12642
12643        struct TestSubscription {
12644            ready_tx: mpsc::Sender<()>,
12645        }
12646
12647        impl Subscription<SubMsg> for TestSubscription {
12648            fn id(&self) -> SubId {
12649                1
12650            }
12651
12652            fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
12653                let _ = sender.send(SubMsg::Ping);
12654                let _ = self.ready_tx.send(());
12655            }
12656        }
12657
12658        let (ready_tx, ready_rx) = mpsc::channel();
12659        let mut program =
12660            headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
12661
12662        program.reconcile_subscriptions();
12663        ready_rx
12664            .recv_timeout(Duration::from_millis(200))
12665            .expect("subscription started");
12666        program
12667            .process_subscription_messages()
12668            .expect("process subscriptions");
12669
12670        assert_eq!(program.model().pings, 1);
12671    }
12672
12673    #[test]
12674    fn headless_execute_cmd_task_spawns_and_reaps() {
12675        struct TaskModel {
12676            done: bool,
12677        }
12678
12679        #[derive(Debug)]
12680        enum TaskMsg {
12681            Done,
12682        }
12683
12684        impl From<Event> for TaskMsg {
12685            fn from(_: Event) -> Self {
12686                TaskMsg::Done
12687            }
12688        }
12689
12690        impl Model for TaskModel {
12691            type Message = TaskMsg;
12692
12693            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12694                match msg {
12695                    TaskMsg::Done => {
12696                        self.done = true;
12697                        Cmd::none()
12698                    }
12699                }
12700            }
12701
12702            fn view(&self, _frame: &mut Frame) {}
12703        }
12704
12705        let mut program =
12706            headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
12707        program
12708            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12709            .expect("task cmd");
12710
12711        let deadline = Instant::now() + Duration::from_millis(200);
12712        while !program.model().done && Instant::now() <= deadline {
12713            program
12714                .process_task_results()
12715                .expect("process task results");
12716            program.reap_finished_tasks();
12717        }
12718
12719        assert!(program.model().done, "task result did not arrive in time");
12720    }
12721
12722    #[test]
12723    fn headless_default_task_executor_is_spawned_for_structured_lane() {
12724        // Input-lag regression fix (#78): the default Structured lane must use
12725        // per-task `Spawned` execution, NOT the single-worker effect queue.
12726        // Routing every `Cmd::Task` through one serialized `effect_queue_loop`
12727        // worker added per-keystroke head-of-line latency for apps that forward
12728        // PTY output via per-pane polling tasks. Structured cancellation does
12729        // not require the effect queue, so the default backend is `Spawned`
12730        // ("spawned") here, matching v0.2.1 task concurrency.
12731        let program =
12732            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12733        assert_eq!(program.task_executor.kind_name(), "spawned");
12734    }
12735
12736    #[test]
12737    fn headless_structured_lane_task_executor_writes_spawned_backend_evidence() {
12738        // Input-lag regression fix (#78): the default Structured lane emits the
12739        // "spawned" backend in startup evidence (was "queued"), reflecting the
12740        // restored per-task-thread execution.
12741        let evidence_path = temp_evidence_path("task_executor_spawned_backend");
12742        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12743        let config = ProgramConfig::default().with_evidence_sink(sink_config);
12744        let _program = headless_program_with_config(TestModel { value: 0 }, config);
12745
12746        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12747        assert_eq!(backend_line["backend"], "spawned");
12748    }
12749
12750    #[test]
12751    fn headless_legacy_lane_task_executor_is_spawned() {
12752        let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
12753        let program = headless_program_with_config(TestModel { value: 0 }, config);
12754        assert_eq!(program.task_executor.kind_name(), "spawned");
12755    }
12756
12757    #[test]
12758    fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
12759        let config = ProgramConfig::default().with_effect_queue(
12760            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
12761        );
12762        let program = headless_program_with_config(TestModel { value: 0 }, config);
12763        assert_eq!(program.task_executor.kind_name(), "spawned");
12764    }
12765
12766    #[cfg(feature = "asupersync-executor")]
12767    #[test]
12768    fn headless_asupersync_task_executor_is_selected() {
12769        let config = ProgramConfig::default().with_effect_queue(
12770            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12771        );
12772        let program = headless_program_with_config(TestModel { value: 0 }, config);
12773        assert_eq!(program.task_executor.kind_name(), "asupersync");
12774    }
12775
12776    #[test]
12777    fn headless_persistence_commands_with_registry() {
12778        use crate::state_persistence::{MemoryStorage, StateRegistry};
12779        use std::sync::Arc;
12780
12781        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12782        let config = ProgramConfig::default().with_registry(registry.clone());
12783        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12784
12785        assert!(program.has_persistence());
12786        assert!(program.state_registry().is_some());
12787
12788        program.execute_cmd(Cmd::save_state()).expect("save");
12789        program.execute_cmd(Cmd::restore_state()).expect("restore");
12790
12791        let saved = program.trigger_save().expect("trigger save");
12792        let loaded = program.trigger_load().expect("trigger load");
12793        assert!(!saved);
12794        assert_eq!(loaded, 0);
12795    }
12796
12797    #[test]
12798    fn headless_process_resize_coalescer_applies_pending_resize() {
12799        struct ResizeModel {
12800            last_size: Option<(u16, u16)>,
12801        }
12802
12803        #[derive(Debug)]
12804        enum ResizeMsg {
12805            Resize(u16, u16),
12806            Other,
12807        }
12808
12809        impl From<Event> for ResizeMsg {
12810            fn from(event: Event) -> Self {
12811                match event {
12812                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12813                    _ => ResizeMsg::Other,
12814                }
12815            }
12816        }
12817
12818        impl Model for ResizeModel {
12819            type Message = ResizeMsg;
12820
12821            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12822                if let ResizeMsg::Resize(w, h) = msg {
12823                    self.last_size = Some((w, h));
12824                }
12825                Cmd::none()
12826            }
12827
12828            fn view(&self, _frame: &mut Frame) {}
12829        }
12830
12831        let evidence_path = temp_evidence_path("fairness_allow");
12832        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12833        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12834        config.resize_coalescer.steady_delay_ms = 0;
12835        config.resize_coalescer.burst_delay_ms = 0;
12836        config.resize_coalescer.hard_deadline_ms = 1_000;
12837        config.evidence_sink = sink_config.clone();
12838
12839        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12840        let sink = EvidenceSink::from_config(&sink_config)
12841            .expect("evidence sink config")
12842            .expect("evidence sink enabled");
12843        program.evidence_sink = Some(sink);
12844
12845        program.resize_coalescer.handle_resize(120, 40);
12846        assert!(program.resize_coalescer.has_pending());
12847
12848        program
12849            .process_resize_coalescer()
12850            .expect("process resize coalescer");
12851
12852        assert_eq!(program.width, 120);
12853        assert_eq!(program.height, 40);
12854        assert_eq!(program.model().last_size, Some((120, 40)));
12855
12856        let config_line = read_evidence_event(&evidence_path, "fairness_config");
12857        assert_eq!(config_line["event"], "fairness_config");
12858        assert!(config_line["enabled"].is_boolean());
12859        assert!(config_line["input_priority_threshold_ms"].is_number());
12860        assert!(config_line["dominance_threshold"].is_number());
12861        assert!(config_line["fairness_threshold"].is_number());
12862
12863        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12864        assert_eq!(decision_line["event"], "fairness_decision");
12865        assert_eq!(decision_line["decision"], "allow");
12866        assert_eq!(decision_line["reason"], "none");
12867        assert!(decision_line["pending_input_latency_ms"].is_null());
12868        assert!(decision_line["jain_index"].is_number());
12869        assert!(decision_line["resize_dominance_count"].is_number());
12870        assert!(decision_line["dominance_threshold"].is_number());
12871        assert!(decision_line["fairness_threshold"].is_number());
12872        assert!(decision_line["input_priority_threshold_ms"].is_number());
12873    }
12874
12875    #[test]
12876    fn headless_process_resize_coalescer_yields_to_input() {
12877        struct ResizeModel {
12878            last_size: Option<(u16, u16)>,
12879        }
12880
12881        #[derive(Debug)]
12882        enum ResizeMsg {
12883            Resize(u16, u16),
12884            Other,
12885        }
12886
12887        impl From<Event> for ResizeMsg {
12888            fn from(event: Event) -> Self {
12889                match event {
12890                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12891                    _ => ResizeMsg::Other,
12892                }
12893            }
12894        }
12895
12896        impl Model for ResizeModel {
12897            type Message = ResizeMsg;
12898
12899            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12900                if let ResizeMsg::Resize(w, h) = msg {
12901                    self.last_size = Some((w, h));
12902                }
12903                Cmd::none()
12904            }
12905
12906            fn view(&self, _frame: &mut Frame) {}
12907        }
12908
12909        let evidence_path = temp_evidence_path("fairness_yield");
12910        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12911        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12912        config.resize_coalescer.steady_delay_ms = 0;
12913        config.resize_coalescer.burst_delay_ms = 0;
12914        // Use a large hard deadline so elapsed wall-clock time between coalescer
12915        // construction and `handle_resize` never triggers an immediate apply.
12916        config.resize_coalescer.hard_deadline_ms = 10_000;
12917        config.evidence_sink = sink_config.clone();
12918
12919        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12920        let sink = EvidenceSink::from_config(&sink_config)
12921            .expect("evidence sink config")
12922            .expect("evidence sink enabled");
12923        program.evidence_sink = Some(sink);
12924
12925        program.fairness_guard = InputFairnessGuard::with_config(
12926            crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
12927        );
12928        program
12929            .fairness_guard
12930            .input_arrived(Instant::now() - Duration::from_millis(1));
12931
12932        program.resize_coalescer.handle_resize(120, 40);
12933        assert!(program.resize_coalescer.has_pending());
12934
12935        program
12936            .process_resize_coalescer()
12937            .expect("process resize coalescer");
12938
12939        assert_eq!(program.width, 80);
12940        assert_eq!(program.height, 24);
12941        assert_eq!(program.model().last_size, None);
12942        assert!(program.resize_coalescer.has_pending());
12943
12944        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12945        assert_eq!(decision_line["event"], "fairness_decision");
12946        assert_eq!(decision_line["decision"], "yield");
12947        assert_eq!(decision_line["reason"], "input_latency");
12948        assert!(decision_line["pending_input_latency_ms"].is_number());
12949        assert!(decision_line["jain_index"].is_number());
12950        assert!(decision_line["resize_dominance_count"].is_number());
12951        assert!(decision_line["dominance_threshold"].is_number());
12952        assert!(decision_line["fairness_threshold"].is_number());
12953        assert!(decision_line["input_priority_threshold_ms"].is_number());
12954    }
12955
12956    #[test]
12957    fn headless_execute_cmd_task_with_effect_queue() {
12958        struct TaskModel {
12959            done: bool,
12960        }
12961
12962        #[derive(Debug)]
12963        enum TaskMsg {
12964            Done,
12965        }
12966
12967        impl From<Event> for TaskMsg {
12968            fn from(_: Event) -> Self {
12969                TaskMsg::Done
12970            }
12971        }
12972
12973        impl Model for TaskModel {
12974            type Message = TaskMsg;
12975
12976            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12977                match msg {
12978                    TaskMsg::Done => {
12979                        self.done = true;
12980                        Cmd::none()
12981                    }
12982                }
12983            }
12984
12985            fn view(&self, _frame: &mut Frame) {}
12986        }
12987
12988        let effect_queue = EffectQueueConfig {
12989            enabled: true,
12990            backend: TaskExecutorBackend::EffectQueue,
12991            scheduler: SchedulerConfig {
12992                max_queue_size: 0,
12993                ..Default::default()
12994            },
12995            explicit_backend: true,
12996            ..Default::default()
12997        };
12998        let config = ProgramConfig::default().with_effect_queue(effect_queue);
12999        let mut program = headless_program_with_config(TaskModel { done: false }, config);
13000
13001        program
13002            .execute_cmd(Cmd::task(|| TaskMsg::Done))
13003            .expect("task cmd");
13004
13005        let deadline = Instant::now() + Duration::from_millis(200);
13006        while !program.model().done && Instant::now() <= deadline {
13007            program
13008                .process_task_results()
13009                .expect("process task results");
13010        }
13011
13012        assert!(
13013            program.model().done,
13014            "effect queue task result did not arrive in time"
13015        );
13016        assert_eq!(program.task_executor.kind_name(), "queued");
13017    }
13018
13019    #[test]
13020    fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
13021        struct TaskModel {
13022            done: bool,
13023        }
13024
13025        #[derive(Debug)]
13026        enum TaskMsg {
13027            Done,
13028        }
13029
13030        impl From<Event> for TaskMsg {
13031            fn from(_: Event) -> Self {
13032                TaskMsg::Done
13033            }
13034        }
13035
13036        impl Model for TaskModel {
13037            type Message = TaskMsg;
13038
13039            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13040                match msg {
13041                    TaskMsg::Done => {
13042                        self.done = true;
13043                        Cmd::none()
13044                    }
13045                }
13046            }
13047
13048            fn view(&self, _frame: &mut Frame) {}
13049        }
13050
13051        let evidence_path = temp_evidence_path("task_executor_spawned_complete");
13052        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13053        let config = ProgramConfig::default()
13054            .with_lane(RuntimeLane::Legacy)
13055            .with_evidence_sink(sink_config);
13056        let mut program = headless_program_with_config(TaskModel { done: false }, config);
13057
13058        program
13059            .execute_cmd(Cmd::task(|| TaskMsg::Done))
13060            .expect("task cmd");
13061
13062        let deadline = Instant::now() + Duration::from_millis(200);
13063        while !program.model().done && Instant::now() <= deadline {
13064            program
13065                .process_task_results()
13066                .expect("process task results");
13067            program.reap_finished_tasks();
13068        }
13069
13070        assert!(
13071            program.model().done,
13072            "spawned task result did not arrive in time"
13073        );
13074
13075        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
13076        assert_eq!(completion_line["backend"], "spawned");
13077        assert!(completion_line["duration_us"].is_number());
13078    }
13079
13080    #[test]
13081    fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
13082        struct TaskModel {
13083            done: bool,
13084        }
13085
13086        #[derive(Debug)]
13087        enum TaskMsg {
13088            Done,
13089        }
13090
13091        impl From<Event> for TaskMsg {
13092            fn from(_: Event) -> Self {
13093                TaskMsg::Done
13094            }
13095        }
13096
13097        impl Model for TaskModel {
13098            type Message = TaskMsg;
13099
13100            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13101                match msg {
13102                    TaskMsg::Done => {
13103                        self.done = true;
13104                        Cmd::none()
13105                    }
13106                }
13107            }
13108
13109            fn view(&self, _frame: &mut Frame) {}
13110        }
13111
13112        let evidence_path = temp_evidence_path("task_executor_queued_panic");
13113        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13114        let config = ProgramConfig::default()
13115            .with_evidence_sink(sink_config)
13116            .with_effect_queue(
13117                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
13118            );
13119        let mut program = headless_program_with_config(TaskModel { done: false }, config);
13120
13121        program
13122            .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
13123            .expect("panic task cmd");
13124        program
13125            .execute_cmd(Cmd::task(|| TaskMsg::Done))
13126            .expect("follow-up task cmd");
13127
13128        let deadline = Instant::now() + Duration::from_millis(500);
13129        while !program.model().done && Instant::now() <= deadline {
13130            program
13131                .process_task_results()
13132                .expect("process task results");
13133        }
13134
13135        assert!(
13136            program.model().done,
13137            "effect queue should continue after a panicking task"
13138        );
13139
13140        let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
13141        assert_eq!(panic_line["backend"], "queued");
13142        assert_eq!(panic_line["panic_msg"], "queued panic evidence");
13143    }
13144
13145    #[cfg(feature = "asupersync-executor")]
13146    #[test]
13147    fn headless_execute_cmd_task_with_asupersync_backend() {
13148        struct TaskModel {
13149            done: bool,
13150        }
13151
13152        #[derive(Debug)]
13153        enum TaskMsg {
13154            Done,
13155        }
13156
13157        impl From<Event> for TaskMsg {
13158            fn from(_: Event) -> Self {
13159                TaskMsg::Done
13160            }
13161        }
13162
13163        impl Model for TaskModel {
13164            type Message = TaskMsg;
13165
13166            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13167                match msg {
13168                    TaskMsg::Done => {
13169                        self.done = true;
13170                        Cmd::none()
13171                    }
13172                }
13173            }
13174
13175            fn view(&self, _frame: &mut Frame) {}
13176        }
13177
13178        let config = ProgramConfig::default().with_effect_queue(
13179            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13180        );
13181        let mut program = headless_program_with_config(TaskModel { done: false }, config);
13182
13183        program
13184            .execute_cmd(Cmd::task(|| TaskMsg::Done))
13185            .expect("task cmd");
13186
13187        let deadline = Instant::now() + Duration::from_millis(200);
13188        while !program.model().done && Instant::now() <= deadline {
13189            program
13190                .process_task_results()
13191                .expect("process task results");
13192            program.reap_finished_tasks();
13193        }
13194
13195        assert!(
13196            program.model().done,
13197            "asupersync task result did not arrive in time"
13198        );
13199        assert_eq!(program.task_executor.kind_name(), "asupersync");
13200    }
13201
13202    #[cfg(feature = "asupersync-executor")]
13203    #[test]
13204    fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
13205        struct TaskModel {
13206            done: bool,
13207        }
13208
13209        #[derive(Debug)]
13210        enum TaskMsg {
13211            Done,
13212        }
13213
13214        impl From<Event> for TaskMsg {
13215            fn from(_: Event) -> Self {
13216                TaskMsg::Done
13217            }
13218        }
13219
13220        impl Model for TaskModel {
13221            type Message = TaskMsg;
13222
13223            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13224                match msg {
13225                    TaskMsg::Done => {
13226                        self.done = true;
13227                        Cmd::none()
13228                    }
13229                }
13230            }
13231
13232            fn view(&self, _frame: &mut Frame) {}
13233        }
13234
13235        let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
13236        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13237        let config = ProgramConfig::default()
13238            .with_evidence_sink(sink_config)
13239            .with_effect_queue(
13240                EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13241            );
13242        let mut program = headless_program_with_config(TaskModel { done: false }, config);
13243
13244        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
13245        assert_eq!(backend_line["backend"], "asupersync");
13246
13247        program
13248            .execute_cmd(Cmd::task(|| TaskMsg::Done))
13249            .expect("task cmd");
13250
13251        let deadline = Instant::now() + Duration::from_millis(200);
13252        while !program.model().done && Instant::now() <= deadline {
13253            program
13254                .process_task_results()
13255                .expect("process task results");
13256            program.reap_finished_tasks();
13257        }
13258
13259        assert!(
13260            program.model().done,
13261            "asupersync task result did not arrive in time"
13262        );
13263
13264        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
13265        assert_eq!(completion_line["backend"], "asupersync");
13266        assert!(completion_line["duration_us"].is_number());
13267    }
13268
13269    // =========================================================================
13270    // Asupersync executor: semantic parity, failure, stress, shutdown, and
13271    // backpressure coverage (bd-392ka).
13272    // =========================================================================
13273
13274    /// Run `count` tasks (each emitting its index) through `backend` headlessly
13275    /// and return the sorted multiset of delivered message payloads.
13276    #[cfg(feature = "asupersync-executor")]
13277    fn collect_task_batch(backend: TaskExecutorBackend, count: u32) -> Vec<u32> {
13278        struct CollectModel {
13279            got: Vec<u32>,
13280        }
13281        #[derive(Debug)]
13282        enum CollectMsg {
13283            Got(u32),
13284        }
13285        impl From<Event> for CollectMsg {
13286            fn from(_: Event) -> Self {
13287                CollectMsg::Got(u32::MAX)
13288            }
13289        }
13290        impl Model for CollectModel {
13291            type Message = CollectMsg;
13292            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13293                match msg {
13294                    CollectMsg::Got(value) => self.got.push(value),
13295                }
13296                Cmd::none()
13297            }
13298            fn view(&self, _frame: &mut Frame) {}
13299        }
13300
13301        let config = ProgramConfig::default()
13302            .with_effect_queue(EffectQueueConfig::default().with_backend(backend));
13303        let mut program = headless_program_with_config(CollectModel { got: Vec::new() }, config);
13304        for index in 0..count {
13305            program
13306                .execute_cmd(Cmd::task(move || CollectMsg::Got(index)))
13307                .expect("task cmd");
13308        }
13309        let deadline = Instant::now() + Duration::from_secs(5);
13310        while program.model().got.len() < count as usize && Instant::now() <= deadline {
13311            program
13312                .process_task_results()
13313                .expect("process task results");
13314            program.reap_finished_tasks();
13315        }
13316        let mut got = program.model().got.clone();
13317        got.sort_unstable();
13318        got
13319    }
13320
13321    #[cfg(feature = "asupersync-executor")]
13322    #[test]
13323    fn asupersync_semantic_parity_with_spawned_backend() {
13324        // The same task batch must deliver an identical message multiset whether
13325        // run through the legacy Spawned backend or the Asupersync backend. This
13326        // is the evidence-backed semantic-parity check (a shadow-run in miniature).
13327        let expected: Vec<u32> = (0..16).collect();
13328        let spawned = collect_task_batch(TaskExecutorBackend::Spawned, 16);
13329        let asupersync = collect_task_batch(TaskExecutorBackend::Asupersync, 16);
13330        assert_eq!(
13331            spawned, expected,
13332            "spawned backend lost or reordered results"
13333        );
13334        assert_eq!(
13335            asupersync, expected,
13336            "asupersync backend diverged from the expected results"
13337        );
13338        assert_eq!(
13339            asupersync, spawned,
13340            "asupersync diverged from spawned (parity)"
13341        );
13342    }
13343
13344    #[cfg(feature = "asupersync-executor")]
13345    #[test]
13346    fn asupersync_stress_delivers_every_task() {
13347        // Under a large burst, every task result must arrive exactly once.
13348        let count: u32 = 200;
13349        let got = collect_task_batch(TaskExecutorBackend::Asupersync, count);
13350        assert_eq!(
13351            got.len(),
13352            count as usize,
13353            "asupersync dropped tasks under load"
13354        );
13355        assert_eq!(got, (0..count).collect::<Vec<_>>());
13356    }
13357
13358    #[cfg(feature = "asupersync-executor")]
13359    #[test]
13360    fn asupersync_task_panic_is_isolated() {
13361        struct PanicModel {
13362            survivor_seen: bool,
13363        }
13364        #[derive(Debug)]
13365        enum PanicMsg {
13366            Survivor,
13367        }
13368        impl From<Event> for PanicMsg {
13369            fn from(_: Event) -> Self {
13370                PanicMsg::Survivor
13371            }
13372        }
13373        impl Model for PanicModel {
13374            type Message = PanicMsg;
13375            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13376                match msg {
13377                    PanicMsg::Survivor => self.survivor_seen = true,
13378                }
13379                Cmd::none()
13380            }
13381            fn view(&self, _frame: &mut Frame) {}
13382        }
13383
13384        let evidence_path = temp_evidence_path("asupersync_panic");
13385        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13386        let config = ProgramConfig::default()
13387            .with_evidence_sink(sink_config)
13388            .with_effect_queue(
13389                EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13390            );
13391        let mut program = headless_program_with_config(
13392            PanicModel {
13393                survivor_seen: false,
13394            },
13395            config,
13396        );
13397
13398        // A panicking task must neither crash the executor nor deliver a message...
13399        program
13400            .execute_cmd(Cmd::task(|| -> PanicMsg { panic!("asupersync boom") }))
13401            .expect("panic task cmd");
13402        // ...and a task submitted afterwards must still complete normally.
13403        program
13404            .execute_cmd(Cmd::task(|| PanicMsg::Survivor))
13405            .expect("survivor task cmd");
13406
13407        let deadline = Instant::now() + Duration::from_secs(5);
13408        while !program.model().survivor_seen && Instant::now() <= deadline {
13409            program
13410                .process_task_results()
13411                .expect("process task results");
13412            program.reap_finished_tasks();
13413        }
13414        assert!(
13415            program.model().survivor_seen,
13416            "executor did not survive a panicking task"
13417        );
13418        let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
13419        assert_eq!(panic_line["backend"], "asupersync");
13420    }
13421
13422    #[cfg(feature = "asupersync-executor")]
13423    #[test]
13424    fn asupersync_rejects_tasks_after_shutdown() {
13425        struct ShutdownModel {
13426            seen: bool,
13427        }
13428        #[derive(Debug)]
13429        enum ShutdownMsg {
13430            Seen,
13431        }
13432        impl From<Event> for ShutdownMsg {
13433            fn from(_: Event) -> Self {
13434                ShutdownMsg::Seen
13435            }
13436        }
13437        impl Model for ShutdownModel {
13438            type Message = ShutdownMsg;
13439            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13440                match msg {
13441                    ShutdownMsg::Seen => self.seen = true,
13442                }
13443                Cmd::none()
13444            }
13445            fn view(&self, _frame: &mut Frame) {}
13446        }
13447
13448        let config = ProgramConfig::default().with_effect_queue(
13449            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13450        );
13451        let mut program = headless_program_with_config(ShutdownModel { seen: false }, config);
13452
13453        // After shutdown, new submissions are rejected (structured ownership):
13454        // the task never runs, so no message is delivered.
13455        program.task_executor.shutdown();
13456        program
13457            .execute_cmd(Cmd::task(|| ShutdownMsg::Seen))
13458            .expect("post-shutdown task cmd");
13459
13460        let deadline = Instant::now() + Duration::from_millis(200);
13461        while Instant::now() <= deadline {
13462            program
13463                .process_task_results()
13464                .expect("process task results");
13465            program.reap_finished_tasks();
13466        }
13467        assert!(
13468            !program.model().seen,
13469            "a task submitted after shutdown was executed"
13470        );
13471    }
13472
13473    #[test]
13474    fn spawned_executor_enforces_max_queue_depth_and_counts_drops() {
13475        let (result_tx, _result_rx) = mpsc::channel::<u32>();
13476        let (gate_tx, gate_rx) = mpsc::channel::<()>();
13477        let mut executor = SpawnTaskExecutor::new(result_tx, None, 1);
13478
13479        // First task occupies the single in-flight slot until gated.
13480        executor.submit(Box::new(move || {
13481            let _ = gate_rx.recv();
13482            1
13483        }));
13484        assert_eq!(executor.handles.len(), 1);
13485
13486        // Second submit exceeds the bound: shed + counted, no thread spawned.
13487        executor.submit(Box::new(|| 2));
13488        assert_eq!(executor.dropped, 1, "backpressure drop not counted");
13489        assert_eq!(executor.handles.len(), 1, "task spawned past the bound");
13490
13491        // Release the gate; post-shutdown submits are also counted drops.
13492        gate_tx.send(()).expect("release gate");
13493        executor.shutdown();
13494        executor.submit(Box::new(|| 3));
13495        assert_eq!(executor.dropped, 2, "post-shutdown drop not counted");
13496    }
13497
13498    #[cfg(feature = "asupersync-executor")]
13499    #[test]
13500    fn asupersync_backpressure_sheds_excess_in_flight_tasks() {
13501        struct SlowModel {
13502            done: u32,
13503        }
13504        #[derive(Debug)]
13505        enum SlowMsg {
13506            Done,
13507        }
13508        impl From<Event> for SlowMsg {
13509            fn from(_: Event) -> Self {
13510                SlowMsg::Done
13511            }
13512        }
13513        impl Model for SlowModel {
13514            type Message = SlowMsg;
13515            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13516                match msg {
13517                    SlowMsg::Done => self.done += 1,
13518                }
13519                Cmd::none()
13520            }
13521            fn view(&self, _frame: &mut Frame) {}
13522        }
13523
13524        let evidence_path = temp_evidence_path("asupersync_backpressure");
13525        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13526        let config = ProgramConfig::default()
13527            .with_evidence_sink(sink_config)
13528            .with_effect_queue(
13529                EffectQueueConfig::default()
13530                    .with_backend(TaskExecutorBackend::Asupersync)
13531                    .with_max_queue_depth(2),
13532            );
13533        let mut program = headless_program_with_config(SlowModel { done: 0 }, config);
13534
13535        // Submit more slow (still in-flight) tasks than the depth cap allows: the
13536        // first two occupy the in-flight slots, the rest are shed by backpressure.
13537        for _ in 0..6 {
13538            program
13539                .execute_cmd(Cmd::task(|| {
13540                    std::thread::sleep(Duration::from_millis(120));
13541                    SlowMsg::Done
13542                }))
13543                .expect("slow task cmd");
13544        }
13545
13546        // Backpressure must have fired with a recorded drop.
13547        let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
13548        assert_eq!(backpressure_line["backend"], "asupersync");
13549        assert_eq!(backpressure_line["action"], "drop");
13550
13551        // Drain the accepted tasks so teardown is prompt; no more than the depth
13552        // cap of tasks may complete.
13553        let deadline = Instant::now() + Duration::from_secs(5);
13554        while program.model().done < 2 && Instant::now() <= deadline {
13555            program
13556                .process_task_results()
13557                .expect("process task results");
13558            program.reap_finished_tasks();
13559        }
13560        assert!(
13561            program.model().done <= 2,
13562            "more tasks completed than the in-flight depth cap permits"
13563        );
13564    }
13565
13566    // =========================================================================
13567    // BatchController Tests (bd-4kq0.8.1)
13568    // =========================================================================
13569
13570    #[test]
13571    fn unit_tau_monotone() {
13572        // τ should decrease (or stay constant) as service time decreases,
13573        // since τ = E[S] × headroom.
13574        let mut bc = BatchController::new();
13575
13576        // High service time → high τ
13577        bc.observe_service(Duration::from_millis(20));
13578        bc.observe_service(Duration::from_millis(20));
13579        bc.observe_service(Duration::from_millis(20));
13580        let tau_high = bc.tau_s();
13581
13582        // Low service time → lower τ
13583        for _ in 0..20 {
13584            bc.observe_service(Duration::from_millis(1));
13585        }
13586        let tau_low = bc.tau_s();
13587
13588        assert!(
13589            tau_low <= tau_high,
13590            "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
13591        );
13592    }
13593
13594    #[test]
13595    fn unit_tau_monotone_lambda() {
13596        // As arrival rate λ decreases (longer inter-arrival times),
13597        // τ should not increase (it's based on service time, not λ).
13598        // But ρ should decrease.
13599        let mut bc = BatchController::new();
13600        let base = Instant::now();
13601
13602        // Fast arrivals (λ high)
13603        for i in 0..10 {
13604            bc.observe_arrival(base + Duration::from_millis(i * 10));
13605        }
13606        let rho_fast = bc.rho_est();
13607
13608        // Slow arrivals (λ low)
13609        for i in 10..20 {
13610            bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
13611        }
13612        let rho_slow = bc.rho_est();
13613
13614        assert!(
13615            rho_slow < rho_fast,
13616            "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
13617        );
13618    }
13619
13620    #[test]
13621    fn unit_stability() {
13622        // With reasonable service times, the controller should keep ρ < 1.
13623        let mut bc = BatchController::new();
13624        let base = Instant::now();
13625
13626        // Moderate arrival rate: 30 events/sec
13627        for i in 0..30 {
13628            bc.observe_arrival(base + Duration::from_millis(i * 33));
13629            bc.observe_service(Duration::from_millis(5)); // 5ms render
13630        }
13631
13632        assert!(
13633            bc.is_stable(),
13634            "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
13635            bc.rho_est()
13636        );
13637        assert!(
13638            bc.rho_est() < 1.0,
13639            "utilization should be < 1: ρ={:.4}",
13640            bc.rho_est()
13641        );
13642
13643        // τ must be > E[S] (stability requirement)
13644        assert!(
13645            bc.tau_s() > bc.service_est_s(),
13646            "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
13647            bc.tau_s(),
13648            bc.service_est_s()
13649        );
13650    }
13651
13652    #[test]
13653    fn unit_stability_high_load() {
13654        // Even under high load, τ keeps the system stable.
13655        let mut bc = BatchController::new();
13656        let base = Instant::now();
13657
13658        // 100 events/sec with 8ms render
13659        for i in 0..50 {
13660            bc.observe_arrival(base + Duration::from_millis(i * 10));
13661            bc.observe_service(Duration::from_millis(8));
13662        }
13663
13664        // τ × ρ_eff = E[S]/τ should be < 1
13665        let tau = bc.tau_s();
13666        let rho_eff = bc.service_est_s() / tau;
13667        assert!(
13668            rho_eff < 1.0,
13669            "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
13670            bc.service_est_s()
13671        );
13672    }
13673
13674    #[test]
13675    fn batch_controller_defaults() {
13676        let bc = BatchController::new();
13677        assert!(bc.tau_s() >= bc.tau_min_s);
13678        assert!(bc.tau_s() <= bc.tau_max_s);
13679        assert_eq!(bc.observations(), 0);
13680        assert!(bc.is_stable());
13681    }
13682
13683    #[test]
13684    fn batch_controller_tau_clamped() {
13685        let mut bc = BatchController::new();
13686
13687        // Very fast service → τ clamped to tau_min
13688        for _ in 0..20 {
13689            bc.observe_service(Duration::from_micros(10));
13690        }
13691        assert!(
13692            bc.tau_s() >= bc.tau_min_s,
13693            "τ should be >= tau_min: τ={:.6}, min={:.6}",
13694            bc.tau_s(),
13695            bc.tau_min_s
13696        );
13697
13698        // Very slow service → τ clamped to tau_max
13699        for _ in 0..20 {
13700            bc.observe_service(Duration::from_millis(100));
13701        }
13702        assert!(
13703            bc.tau_s() <= bc.tau_max_s,
13704            "τ should be <= tau_max: τ={:.6}, max={:.6}",
13705            bc.tau_s(),
13706            bc.tau_max_s
13707        );
13708    }
13709
13710    #[test]
13711    fn batch_controller_duration_conversion() {
13712        let bc = BatchController::new();
13713        let tau = bc.tau();
13714        let tau_s = bc.tau_s();
13715        // Duration should match f64 representation
13716        let diff = (tau.as_secs_f64() - tau_s).abs();
13717        assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
13718    }
13719
13720    #[test]
13721    fn batch_controller_lambda_estimation() {
13722        let mut bc = BatchController::new();
13723        let base = Instant::now();
13724
13725        // 50 events/sec (20ms apart)
13726        for i in 0..20 {
13727            bc.observe_arrival(base + Duration::from_millis(i * 20));
13728        }
13729
13730        // λ should converge near 50
13731        let lambda = bc.lambda_est();
13732        assert!(
13733            lambda > 20.0 && lambda < 100.0,
13734            "λ should be near 50: got {lambda:.1}"
13735        );
13736    }
13737
13738    // ─────────────────────────────────────────────────────────────────────────────
13739    // Persistence Config Tests
13740    // ─────────────────────────────────────────────────────────────────────────────
13741
13742    #[test]
13743    fn cmd_save_state() {
13744        let cmd: Cmd<TestMsg> = Cmd::save_state();
13745        assert!(matches!(cmd, Cmd::SaveState));
13746    }
13747
13748    #[test]
13749    fn cmd_restore_state() {
13750        let cmd: Cmd<TestMsg> = Cmd::restore_state();
13751        assert!(matches!(cmd, Cmd::RestoreState));
13752    }
13753
13754    #[test]
13755    fn persistence_config_default() {
13756        let config = PersistenceConfig::default();
13757        assert!(config.registry.is_none());
13758        assert!(config.checkpoint_interval.is_none());
13759        assert!(config.auto_load);
13760        assert!(config.auto_save);
13761    }
13762
13763    #[test]
13764    fn persistence_config_disabled() {
13765        let config = PersistenceConfig::disabled();
13766        assert!(config.registry.is_none());
13767    }
13768
13769    #[test]
13770    fn persistence_config_with_registry() {
13771        use crate::state_persistence::{MemoryStorage, StateRegistry};
13772        use std::sync::Arc;
13773
13774        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13775        let config = PersistenceConfig::with_registry(registry.clone());
13776
13777        assert!(config.registry.is_some());
13778        assert!(config.auto_load);
13779        assert!(config.auto_save);
13780    }
13781
13782    #[test]
13783    fn persistence_config_checkpoint_interval() {
13784        use crate::state_persistence::{MemoryStorage, StateRegistry};
13785        use std::sync::Arc;
13786
13787        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13788        let config = PersistenceConfig::with_registry(registry)
13789            .checkpoint_every(Duration::from_secs(30))
13790            .auto_load(false)
13791            .auto_save(true);
13792
13793        assert!(config.checkpoint_interval.is_some());
13794        assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
13795        assert!(!config.auto_load);
13796        assert!(config.auto_save);
13797    }
13798
13799    #[test]
13800    fn program_config_with_persistence() {
13801        use crate::state_persistence::{MemoryStorage, StateRegistry};
13802        use std::sync::Arc;
13803
13804        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13805        let config = ProgramConfig::default().with_registry(registry);
13806
13807        assert!(config.persistence.registry.is_some());
13808    }
13809
13810    // =========================================================================
13811    // TaskSpec tests (bd-2yjus)
13812    // =========================================================================
13813
13814    #[test]
13815    fn task_spec_default() {
13816        let spec = TaskSpec::default();
13817        assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
13818        assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13819        assert!(spec.name.is_none());
13820    }
13821
13822    #[test]
13823    fn task_spec_new() {
13824        let spec = TaskSpec::new(5.0, 20.0);
13825        assert_eq!(spec.weight, 5.0);
13826        assert_eq!(spec.estimate_ms, 20.0);
13827        assert!(spec.name.is_none());
13828    }
13829
13830    #[test]
13831    fn task_spec_with_name() {
13832        let spec = TaskSpec::default().with_name("fetch_data");
13833        assert_eq!(spec.name.as_deref(), Some("fetch_data"));
13834    }
13835
13836    #[test]
13837    fn task_spec_debug() {
13838        let spec = TaskSpec::new(2.0, 15.0).with_name("test");
13839        let debug = format!("{spec:?}");
13840        assert!(debug.contains("2.0"));
13841        assert!(debug.contains("15.0"));
13842        assert!(debug.contains("test"));
13843    }
13844
13845    // =========================================================================
13846    // Cmd::count() tests (bd-2yjus)
13847    // =========================================================================
13848
13849    #[test]
13850    fn cmd_count_none() {
13851        let cmd: Cmd<TestMsg> = Cmd::none();
13852        assert_eq!(cmd.count(), 0);
13853    }
13854
13855    #[test]
13856    fn cmd_count_atomic() {
13857        assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
13858        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
13859        assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
13860        assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
13861        assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
13862        assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
13863        assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
13864    }
13865
13866    #[test]
13867    fn cmd_count_batch() {
13868        let cmd: Cmd<TestMsg> =
13869            Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
13870        assert_eq!(cmd.count(), 2); // quit + msg, none counts 0
13871    }
13872
13873    #[test]
13874    fn cmd_count_nested() {
13875        let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
13876            Cmd::msg(TestMsg::Increment),
13877            Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
13878        ]);
13879        assert_eq!(cmd.count(), 3);
13880    }
13881
13882    // =========================================================================
13883    // Cmd::type_name() tests (bd-2yjus)
13884    // =========================================================================
13885
13886    #[test]
13887    fn cmd_type_name_all_variants() {
13888        assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
13889        assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
13890        assert_eq!(
13891            Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
13892            "Batch"
13893        );
13894        assert_eq!(
13895            Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
13896            "Sequence"
13897        );
13898        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
13899        assert_eq!(
13900            Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
13901            "Tick"
13902        );
13903        assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
13904        assert_eq!(
13905            Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
13906            "Task"
13907        );
13908        assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
13909        assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
13910        assert_eq!(
13911            Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
13912            "SetMouseCapture"
13913        );
13914    }
13915
13916    // =========================================================================
13917    // Cmd::batch() / Cmd::sequence() edge-case tests (bd-2yjus)
13918    // =========================================================================
13919
13920    #[test]
13921    fn cmd_batch_empty_returns_none() {
13922        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
13923        assert!(matches!(cmd, Cmd::None));
13924    }
13925
13926    #[test]
13927    fn cmd_batch_single_unwraps() {
13928        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
13929        assert!(matches!(cmd, Cmd::Quit));
13930    }
13931
13932    #[test]
13933    fn cmd_batch_multiple_stays_batch() {
13934        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13935        assert!(matches!(cmd, Cmd::Batch(_)));
13936    }
13937
13938    #[test]
13939    fn cmd_sequence_empty_returns_none() {
13940        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
13941        assert!(matches!(cmd, Cmd::None));
13942    }
13943
13944    #[test]
13945    fn cmd_sequence_single_unwraps_to_inner() {
13946        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
13947        assert!(matches!(cmd, Cmd::Quit));
13948    }
13949
13950    #[test]
13951    fn cmd_sequence_multiple_stays_sequence() {
13952        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13953        assert!(matches!(cmd, Cmd::Sequence(_)));
13954    }
13955
13956    // =========================================================================
13957    // Cmd task constructor variants (bd-2yjus)
13958    // =========================================================================
13959
13960    #[test]
13961    fn cmd_task_with_spec() {
13962        let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
13963        let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
13964        match cmd {
13965            Cmd::Task(s, _) => {
13966                assert_eq!(s.weight, 3.0);
13967                assert_eq!(s.estimate_ms, 25.0);
13968                assert_eq!(s.name.as_deref(), Some("my_task"));
13969            }
13970            _ => panic!("expected Task variant"),
13971        }
13972    }
13973
13974    #[test]
13975    fn cmd_task_weighted() {
13976        let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
13977        match cmd {
13978            Cmd::Task(s, _) => {
13979                assert_eq!(s.weight, 2.0);
13980                assert_eq!(s.estimate_ms, 50.0);
13981                assert!(s.name.is_none());
13982            }
13983            _ => panic!("expected Task variant"),
13984        }
13985    }
13986
13987    #[test]
13988    fn cmd_task_named() {
13989        let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
13990        match cmd {
13991            Cmd::Task(s, _) => {
13992                assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
13993                assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13994                assert_eq!(s.name.as_deref(), Some("background_fetch"));
13995            }
13996            _ => panic!("expected Task variant"),
13997        }
13998    }
13999
14000    // =========================================================================
14001    // Cmd Debug formatting (bd-2yjus)
14002    // =========================================================================
14003
14004    #[test]
14005    fn cmd_debug_all_variant_strings() {
14006        assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
14007        assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
14008        assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
14009        assert!(
14010            format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
14011        );
14012        assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
14013        assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
14014        assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
14015        assert_eq!(
14016            format!("{:?}", Cmd::<TestMsg>::restore_state()),
14017            "RestoreState"
14018        );
14019        assert_eq!(
14020            format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
14021            "SetMouseCapture(true)"
14022        );
14023    }
14024
14025    // =========================================================================
14026    // Cmd::set_mouse_capture headless execution (bd-2yjus)
14027    // =========================================================================
14028
14029    #[test]
14030    fn headless_execute_cmd_set_mouse_capture() {
14031        let mut program =
14032            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14033        assert!(!program.backend_features.mouse_capture);
14034
14035        program
14036            .execute_cmd(Cmd::set_mouse_capture(true))
14037            .expect("set mouse capture true");
14038        assert!(program.backend_features.mouse_capture);
14039
14040        program
14041            .execute_cmd(Cmd::set_mouse_capture(false))
14042            .expect("set mouse capture false");
14043        assert!(!program.backend_features.mouse_capture);
14044    }
14045
14046    // =========================================================================
14047    // ResizeBehavior tests (bd-2yjus)
14048    // =========================================================================
14049
14050    #[test]
14051    fn resize_behavior_uses_coalescer() {
14052        assert!(ResizeBehavior::Throttled.uses_coalescer());
14053        assert!(!ResizeBehavior::Immediate.uses_coalescer());
14054    }
14055
14056    #[test]
14057    fn resize_behavior_eq_and_debug() {
14058        assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
14059        assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
14060        let debug = format!("{:?}", ResizeBehavior::Throttled);
14061        assert_eq!(debug, "Throttled");
14062    }
14063
14064    // =========================================================================
14065    // WidgetRefreshConfig default values (bd-2yjus)
14066    // =========================================================================
14067
14068    #[test]
14069    fn widget_refresh_config_defaults() {
14070        let config = WidgetRefreshConfig::default();
14071        assert!(config.enabled);
14072        assert_eq!(config.staleness_window_ms, 1_000);
14073        assert_eq!(config.starve_ms, 3_000);
14074        assert_eq!(config.max_starved_per_frame, 2);
14075        assert_eq!(config.max_drop_fraction, 1.0);
14076        assert_eq!(config.weight_priority, 1.0);
14077        assert_eq!(config.weight_staleness, 0.5);
14078        assert_eq!(config.weight_focus, 0.75);
14079        assert_eq!(config.weight_interaction, 0.5);
14080        assert_eq!(config.starve_boost, 1.5);
14081        assert_eq!(config.min_cost_us, 1.0);
14082    }
14083
14084    // =========================================================================
14085    // EffectQueueConfig tests (bd-2yjus)
14086    // =========================================================================
14087
14088    #[test]
14089    fn effect_queue_config_default() {
14090        let config = EffectQueueConfig::default();
14091        assert!(!config.enabled);
14092        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
14093        assert!(!config.explicit_backend);
14094        assert!(config.scheduler.smith_enabled);
14095        assert!(!config.scheduler.force_fifo);
14096        assert!(!config.scheduler.preemptive);
14097    }
14098
14099    #[test]
14100    fn effect_queue_config_with_enabled() {
14101        let config = EffectQueueConfig::default().with_enabled(true);
14102        assert!(config.enabled);
14103        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
14104        assert!(config.explicit_backend);
14105    }
14106
14107    #[test]
14108    fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
14109        let config = EffectQueueConfig::default().with_enabled(false);
14110        assert!(!config.enabled);
14111        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
14112        assert!(config.explicit_backend);
14113    }
14114
14115    #[test]
14116    fn effect_queue_config_with_backend() {
14117        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
14118        assert!(config.enabled);
14119        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
14120        assert!(config.explicit_backend);
14121    }
14122
14123    #[cfg(feature = "asupersync-executor")]
14124    #[test]
14125    fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
14126        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
14127        assert!(!config.enabled);
14128        assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
14129    }
14130
14131    #[test]
14132    fn effect_queue_config_with_scheduler() {
14133        let sched = SchedulerConfig {
14134            force_fifo: true,
14135            ..Default::default()
14136        };
14137        let config = EffectQueueConfig::default().with_scheduler(sched);
14138        assert!(config.scheduler.force_fifo);
14139    }
14140
14141    // =========================================================================
14142    // InlineAutoRemeasureConfig defaults (bd-2yjus)
14143    // =========================================================================
14144
14145    #[test]
14146    fn inline_auto_remeasure_config_defaults() {
14147        let config = InlineAutoRemeasureConfig::default();
14148        assert_eq!(config.change_threshold_rows, 1);
14149        assert_eq!(config.voi.prior_alpha, 1.0);
14150        assert_eq!(config.voi.prior_beta, 9.0);
14151        assert_eq!(config.voi.max_interval_ms, 1000);
14152        assert_eq!(config.voi.min_interval_ms, 100);
14153        assert_eq!(config.voi.sample_cost, 0.08);
14154    }
14155
14156    // =========================================================================
14157    // HeadlessEventSource direct tests (bd-2yjus)
14158    // =========================================================================
14159
14160    #[test]
14161    fn headless_event_source_size() {
14162        let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
14163        assert_eq!(source.size().unwrap(), (120, 40));
14164    }
14165
14166    #[test]
14167    fn headless_event_source_poll_always_false() {
14168        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14169        assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
14170    }
14171
14172    #[test]
14173    fn headless_event_source_read_always_none() {
14174        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14175        assert!(source.read_event().unwrap().is_none());
14176    }
14177
14178    #[test]
14179    fn headless_event_source_set_features() {
14180        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14181        let features = BackendFeatures {
14182            mouse_capture: true,
14183            bracketed_paste: true,
14184            focus_events: true,
14185            kitty_keyboard: true,
14186        };
14187        source.set_features(features).unwrap();
14188        assert_eq!(source.features, features);
14189    }
14190
14191    #[test]
14192    fn immediate_drain_budget_adds_backoff_poll_under_burst() {
14193        use ftui_core::event::{KeyCode, KeyEvent};
14194
14195        struct DrainBurstModel {
14196            processed: usize,
14197            quit_after: usize,
14198        }
14199
14200        #[derive(Debug)]
14201        #[allow(dead_code)]
14202        enum DrainBurstMsg {
14203            Event(Event),
14204        }
14205
14206        impl From<Event> for DrainBurstMsg {
14207            fn from(event: Event) -> Self {
14208                DrainBurstMsg::Event(event)
14209            }
14210        }
14211
14212        impl Model for DrainBurstModel {
14213            type Message = DrainBurstMsg;
14214
14215            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14216                match msg {
14217                    DrainBurstMsg::Event(_) => {
14218                        self.processed = self.processed.saturating_add(1);
14219                        if self.processed >= self.quit_after {
14220                            Cmd::quit()
14221                        } else {
14222                            Cmd::none()
14223                        }
14224                    }
14225                }
14226            }
14227
14228            fn view(&self, _frame: &mut Frame) {}
14229        }
14230
14231        struct DrainBurstEventSource {
14232            queue: VecDeque<Event>,
14233            poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
14234            size: (u16, u16),
14235        }
14236
14237        impl BackendEventSource for DrainBurstEventSource {
14238            type Error = io::Error;
14239
14240            fn size(&self) -> Result<(u16, u16), Self::Error> {
14241                Ok(self.size)
14242            }
14243
14244            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14245                Ok(())
14246            }
14247
14248            fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
14249                self.poll_timeouts.lock().unwrap().push(timeout);
14250                Ok(!self.queue.is_empty())
14251            }
14252
14253            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14254                Ok(self.queue.pop_front())
14255            }
14256        }
14257
14258        let burst_events = 24usize;
14259        let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
14260        let mut queue = VecDeque::new();
14261        for _ in 0..burst_events {
14262            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
14263        }
14264
14265        let events = DrainBurstEventSource {
14266            queue,
14267            poll_timeouts: poll_timeouts.clone(),
14268            size: (80, 24),
14269        };
14270        let writer = TerminalWriter::new(
14271            Vec::<u8>::new(),
14272            ScreenMode::AltScreen,
14273            UiAnchor::Bottom,
14274            TerminalCapabilities::dumb(),
14275        );
14276        let config = ProgramConfig::default()
14277            .with_forced_size(80, 24)
14278            .with_signal_interception(false)
14279            .with_immediate_drain(ImmediateDrainConfig {
14280                max_zero_timeout_polls_per_burst: 3,
14281                max_burst_duration: Duration::from_secs(1),
14282                backoff_timeout: Duration::from_millis(1),
14283            });
14284
14285        let model = DrainBurstModel {
14286            processed: 0,
14287            quit_after: burst_events,
14288        };
14289        let mut program =
14290            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14291                .expect("program creation");
14292        program.run().expect("run burst");
14293
14294        assert_eq!(program.model().processed, burst_events);
14295
14296        let stats = program.immediate_drain_stats();
14297        assert_eq!(stats.bursts, 1);
14298        assert!(stats.capped_bursts >= 1);
14299        assert!(stats.backoff_polls >= 1);
14300        assert!(stats.zero_timeout_polls >= 1);
14301        assert!(stats.max_zero_timeout_polls_in_burst <= 3);
14302
14303        let timeouts = poll_timeouts.lock().unwrap();
14304        assert!(timeouts.contains(&Duration::ZERO));
14305        assert!(timeouts.contains(&Duration::from_millis(1)));
14306    }
14307
14308    #[test]
14309    fn immediate_drain_zero_poll_limit_is_clamped() {
14310        use ftui_core::event::{KeyCode, KeyEvent};
14311
14312        struct ClampModel {
14313            processed: usize,
14314            quit_after: usize,
14315        }
14316
14317        #[derive(Debug)]
14318        #[allow(dead_code)]
14319        enum ClampMsg {
14320            Event(Event),
14321        }
14322
14323        impl From<Event> for ClampMsg {
14324            fn from(event: Event) -> Self {
14325                ClampMsg::Event(event)
14326            }
14327        }
14328
14329        impl Model for ClampModel {
14330            type Message = ClampMsg;
14331
14332            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14333                match msg {
14334                    ClampMsg::Event(_) => {
14335                        self.processed = self.processed.saturating_add(1);
14336                        if self.processed >= self.quit_after {
14337                            Cmd::quit()
14338                        } else {
14339                            Cmd::none()
14340                        }
14341                    }
14342                }
14343            }
14344
14345            fn view(&self, _frame: &mut Frame) {}
14346        }
14347
14348        struct ClampSource {
14349            queue: VecDeque<Event>,
14350        }
14351
14352        impl BackendEventSource for ClampSource {
14353            type Error = io::Error;
14354
14355            fn size(&self) -> Result<(u16, u16), Self::Error> {
14356                Ok((80, 24))
14357            }
14358
14359            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14360                Ok(())
14361            }
14362
14363            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
14364                Ok(!self.queue.is_empty())
14365            }
14366
14367            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14368                Ok(self.queue.pop_front())
14369            }
14370        }
14371
14372        let burst_events = 8usize;
14373        let mut queue = VecDeque::new();
14374        for _ in 0..burst_events {
14375            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
14376        }
14377        let events = ClampSource { queue };
14378
14379        let writer = TerminalWriter::new(
14380            Vec::<u8>::new(),
14381            ScreenMode::AltScreen,
14382            UiAnchor::Bottom,
14383            TerminalCapabilities::dumb(),
14384        );
14385        let config = ProgramConfig::default()
14386            .with_forced_size(80, 24)
14387            .with_signal_interception(false)
14388            .with_immediate_drain(ImmediateDrainConfig {
14389                max_zero_timeout_polls_per_burst: 0,
14390                max_burst_duration: Duration::from_secs(1),
14391                backoff_timeout: Duration::from_millis(1),
14392            });
14393        let model = ClampModel {
14394            processed: 0,
14395            quit_after: burst_events,
14396        };
14397
14398        let mut program =
14399            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14400                .expect("program creation");
14401        program.run().expect("run clamp");
14402
14403        let stats = program.immediate_drain_stats();
14404        assert!(stats.max_zero_timeout_polls_in_burst <= 1);
14405    }
14406
14407    #[test]
14408    fn quit_stops_draining_remaining_burst_events() {
14409        use ftui_core::event::{KeyCode, KeyEvent};
14410
14411        struct QuitBurstModel {
14412            processed: usize,
14413            quit_after: usize,
14414        }
14415
14416        #[derive(Debug)]
14417        #[allow(dead_code)]
14418        enum QuitBurstMsg {
14419            Event(Event),
14420        }
14421
14422        impl From<Event> for QuitBurstMsg {
14423            fn from(event: Event) -> Self {
14424                Self::Event(event)
14425            }
14426        }
14427
14428        impl Model for QuitBurstModel {
14429            type Message = QuitBurstMsg;
14430
14431            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14432                match msg {
14433                    QuitBurstMsg::Event(_) => {
14434                        self.processed = self.processed.saturating_add(1);
14435                        if self.processed >= self.quit_after {
14436                            Cmd::quit()
14437                        } else {
14438                            Cmd::none()
14439                        }
14440                    }
14441                }
14442            }
14443
14444            fn view(&self, _frame: &mut Frame) {}
14445        }
14446
14447        struct QuitBurstSource {
14448            queue: VecDeque<Event>,
14449        }
14450
14451        impl BackendEventSource for QuitBurstSource {
14452            type Error = io::Error;
14453
14454            fn size(&self) -> Result<(u16, u16), Self::Error> {
14455                Ok((80, 24))
14456            }
14457
14458            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14459                Ok(())
14460            }
14461
14462            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
14463                Ok(!self.queue.is_empty())
14464            }
14465
14466            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14467                Ok(self.queue.pop_front())
14468            }
14469        }
14470
14471        let total_events = 8usize;
14472        let quit_after = 3usize;
14473        let mut queue = VecDeque::new();
14474        for _ in 0..total_events {
14475            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
14476        }
14477
14478        let writer = TerminalWriter::new(
14479            Vec::<u8>::new(),
14480            ScreenMode::AltScreen,
14481            UiAnchor::Bottom,
14482            TerminalCapabilities::dumb(),
14483        );
14484        let config = ProgramConfig::default()
14485            .with_forced_size(80, 24)
14486            .with_signal_interception(false)
14487            .with_immediate_drain(ImmediateDrainConfig {
14488                max_zero_timeout_polls_per_burst: 64,
14489                max_burst_duration: Duration::from_secs(1),
14490                backoff_timeout: Duration::from_millis(1),
14491            });
14492        let model = QuitBurstModel {
14493            processed: 0,
14494            quit_after,
14495        };
14496        let events = QuitBurstSource { queue };
14497
14498        let mut program =
14499            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14500                .expect("program creation");
14501        program.run().expect("run burst quit");
14502
14503        assert_eq!(program.model().processed, quit_after);
14504    }
14505
14506    // =========================================================================
14507    // Program helper methods (bd-2yjus)
14508    // =========================================================================
14509
14510    #[test]
14511    fn headless_program_quit_and_is_running() {
14512        let mut program =
14513            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14514        assert!(program.is_running());
14515
14516        program.quit();
14517        assert!(!program.is_running());
14518    }
14519
14520    #[test]
14521    fn headless_program_model_mut() {
14522        let mut program =
14523            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14524        assert_eq!(program.model().value, 0);
14525
14526        program.model_mut().value = 42;
14527        assert_eq!(program.model().value, 42);
14528    }
14529
14530    #[test]
14531    fn headless_program_request_redraw() {
14532        let mut program =
14533            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14534        program.dirty = false;
14535
14536        program.request_redraw();
14537        assert!(program.dirty);
14538    }
14539
14540    #[test]
14541    fn headless_program_last_widget_signals_initially_empty() {
14542        let program =
14543            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14544        assert!(program.last_widget_signals().is_empty());
14545    }
14546
14547    #[test]
14548    fn headless_program_no_persistence_by_default() {
14549        let program =
14550            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14551        assert!(!program.has_persistence());
14552        assert!(program.state_registry().is_none());
14553    }
14554
14555    // =========================================================================
14556    // classify_event_for_fairness (bd-2yjus)
14557    // =========================================================================
14558
14559    #[test]
14560    fn classify_event_fairness_key_is_input() {
14561        let event = Event::Key(ftui_core::event::KeyEvent::new(
14562            ftui_core::event::KeyCode::Char('a'),
14563        ));
14564        let classification =
14565            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14566        assert_eq!(classification, FairnessEventType::Input);
14567    }
14568
14569    #[test]
14570    fn classify_event_fairness_resize_is_resize() {
14571        let event = Event::Resize {
14572            width: 80,
14573            height: 24,
14574        };
14575        let classification =
14576            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14577        assert_eq!(classification, FairnessEventType::Resize);
14578    }
14579
14580    #[test]
14581    fn classify_event_fairness_tick_is_tick() {
14582        let event = Event::Tick;
14583        let classification =
14584            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14585        assert_eq!(classification, FairnessEventType::Tick);
14586    }
14587
14588    #[test]
14589    fn classify_event_fairness_paste_is_input() {
14590        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
14591        let classification =
14592            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14593        assert_eq!(classification, FairnessEventType::Input);
14594    }
14595
14596    #[test]
14597    fn classify_event_fairness_focus_is_input() {
14598        let event = Event::Focus(true);
14599        let classification =
14600            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14601        assert_eq!(classification, FairnessEventType::Input);
14602    }
14603
14604    // =========================================================================
14605    // ProgramConfig builder methods (bd-2yjus)
14606    // =========================================================================
14607
14608    #[test]
14609    fn program_config_with_diff_config() {
14610        let diff = RuntimeDiffConfig::default();
14611        let config = ProgramConfig::default().with_diff_config(diff.clone());
14612        // Just verify it doesn't panic and the field is set
14613        let _ = format!("{:?}", config);
14614    }
14615
14616    #[test]
14617    fn program_config_with_evidence_sink() {
14618        let config =
14619            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
14620        let _ = format!("{:?}", config);
14621    }
14622
14623    #[test]
14624    fn program_config_with_render_trace() {
14625        let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
14626        let _ = format!("{:?}", config);
14627    }
14628
14629    #[test]
14630    fn program_config_with_locale() {
14631        let config = ProgramConfig::default().with_locale("fr");
14632        let _ = format!("{:?}", config);
14633    }
14634
14635    #[test]
14636    fn program_config_with_locale_context() {
14637        let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
14638        let _ = format!("{:?}", config);
14639    }
14640
14641    #[test]
14642    fn program_config_without_forced_size() {
14643        let config = ProgramConfig::default()
14644            .with_forced_size(80, 24)
14645            .without_forced_size();
14646        assert!(config.forced_size.is_none());
14647    }
14648
14649    #[test]
14650    fn program_config_forced_size_clamps_min() {
14651        let config = ProgramConfig::default().with_forced_size(0, 0);
14652        assert_eq!(config.forced_size, Some((1, 1)));
14653    }
14654
14655    #[test]
14656    fn program_config_with_widget_refresh() {
14657        let wrc = WidgetRefreshConfig {
14658            enabled: false,
14659            ..Default::default()
14660        };
14661        let config = ProgramConfig::default().with_widget_refresh(wrc);
14662        assert!(!config.widget_refresh.enabled);
14663    }
14664
14665    #[test]
14666    fn program_config_with_effect_queue() {
14667        let eqc = EffectQueueConfig::default().with_enabled(true);
14668        let config = ProgramConfig::default().with_effect_queue(eqc);
14669        assert!(config.effect_queue.enabled);
14670        assert_eq!(
14671            config.effect_queue.backend,
14672            TaskExecutorBackend::EffectQueue
14673        );
14674    }
14675
14676    #[test]
14677    fn program_config_with_resize_coalescer_custom() {
14678        let cc = CoalescerConfig {
14679            steady_delay_ms: 42,
14680            ..Default::default()
14681        };
14682        let config = ProgramConfig::default().with_resize_coalescer(cc);
14683        assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
14684    }
14685
14686    #[test]
14687    fn program_config_with_inline_auto_remeasure() {
14688        let config = ProgramConfig::default()
14689            .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
14690        assert!(config.inline_auto_remeasure.is_some());
14691
14692        let config = config.without_inline_auto_remeasure();
14693        assert!(config.inline_auto_remeasure.is_none());
14694    }
14695
14696    #[test]
14697    fn program_config_with_persistence_full() {
14698        let pc = PersistenceConfig::disabled();
14699        let config = ProgramConfig::default().with_persistence(pc);
14700        assert!(config.persistence.registry.is_none());
14701    }
14702
14703    #[test]
14704    fn program_config_with_conformal_config() {
14705        let config = ProgramConfig::default()
14706            .with_conformal_config(ConformalConfig::default())
14707            .without_conformal();
14708        assert!(config.conformal_config.is_none());
14709    }
14710
14711    // =========================================================================
14712    // Rollout config builder methods (bd-2crbt)
14713    // =========================================================================
14714
14715    #[test]
14716    fn program_config_with_lane() {
14717        let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
14718        assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
14719    }
14720
14721    #[test]
14722    fn program_config_default_lane_resolves_to_spawned_backend() {
14723        // Input-lag regression fix (#78): the default Structured lane now
14724        // resolves to per-task `Spawned` execution instead of the single-worker
14725        // `EffectQueue`. Structured cancellation is independent of the task
14726        // executor backend, so the lane no longer serializes `Cmd::Task` through
14727        // one `effect_queue_loop` worker (which caused per-keystroke head-of-line
14728        // latency for PTY-forwarding apps). `enabled` is the legacy convenience
14729        // flag mirroring the backend, so it is now false for the default lane.
14730        let resolved = ProgramConfig::default().resolved_effect_queue_config();
14731        assert!(!resolved.enabled);
14732        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14733    }
14734
14735    #[test]
14736    fn program_config_legacy_lane_resolves_to_spawned_backend() {
14737        let resolved = ProgramConfig::default()
14738            .with_lane(RuntimeLane::Legacy)
14739            .resolved_effect_queue_config();
14740        assert!(!resolved.enabled);
14741        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14742    }
14743
14744    #[test]
14745    fn program_config_explicit_spawned_backend_is_preserved() {
14746        let resolved = ProgramConfig::default()
14747            .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
14748            .resolved_effect_queue_config();
14749        assert!(!resolved.enabled);
14750        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14751    }
14752
14753    #[test]
14754    fn program_config_with_rollout_policy() {
14755        let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
14756        assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
14757    }
14758
14759    #[test]
14760    fn rollout_policy_labels() {
14761        assert_eq!(RolloutPolicy::Off.label(), "off");
14762        assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
14763        assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
14764        assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
14765    }
14766
14767    #[test]
14768    fn rollout_policy_is_shadow() {
14769        assert!(!RolloutPolicy::Off.is_shadow());
14770        assert!(RolloutPolicy::Shadow.is_shadow());
14771        assert!(!RolloutPolicy::Enabled.is_shadow());
14772    }
14773
14774    #[test]
14775    fn rollout_policy_default_is_off() {
14776        assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
14777    }
14778
14779    #[test]
14780    fn runtime_lane_parse_legacy() {
14781        assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
14782    }
14783
14784    #[test]
14785    fn runtime_lane_parse_structured_case_insensitive() {
14786        assert_eq!(
14787            RuntimeLane::parse("Structured"),
14788            Some(RuntimeLane::Structured)
14789        );
14790    }
14791
14792    #[test]
14793    fn runtime_lane_parse_asupersync_uppercase() {
14794        assert_eq!(
14795            RuntimeLane::parse("ASUPERSYNC"),
14796            Some(RuntimeLane::Asupersync)
14797        );
14798    }
14799
14800    #[test]
14801    fn runtime_lane_parse_unrecognized() {
14802        assert_eq!(RuntimeLane::parse("bogus"), None);
14803    }
14804
14805    #[test]
14806    fn rollout_policy_parse_shadow() {
14807        assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
14808    }
14809
14810    #[test]
14811    fn rollout_policy_parse_enabled() {
14812        assert_eq!(
14813            RolloutPolicy::parse("enabled"),
14814            Some(RolloutPolicy::Enabled)
14815        );
14816    }
14817
14818    #[test]
14819    fn rollout_policy_parse_off() {
14820        assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
14821    }
14822
14823    #[test]
14824    fn rollout_policy_parse_unrecognized() {
14825        assert_eq!(RolloutPolicy::parse("bogus"), None);
14826    }
14827
14828    // =========================================================================
14829    // PersistenceConfig Debug (bd-2yjus)
14830    // =========================================================================
14831
14832    #[test]
14833    fn persistence_config_debug() {
14834        let config = PersistenceConfig::default();
14835        let debug = format!("{config:?}");
14836        assert!(debug.contains("PersistenceConfig"));
14837        assert!(debug.contains("auto_load"));
14838        assert!(debug.contains("auto_save"));
14839    }
14840
14841    // =========================================================================
14842    // FrameTimingConfig (bd-2yjus)
14843    // =========================================================================
14844
14845    #[test]
14846    fn frame_timing_config_debug() {
14847        use std::sync::Arc;
14848
14849        struct DummySink;
14850        impl FrameTimingSink for DummySink {
14851            fn record_frame(&self, _timing: &FrameTiming) {}
14852        }
14853
14854        let config = FrameTimingConfig::new(Arc::new(DummySink));
14855        let debug = format!("{config:?}");
14856        assert!(debug.contains("FrameTimingConfig"));
14857    }
14858
14859    #[test]
14860    fn program_config_with_frame_timing() {
14861        use std::sync::Arc;
14862
14863        struct DummySink;
14864        impl FrameTimingSink for DummySink {
14865            fn record_frame(&self, _timing: &FrameTiming) {}
14866        }
14867
14868        let config =
14869            ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
14870        assert!(config.frame_timing.is_some());
14871    }
14872
14873    // =========================================================================
14874    // BudgetDecisionEvidence helper functions (bd-2yjus)
14875    // =========================================================================
14876
14877    #[test]
14878    fn budget_decision_evidence_decision_from_levels() {
14879        use ftui_render::budget::DegradationLevel;
14880        // Degrade: after > before
14881        assert_eq!(
14882            BudgetDecisionEvidence::decision_from_levels(
14883                DegradationLevel::Full,
14884                DegradationLevel::SimpleBorders
14885            ),
14886            BudgetDecision::Degrade
14887        );
14888        // Upgrade: after < before
14889        assert_eq!(
14890            BudgetDecisionEvidence::decision_from_levels(
14891                DegradationLevel::SimpleBorders,
14892                DegradationLevel::Full
14893            ),
14894            BudgetDecision::Upgrade
14895        );
14896        // Hold: same
14897        assert_eq!(
14898            BudgetDecisionEvidence::decision_from_levels(
14899                DegradationLevel::Full,
14900                DegradationLevel::Full
14901            ),
14902            BudgetDecision::Hold
14903        );
14904    }
14905
14906    // =========================================================================
14907    // WidgetRefreshPlan (bd-2yjus)
14908    // =========================================================================
14909
14910    #[test]
14911    fn widget_refresh_plan_clear() {
14912        let mut plan = WidgetRefreshPlan::new();
14913        plan.frame_idx = 5;
14914        plan.budget_us = 100.0;
14915        plan.signal_count = 3;
14916        plan.over_budget = true;
14917        plan.clear();
14918        assert_eq!(plan.frame_idx, 0);
14919        assert_eq!(plan.budget_us, 0.0);
14920        assert_eq!(plan.signal_count, 0);
14921        assert!(!plan.over_budget);
14922    }
14923
14924    #[test]
14925    fn widget_refresh_plan_as_budget_empty_signals() {
14926        let plan = WidgetRefreshPlan::new();
14927        let budget = plan.as_budget();
14928        // With signal_count == 0, should be allow_all (allows any widget)
14929        assert!(budget.allows(0, false));
14930        assert!(budget.allows(999, false));
14931    }
14932
14933    #[test]
14934    fn widget_refresh_plan_to_jsonl_structure() {
14935        let plan = WidgetRefreshPlan::new();
14936        let jsonl = plan.to_jsonl();
14937        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
14938        assert!(jsonl.contains("\"frame_idx\":0"));
14939        assert!(jsonl.contains("\"selected\":[]"));
14940    }
14941
14942    // =========================================================================
14943    // BatchController Default trait (bd-2yjus)
14944    // =========================================================================
14945
14946    #[test]
14947    fn batch_controller_default_trait() {
14948        let bc = BatchController::default();
14949        let bc2 = BatchController::new();
14950        // Should be equivalent
14951        assert_eq!(bc.tau_s(), bc2.tau_s());
14952        assert_eq!(bc.observations(), bc2.observations());
14953    }
14954
14955    #[test]
14956    fn batch_controller_observe_arrival_stale_gap_ignored() {
14957        let mut bc = BatchController::new();
14958        let base = Instant::now();
14959        // First arrival
14960        bc.observe_arrival(base);
14961        // Stale gap > 10s should be ignored
14962        bc.observe_arrival(base + Duration::from_secs(15));
14963        assert_eq!(bc.observations(), 0);
14964    }
14965
14966    #[test]
14967    fn batch_controller_observe_service_out_of_range() {
14968        let mut bc = BatchController::new();
14969        let original_service = bc.service_est_s();
14970        // Out-of-range (>= 10s) should be ignored
14971        bc.observe_service(Duration::from_secs(15));
14972        assert_eq!(bc.service_est_s(), original_service);
14973    }
14974
14975    #[test]
14976    fn batch_controller_lambda_zero_inter_arrival() {
14977        // When ema_inter_arrival_s is effectively 0, lambda should be 0
14978        let bc = BatchController {
14979            ema_inter_arrival_s: 0.0,
14980            ..BatchController::new()
14981        };
14982        assert_eq!(bc.lambda_est(), 0.0);
14983    }
14984
14985    // =========================================================================
14986    // Headless program: Cmd::Log with and without trailing newline (bd-2yjus)
14987    // =========================================================================
14988
14989    #[test]
14990    fn headless_execute_cmd_log_appends_newline_if_missing() {
14991        let mut program =
14992            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14993        program.execute_cmd(Cmd::log("no newline")).expect("log");
14994
14995        let bytes = program.writer.into_inner().expect("writer output");
14996        let output = String::from_utf8_lossy(&bytes);
14997        // The sanitized output should end with a newline
14998        assert!(output.contains("no newline"));
14999    }
15000
15001    #[test]
15002    fn headless_execute_cmd_log_preserves_trailing_newline() {
15003        let mut program =
15004            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
15005        program
15006            .execute_cmd(Cmd::log("with newline\n"))
15007            .expect("log");
15008
15009        let bytes = program.writer.into_inner().expect("writer output");
15010        let output = String::from_utf8_lossy(&bytes);
15011        assert!(output.contains("with newline"));
15012    }
15013
15014    // =========================================================================
15015    // Headless program: immediate resize behavior (bd-2yjus)
15016    // =========================================================================
15017
15018    #[test]
15019    fn headless_handle_event_immediate_resize() {
15020        struct ResizeModel {
15021            last_size: Option<(u16, u16)>,
15022        }
15023
15024        #[derive(Debug)]
15025        enum ResizeMsg {
15026            Resize(u16, u16),
15027            Other,
15028        }
15029
15030        impl From<Event> for ResizeMsg {
15031            fn from(event: Event) -> Self {
15032                match event {
15033                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
15034                    _ => ResizeMsg::Other,
15035                }
15036            }
15037        }
15038
15039        impl Model for ResizeModel {
15040            type Message = ResizeMsg;
15041
15042            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
15043                if let ResizeMsg::Resize(w, h) = msg {
15044                    self.last_size = Some((w, h));
15045                }
15046                Cmd::none()
15047            }
15048
15049            fn view(&self, _frame: &mut Frame) {}
15050        }
15051
15052        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
15053        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
15054
15055        program
15056            .handle_event(Event::Resize {
15057                width: 120,
15058                height: 40,
15059            })
15060            .expect("handle resize");
15061
15062        assert_eq!(program.width, 120);
15063        assert_eq!(program.height, 40);
15064        assert_eq!(program.model().last_size, Some((120, 40)));
15065    }
15066
15067    // =========================================================================
15068    // Headless program: resize clamps zero dimensions (bd-2yjus)
15069    // =========================================================================
15070
15071    #[test]
15072    fn headless_apply_resize_clamps_zero_to_one() {
15073        struct SimpleModel;
15074
15075        #[derive(Debug)]
15076        enum SimpleMsg {
15077            Noop,
15078        }
15079
15080        impl From<Event> for SimpleMsg {
15081            fn from(_: Event) -> Self {
15082                SimpleMsg::Noop
15083            }
15084        }
15085
15086        impl Model for SimpleModel {
15087            type Message = SimpleMsg;
15088
15089            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
15090                Cmd::none()
15091            }
15092
15093            fn view(&self, _frame: &mut Frame) {}
15094        }
15095
15096        let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
15097        program
15098            .apply_resize(0, 0, Duration::ZERO, false)
15099            .expect("resize");
15100
15101        // Zero dimensions should be clamped to 1
15102        assert_eq!(program.width, 1);
15103        assert_eq!(program.height, 1);
15104    }
15105
15106    // =========================================================================
15107    // PaneTerminalAdapter::force_cancel_all (bd-24v9m)
15108    // =========================================================================
15109
15110    #[test]
15111    fn force_cancel_all_idle_returns_none() {
15112        let mut adapter =
15113            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15114        assert!(adapter.force_cancel_all().is_none());
15115    }
15116
15117    #[test]
15118    fn force_cancel_all_after_pointer_down_returns_diagnostics() {
15119        let mut adapter =
15120            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15121        let target = pane_target(SplitAxis::Horizontal);
15122
15123        let down = Event::Mouse(MouseEvent::new(
15124            MouseEventKind::Down(MouseButton::Left),
15125            5,
15126            5,
15127        ));
15128        let _ = adapter.translate(&down, Some(target));
15129        assert!(adapter.active_pointer_id().is_some());
15130
15131        let diag = adapter
15132            .force_cancel_all()
15133            .expect("should produce diagnostics");
15134        assert!(diag.had_active_pointer);
15135        assert_eq!(diag.active_pointer_id, Some(1));
15136        assert!(diag.machine_transition.is_some());
15137
15138        // Adapter should be fully idle afterwards
15139        assert_eq!(adapter.active_pointer_id(), None);
15140        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
15141    }
15142
15143    #[test]
15144    fn force_cancel_all_during_drag_returns_diagnostics() {
15145        let mut adapter =
15146            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15147        let target = pane_target(SplitAxis::Vertical);
15148
15149        // Down → arm
15150        let down = Event::Mouse(MouseEvent::new(
15151            MouseEventKind::Down(MouseButton::Left),
15152            3,
15153            3,
15154        ));
15155        let _ = adapter.translate(&down, Some(target));
15156
15157        // Drag → transition to Dragging
15158        let drag = Event::Mouse(MouseEvent::new(
15159            MouseEventKind::Drag(MouseButton::Left),
15160            8,
15161            3,
15162        ));
15163        let _ = adapter.translate(&drag, None);
15164
15165        let diag = adapter
15166            .force_cancel_all()
15167            .expect("should produce diagnostics");
15168        assert!(diag.had_active_pointer);
15169        assert!(diag.machine_transition.is_some());
15170        let transition = diag.machine_transition.unwrap();
15171        assert!(matches!(
15172            transition.effect,
15173            PaneDragResizeEffect::Canceled {
15174                reason: PaneCancelReason::Programmatic,
15175                ..
15176            }
15177        ));
15178    }
15179
15180    #[test]
15181    fn force_cancel_all_is_idempotent() {
15182        let mut adapter =
15183            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15184        let target = pane_target(SplitAxis::Horizontal);
15185
15186        let down = Event::Mouse(MouseEvent::new(
15187            MouseEventKind::Down(MouseButton::Left),
15188            5,
15189            5,
15190        ));
15191        let _ = adapter.translate(&down, Some(target));
15192
15193        let first = adapter.force_cancel_all();
15194        assert!(first.is_some());
15195
15196        let second = adapter.force_cancel_all();
15197        assert!(second.is_none());
15198    }
15199
15200    // =========================================================================
15201    // PaneInteractionGuard (bd-24v9m)
15202    // =========================================================================
15203
15204    #[test]
15205    fn pane_interaction_guard_finish_when_idle() {
15206        let mut adapter =
15207            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15208        let guard = PaneInteractionGuard::new(&mut adapter);
15209        let diag = guard.finish();
15210        assert!(diag.is_none());
15211    }
15212
15213    #[test]
15214    fn pane_interaction_guard_finish_returns_diagnostics() {
15215        let mut adapter =
15216            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15217        let target = pane_target(SplitAxis::Horizontal);
15218
15219        // Start a drag interaction through the adapter directly
15220        let down = Event::Mouse(MouseEvent::new(
15221            MouseEventKind::Down(MouseButton::Left),
15222            5,
15223            5,
15224        ));
15225        let _ = adapter.translate(&down, Some(target));
15226
15227        let guard = PaneInteractionGuard::new(&mut adapter);
15228        let diag = guard.finish().expect("should produce diagnostics");
15229        assert!(diag.had_active_pointer);
15230        assert_eq!(diag.active_pointer_id, Some(1));
15231    }
15232
15233    #[test]
15234    fn pane_interaction_guard_drop_cancels_active_interaction() {
15235        let mut adapter =
15236            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15237        let target = pane_target(SplitAxis::Vertical);
15238
15239        let down = Event::Mouse(MouseEvent::new(
15240            MouseEventKind::Down(MouseButton::Left),
15241            7,
15242            7,
15243        ));
15244        let _ = adapter.translate(&down, Some(target));
15245        assert!(adapter.active_pointer_id().is_some());
15246
15247        {
15248            let _guard = PaneInteractionGuard::new(&mut adapter);
15249            // guard drops here without finish()
15250        }
15251
15252        // After guard drop, adapter should be idle
15253        assert_eq!(adapter.active_pointer_id(), None);
15254        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
15255    }
15256
15257    #[test]
15258    fn pane_interaction_guard_adapter_access_works() {
15259        let mut adapter =
15260            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15261        let target = pane_target(SplitAxis::Horizontal);
15262
15263        let mut guard = PaneInteractionGuard::new(&mut adapter);
15264
15265        // Use the adapter through the guard
15266        let down = Event::Mouse(MouseEvent::new(
15267            MouseEventKind::Down(MouseButton::Left),
15268            5,
15269            5,
15270        ));
15271        let dispatch = guard.adapter().translate(&down, Some(target));
15272        assert!(dispatch.primary_event.is_some());
15273
15274        // finish should clean up the interaction started through the guard
15275        let diag = guard.finish().expect("should produce diagnostics");
15276        assert!(diag.had_active_pointer);
15277    }
15278
15279    #[test]
15280    fn pane_interaction_guard_finish_then_drop_is_safe() {
15281        let mut adapter =
15282            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15283        let target = pane_target(SplitAxis::Horizontal);
15284
15285        let down = Event::Mouse(MouseEvent::new(
15286            MouseEventKind::Down(MouseButton::Left),
15287            5,
15288            5,
15289        ));
15290        let _ = adapter.translate(&down, Some(target));
15291
15292        let guard = PaneInteractionGuard::new(&mut adapter);
15293        let _diag = guard.finish();
15294        // guard is consumed by finish(), so drop doesn't double-cancel
15295        // This test proves the API is safe: finish() takes `self` not `&mut self`
15296        assert_eq!(adapter.active_pointer_id(), None);
15297    }
15298
15299    // =========================================================================
15300    // PaneCapabilityMatrix (bd-6u66i)
15301    // =========================================================================
15302
15303    fn caps_modern() -> TerminalCapabilities {
15304        TerminalCapabilities::modern()
15305    }
15306
15307    fn caps_with_mux(
15308        mux: PaneMuxEnvironment,
15309    ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
15310        let mut caps = TerminalCapabilities::modern();
15311        match mux {
15312            PaneMuxEnvironment::Tmux => caps.in_tmux = true,
15313            PaneMuxEnvironment::Screen => caps.in_screen = true,
15314            PaneMuxEnvironment::Zellij => caps.in_zellij = true,
15315            PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
15316            PaneMuxEnvironment::None => {}
15317        }
15318        caps
15319    }
15320
15321    #[test]
15322    fn capability_matrix_bare_terminal_modern() {
15323        let caps = caps_modern();
15324        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15325
15326        assert_eq!(mat.mux, PaneMuxEnvironment::None);
15327        assert!(mat.mouse_sgr);
15328        assert!(mat.mouse_drag_reliable);
15329        assert!(mat.mouse_button_discrimination);
15330        assert!(mat.focus_events);
15331        assert!(mat.unicode_box_drawing);
15332        assert!(mat.true_color);
15333        assert!(!mat.degraded);
15334        assert!(mat.drag_enabled());
15335        assert!(mat.focus_cancel_effective());
15336        assert!(mat.limitations().is_empty());
15337    }
15338
15339    #[test]
15340    fn capability_matrix_tmux() {
15341        let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
15342        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15343
15344        assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
15345        // Focus cancel path is conservatively disabled in all muxes.
15346        assert!(mat.mouse_drag_reliable);
15347        assert!(!mat.focus_events);
15348        assert!(mat.drag_enabled());
15349        assert!(!mat.focus_cancel_effective());
15350        assert!(mat.degraded);
15351    }
15352
15353    #[test]
15354    fn capability_matrix_screen_degrades_drag() {
15355        let caps = caps_with_mux(PaneMuxEnvironment::Screen);
15356        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15357
15358        assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
15359        assert!(!mat.mouse_drag_reliable);
15360        assert!(!mat.focus_events);
15361        assert!(!mat.drag_enabled());
15362        assert!(!mat.focus_cancel_effective());
15363        assert!(mat.degraded);
15364
15365        let lims = mat.limitations();
15366        assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
15367        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
15368    }
15369
15370    #[test]
15371    fn capability_matrix_zellij() {
15372        let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
15373        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15374
15375        assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
15376        assert!(mat.mouse_drag_reliable);
15377        assert!(!mat.focus_events);
15378        assert!(mat.drag_enabled());
15379        assert!(!mat.focus_cancel_effective());
15380        assert!(mat.degraded);
15381    }
15382
15383    #[test]
15384    fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
15385        let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
15386        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15387
15388        assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
15389        assert!(mat.mouse_drag_reliable);
15390        assert!(!mat.focus_events);
15391        assert!(mat.drag_enabled());
15392        assert!(!mat.focus_cancel_effective());
15393        assert!(mat.degraded);
15394    }
15395
15396    #[test]
15397    fn capability_matrix_no_sgr_mouse() {
15398        let mut caps = caps_modern();
15399        caps.mouse_sgr = false;
15400        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15401
15402        assert!(!mat.mouse_sgr);
15403        assert!(!mat.mouse_button_discrimination);
15404        assert!(mat.degraded);
15405
15406        let lims = mat.limitations();
15407        assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
15408        assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
15409    }
15410
15411    #[test]
15412    fn capability_matrix_no_focus_events() {
15413        let mut caps = caps_modern();
15414        caps.focus_events = false;
15415        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15416
15417        assert!(!mat.focus_events);
15418        assert!(!mat.focus_cancel_effective());
15419        assert!(mat.degraded);
15420
15421        let lims = mat.limitations();
15422        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
15423    }
15424
15425    #[test]
15426    fn capability_matrix_dumb_terminal() {
15427        let caps = TerminalCapabilities::dumb();
15428        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15429
15430        assert_eq!(mat.mux, PaneMuxEnvironment::None);
15431        assert!(!mat.mouse_sgr);
15432        assert!(!mat.focus_events);
15433        assert!(!mat.unicode_box_drawing);
15434        assert!(!mat.true_color);
15435        assert!(mat.degraded);
15436        assert!(mat.limitations().len() >= 3);
15437    }
15438
15439    #[test]
15440    fn capability_matrix_limitations_have_fallbacks() {
15441        let caps = TerminalCapabilities::dumb();
15442        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15443
15444        for lim in mat.limitations() {
15445            assert!(!lim.id.is_empty());
15446            assert!(!lim.description.is_empty());
15447            assert!(!lim.fallback.is_empty());
15448        }
15449    }
15450
15451    // ========================================================================
15452    // Screen transition detection tests (A.2 + D.3)
15453    // ========================================================================
15454
15455    /// A multi-screen model that implements ScreenTickDispatch, for testing
15456    /// the `check_screen_transition` logic.
15457    struct MultiScreenModel {
15458        active: String,
15459        screens: Vec<String>,
15460        ticked_screens: Vec<(String, u64)>,
15461    }
15462
15463    #[derive(Debug)]
15464    enum MultiScreenMsg {
15465        #[expect(dead_code)]
15466        Event(Event),
15467    }
15468
15469    impl From<Event> for MultiScreenMsg {
15470        fn from(event: Event) -> Self {
15471            MultiScreenMsg::Event(event)
15472        }
15473    }
15474
15475    impl Model for MultiScreenModel {
15476        type Message = MultiScreenMsg;
15477
15478        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
15479            match msg {
15480                MultiScreenMsg::Event(_) => Cmd::none(),
15481            }
15482        }
15483
15484        fn view(&self, _frame: &mut Frame) {}
15485
15486        fn as_screen_tick_dispatch(
15487            &mut self,
15488        ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15489            Some(self)
15490        }
15491    }
15492
15493    impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
15494        fn screen_ids(&self) -> Vec<String> {
15495            self.screens.clone()
15496        }
15497
15498        fn active_screen_id(&self) -> String {
15499            self.active.clone()
15500        }
15501
15502        fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
15503            self.ticked_screens.push((screen_id.to_owned(), tick_count));
15504        }
15505    }
15506
15507    /// Shared log for recording strategy transitions (inspectable after test).
15508    type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
15509
15510    /// A recording tick strategy that logs `on_screen_transition` calls
15511    /// to a shared log that can be inspected from test assertions.
15512    struct RecordingStrategy {
15513        log: TransitionLog,
15514    }
15515
15516    impl RecordingStrategy {
15517        fn new(log: TransitionLog) -> Self {
15518            Self { log }
15519        }
15520    }
15521
15522    impl crate::tick_strategy::TickStrategy for RecordingStrategy {
15523        fn should_tick(
15524            &mut self,
15525            _screen_id: &str,
15526            _tick_count: u64,
15527            _active_screen: &str,
15528        ) -> crate::tick_strategy::TickDecision {
15529            crate::tick_strategy::TickDecision::Skip
15530        }
15531
15532        fn on_screen_transition(&mut self, from: &str, to: &str) {
15533            self.log
15534                .lock()
15535                .unwrap()
15536                .push((from.to_owned(), to.to_owned()));
15537        }
15538
15539        fn name(&self) -> &str {
15540            "Recording"
15541        }
15542
15543        fn debug_stats(&self) -> Vec<(String, String)> {
15544            vec![("strategy".into(), "Recording".into())]
15545        }
15546    }
15547
15548    /// Helper to create a headless Program with a multi-screen model and
15549    /// a recording tick strategy. Returns the program and a shared log of
15550    /// `on_screen_transition` calls for assertions.
15551    fn headless_multi_screen_program(
15552        active: &str,
15553        screens: &[&str],
15554    ) -> (
15555        Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
15556        TransitionLog,
15557    ) {
15558        let model = MultiScreenModel {
15559            active: active.to_owned(),
15560            screens: screens.iter().map(|s| (*s).to_owned()).collect(),
15561            ticked_screens: Vec::new(),
15562        };
15563        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15564        let writer = TerminalWriter::new(
15565            Vec::<u8>::new(),
15566            ScreenMode::AltScreen,
15567            UiAnchor::Bottom,
15568            TerminalCapabilities::dumb(),
15569        );
15570        let config = ProgramConfig {
15571            forced_size: Some((80, 24)),
15572            tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
15573            ..ProgramConfig::default()
15574        };
15575        let mut prog =
15576            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15577                .expect("headless program creation failed");
15578
15579        // Replace the default strategy with our recording strategy.
15580        let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
15581        prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
15582
15583        (prog, log)
15584    }
15585
15586    #[test]
15587    fn check_screen_transition_first_call_records_active() {
15588        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15589
15590        assert!(prog.last_active_screen_for_strategy.is_none());
15591        prog.check_screen_transition();
15592        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15593
15594        // First observation: no transition event, no force-tick.
15595        assert!(prog.model.ticked_screens.is_empty());
15596        assert!(log.lock().unwrap().is_empty());
15597    }
15598
15599    #[test]
15600    fn check_screen_transition_no_change_is_noop() {
15601        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15602
15603        // First call: records.
15604        prog.check_screen_transition();
15605
15606        // Second call with same active screen: no-op.
15607        prog.check_screen_transition();
15608        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15609
15610        // No force-tick, no transition notification.
15611        assert!(prog.model.ticked_screens.is_empty());
15612        assert!(log.lock().unwrap().is_empty());
15613    }
15614
15615    #[test]
15616    fn check_screen_transition_detects_switch_and_force_ticks() {
15617        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15618
15619        prog.check_screen_transition(); // records "A"
15620
15621        // Simulate model switching to screen "B".
15622        prog.model.active = "B".to_owned();
15623        prog.check_screen_transition();
15624
15625        // D.3: force-tick should have been dispatched for "B".
15626        assert_eq!(prog.model.ticked_screens.len(), 1);
15627        assert_eq!(prog.model.ticked_screens[0].0, "B");
15628
15629        // A.2: strategy should have been notified of A → B.
15630        let transitions = log.lock().unwrap();
15631        assert_eq!(transitions.len(), 1);
15632        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15633
15634        // last_active should now be "B".
15635        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
15636    }
15637
15638    #[test]
15639    fn check_screen_transition_marks_dirty_on_change() {
15640        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15641
15642        prog.check_screen_transition();
15643        prog.dirty = false;
15644
15645        prog.model.active = "B".to_owned();
15646        prog.check_screen_transition();
15647
15648        assert!(prog.dirty);
15649    }
15650
15651    #[test]
15652    fn check_screen_transition_not_dirty_when_unchanged() {
15653        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15654
15655        prog.check_screen_transition();
15656        prog.dirty = false;
15657
15658        prog.check_screen_transition();
15659
15660        assert!(!prog.dirty);
15661    }
15662
15663    #[test]
15664    fn check_screen_transition_noop_without_strategy() {
15665        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15666
15667        // Remove the tick strategy.
15668        prog.tick_strategy = None;
15669
15670        prog.check_screen_transition();
15671        assert!(prog.last_active_screen_for_strategy.is_none());
15672    }
15673
15674    #[test]
15675    fn check_screen_transition_multiple_switches_notifies_strategy() {
15676        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15677
15678        prog.check_screen_transition(); // records "A"
15679
15680        // A → B
15681        prog.model.active = "B".to_owned();
15682        prog.check_screen_transition();
15683        assert_eq!(prog.model.ticked_screens.len(), 1);
15684        assert_eq!(prog.model.ticked_screens[0].0, "B");
15685
15686        // B → C
15687        prog.model.active = "C".to_owned();
15688        prog.check_screen_transition();
15689        assert_eq!(prog.model.ticked_screens.len(), 2);
15690        assert_eq!(prog.model.ticked_screens[1].0, "C");
15691
15692        // C → A
15693        prog.model.active = "A".to_owned();
15694        prog.check_screen_transition();
15695        assert_eq!(prog.model.ticked_screens.len(), 3);
15696        assert_eq!(prog.model.ticked_screens[2].0, "A");
15697
15698        // A.2: strategy should have all three transitions.
15699        let transitions = log.lock().unwrap();
15700        assert_eq!(transitions.len(), 3);
15701        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15702        assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
15703        assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
15704    }
15705
15706    #[test]
15707    fn check_screen_transition_uses_current_tick_count() {
15708        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15709        prog.tick_count = 42;
15710
15711        prog.check_screen_transition(); // records "A"
15712
15713        prog.model.active = "B".to_owned();
15714        prog.check_screen_transition();
15715
15716        // Force-tick should use the current tick_count.
15717        assert_eq!(prog.model.ticked_screens[0].1, 42);
15718    }
15719
15720    #[test]
15721    fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
15722        use crate::subscription::{StopSignal, SubId, Subscription};
15723
15724        struct TransitionSubModel {
15725            active: String,
15726            screens: Vec<String>,
15727            subscribed: bool,
15728        }
15729
15730        #[derive(Debug)]
15731        #[allow(dead_code)]
15732        enum TransitionSubMsg {
15733            Event(Event),
15734        }
15735
15736        impl From<Event> for TransitionSubMsg {
15737            fn from(event: Event) -> Self {
15738                Self::Event(event)
15739            }
15740        }
15741
15742        impl Model for TransitionSubModel {
15743            type Message = TransitionSubMsg;
15744
15745            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
15746                Cmd::none()
15747            }
15748
15749            fn view(&self, _frame: &mut Frame) {}
15750
15751            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
15752                if self.subscribed {
15753                    vec![Box::new(TransitionSubscription)]
15754                } else {
15755                    vec![]
15756                }
15757            }
15758
15759            fn as_screen_tick_dispatch(
15760                &mut self,
15761            ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15762                Some(self)
15763            }
15764        }
15765
15766        impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
15767            fn screen_ids(&self) -> Vec<String> {
15768                self.screens.clone()
15769            }
15770
15771            fn active_screen_id(&self) -> String {
15772                self.active.clone()
15773            }
15774
15775            fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
15776                if screen_id == self.active {
15777                    self.subscribed = true;
15778                }
15779            }
15780        }
15781
15782        struct TransitionSubscription;
15783
15784        impl Subscription<TransitionSubMsg> for TransitionSubscription {
15785            fn id(&self) -> SubId {
15786                1
15787            }
15788
15789            fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
15790        }
15791
15792        struct TransitionStrategy;
15793
15794        impl crate::tick_strategy::TickStrategy for TransitionStrategy {
15795            fn should_tick(
15796                &mut self,
15797                _screen_id: &str,
15798                _tick_count: u64,
15799                _active_screen: &str,
15800            ) -> crate::tick_strategy::TickDecision {
15801                crate::tick_strategy::TickDecision::Skip
15802            }
15803
15804            fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
15805
15806            fn name(&self) -> &str {
15807                "TransitionStrategy"
15808            }
15809
15810            fn debug_stats(&self) -> Vec<(String, String)> {
15811                vec![]
15812            }
15813        }
15814
15815        let model = TransitionSubModel {
15816            active: "A".to_owned(),
15817            screens: vec!["A".to_owned(), "B".to_owned()],
15818            subscribed: false,
15819        };
15820        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15821        let writer = TerminalWriter::new(
15822            Vec::<u8>::new(),
15823            ScreenMode::AltScreen,
15824            UiAnchor::Bottom,
15825            TerminalCapabilities::dumb(),
15826        );
15827        let config = ProgramConfig::default().with_forced_size(80, 24);
15828
15829        let mut program =
15830            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15831                .expect("program creation");
15832        program.tick_strategy = Some(Box::new(TransitionStrategy));
15833
15834        program.check_screen_transition();
15835        assert_eq!(program.subscriptions.active_count(), 0);
15836
15837        program.model.active = "B".to_owned();
15838        program.check_screen_transition();
15839
15840        assert!(program.model().subscribed);
15841        assert_eq!(program.subscriptions.active_count(), 1);
15842    }
15843
15844    #[test]
15845    fn tick_strategy_stats_returns_empty_without_strategy() {
15846        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15847        prog.tick_strategy = None;
15848        assert!(prog.tick_strategy_stats().is_empty());
15849    }
15850
15851    #[test]
15852    fn tick_strategy_stats_returns_strategy_fields() {
15853        let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15854        let stats = prog.tick_strategy_stats();
15855        // RecordingStrategy returns [("strategy", "Recording")]
15856        assert!(
15857            !stats.is_empty(),
15858            "stats should not be empty when strategy is configured"
15859        );
15860    }
15861}