Skip to main content

zero_tui/app/
state.rs

1//! Top-level app state — composed of mode, conversation log,
2//! prompt buffer, and a shared handle to the engine-state mirror.
3//!
4//! Ownership rules:
5//!   - `AppState` is !Send because of the crossterm backend it
6//!     will eventually render through; keep it single-threaded.
7//!   - The engine-state mirror is `Arc<RwLock<EngineState>>` and
8//!     is written only by the WS subscriber task; the app reads
9//!     it.
10//!   - Command execution is async and happens in the event loop,
11//!     not in `submit_prompt`. `submit_prompt` only buffers the
12//!     line into [`AppState::pending_input`]; the loop drains it.
13//!   - Persistence is optional. When a [`SessionSink`] is present,
14//!     every log entry is recorded synchronously.
15
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use parking_lot::RwLock;
20use zero_commands::{
21    Command, DispatchOutput, FrictionDecision, ModeTarget, OutputLine, OverlayTarget, ReplayKind,
22};
23use zero_engine_client::{EngineEvent, EngineState, RateBudget};
24use zero_operator_state::friction::FrictionLevel;
25
26use crate::app::event_ring::EventRing;
27use crate::app::log::{ConversationLog, EntryKind, LogEntry};
28use crate::app::mode::Mode;
29use crate::app::picker::SlashPicker;
30use crate::app::prompt::PromptBuffer;
31use crate::app::session::SessionSink;
32use crate::theme::Theme;
33
34// Each boolean on `AppState` tracks an orthogonal operator-
35// facing toggle (quit intent, screen-reader mode, live-stream
36// pane visibility, verbose rendering). Collapsing them into a
37// state machine would obscure the fact that any combination is
38// valid — e.g. an operator with screen_reader+verbose both on
39// is a real configuration. Allow the lint with the rationale
40// inline so a later contributor does not have to rediscover it.
41/// First-live-trade ceremony copy (§8.4, exact shape).
42///
43/// Three short system lines — acknowledge, contextualise,
44/// orient. No celebration language. The block renders
45/// inline in the conversation pane between the engine
46/// event stream and the next prompt, so the operator sees
47/// it the same way they see any other system line.
48///
49/// Kept as a module-level constant so the copy is testable
50/// by reference rather than by re-matching a regex, and so
51/// a future spec tweak is a one-file PR.
52const CEREMONY_LINES: &[&str] = &[
53    "first live position observed.",
54    "from here on every fill is real. so is every loss.",
55    "type /risk to see what the engine is watching for you. /break takes you out of the seat.",
56];
57
58#[allow(clippy::struct_excessive_bools)]
59#[derive(Debug)]
60pub struct AppState {
61    pub mode: Mode,
62    pub log: ConversationLog,
63    pub prompt: PromptBuffer,
64    pub theme: Theme,
65    pub engine: Arc<RwLock<EngineState>>,
66    pub should_quit: bool,
67    /// Buffered input line waiting for the event loop to dispatch
68    /// it. `None` between submissions.
69    pub pending_input: Option<String>,
70    /// Optional session persistence. When set, every log entry is
71    /// recorded.
72    pub sink: Option<SessionSink>,
73    /// Currently visible modal overlay. `Some` grabs input: the
74    /// next key press dismisses (except Ctrl+C, which still exits).
75    /// `None` is the normal prompt-editing state.
76    pub overlay: Option<ActiveOverlay>,
77    /// Slash-command picker. `Some` only when the prompt's first
78    /// row starts with `/` and there is no modal overlay active.
79    /// Rebuilt by [`AppState::refresh_picker`] after every input
80    /// event; the selection is preserved across rebuilds when the
81    /// previous highlighted entry still matches the new filter.
82    pub picker: Option<SlashPicker>,
83    /// Whether the operator has opted in to screen-reader-
84    /// friendly rendering (plain ASCII, role prefixes, no dimming).
85    /// Toggled by `Ctrl+R`; persists for the session only.
86    pub screen_reader: bool,
87    /// Conversation scrollback offset in rows from the bottom.
88    /// `0` means "stuck to bottom" — new entries auto-scroll into
89    /// view. Any non-zero value means the operator has scrolled up
90    /// and should *not* be yanked back by background traffic.
91    pub log_scroll: u16,
92    /// Bounded ring of engine events sourced from the WS
93    /// subscriber's broadcast channel. Rendered by the
94    /// live-stream pane (`]` to toggle). The ring is always
95    /// populated — even when the pane is hidden — so toggling
96    /// it on immediately shows recent activity rather than a
97    /// blank surface.
98    pub event_ring: EventRing,
99    /// Whether the live-stream pane is currently visible. Toggled
100    /// by `]`. Starts hidden so the conversation pane owns the
101    /// full height on launch — operators opt in when they want
102    /// the firehose.
103    pub live_stream_visible: bool,
104    /// Whether verbose rendering is active. Today this expands
105    /// log timestamps from `HH:MM:SS` to `YYYY-MM-DD HH:MM:SS`
106    /// (honest across sessions that span midnight) and is
107    /// reserved for future "rich event detail" toggles. Toggled
108    /// by `/verbose`. Starts `false` so the conversation pane
109    /// defaults to compact wording — operators opt in when they
110    /// want the fuller trace.
111    pub verbose: bool,
112    /// Whether the daily-wrap generator is suppressed for this
113    /// session. Set by `/wrap-off`; the operator cannot make it
114    /// sticky — per ADDENDUM_A §9.1 next session's wrap runs
115    /// again. The field is session-scoped, never persisted, and
116    /// read by the binary's `run_tui` exit path (via
117    /// [`crate::AppExit::wrap_off`]) to decide whether to run
118    /// the wrap generator before returning to the shell.
119    pub wrap_off: bool,
120    /// Outstanding coaching notices waiting for the operator
121    /// to acknowledge via `/continue`. Today the coaching
122    /// stream does not emit anything into this buffer (no
123    /// engine-side coaching channel has landed), so the field
124    /// is initialized empty and `/coaching reset` is an honest
125    /// no-op on the receiving end. Kept here rather than
126    /// inside a future coaching module because the dispatcher
127    /// already sends a `coaching_reset` signal, and having the
128    /// buffer alongside the other orthogonal toggles keeps the
129    /// state contract observable in one place.
130    pub coaching_notices: Vec<String>,
131    /// Handle on the CLI-side `RateBudget` attached to the
132    /// `HttpClient`. Cloned at app construction (the handle is
133    /// an `Arc`, O(1) clone). The status-bar widget reads a
134    /// `BudgetSnapshot` from this every frame to render the
135    /// `rate:N/M` segment. `None` means no bucket was attached
136    /// (e.g. `--no-persist` + no API token + offline-only tests)
137    /// — the widget falls back to `rate:?` in that case.
138    pub rate_budget: Option<RateBudget>,
139    /// Latch for the first-live-trade ceremony (§8.4). Starts
140    /// `true` in two cases: (a) no session store is attached
141    /// (`--no-persist`) — without a store we cannot honor
142    /// "once ever", and a ceremony-on-every-run would be a
143    /// regression, so we suppress it; (b) the
144    /// [`zero_session::milestones::FIRST_LIVE_TRADE_AT`]
145    /// milestone is already set in the store — the operator
146    /// has traded before and does not need a first-trade
147    /// greeting. On ingesting a `Positions` event whose open
148    /// set is non-empty, if this field is still `false`, the
149    /// ceremony is rendered + persisted + the latch flips.
150    /// Session-scoped; never reset within a run.
151    pub first_live_trade_recorded: bool,
152    /// **M2 §4** — monotonic instant of the most recent risk-
153    /// overlay dismissal. The auto-open hook refuses to reopen a
154    /// Risk overlay within [`Self::RISK_DISMISS_COOLDOWN`] of
155    /// this timestamp *unless* the trigger strictly escalates
156    /// (L3 → L4) or a fresh guardrail threshold trips (see
157    /// [`Self::risk_overlay_last_seen_alert_pct`]). Operators
158    /// pushing through `Esc` on a steady L3 must not be
159    /// bombarded — §4 of `M2_PLAN.md`.
160    pub risk_overlay_last_dismissed_at: Option<Instant>,
161    /// **M2 §4** — the `Risk.last_drawdown_alert_pct` value
162    /// observed at the moment the operator last dismissed a
163    /// Risk overlay. Compared against the engine's current
164    /// value each tick: a change means the engine has tripped a
165    /// *new* guardrail threshold, which overrides the
166    /// dismiss-cooldown and forces the overlay back open. `None`
167    /// means "no dismissal pending" — the first open does not
168    /// consult this field.
169    pub risk_overlay_last_seen_alert_pct: Option<f64>,
170    /// **M2 §4** — the trigger the most-recently open Risk
171    /// overlay was built against. Used to detect L3 → L4
172    /// escalation inside the dismiss cooldown: if the incoming
173    /// trigger is strictly stronger (L4 beats L3; Proximity +
174    /// L3 combined is already L3's territory and does not
175    /// re-open). `None` when no overlay is currently or
176    /// recently open.
177    pub risk_overlay_last_trigger: Option<RiskOverlayTrigger>,
178}
179
180/// Why the [`ActiveOverlay::Risk`] overlay opened.
181///
182/// Populated at open-time by the auto-open hook so the widget can
183/// render context-specific copy (L3 "approaching guardrail", L4
184/// "engine halted"), and so the rate-limiter can distinguish
185/// "same trigger, operator already saw it" from "new escalation,
186/// operator must see it again within the cooldown".
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum RiskOverlayTrigger {
189    /// The operator-state snapshot the engine returned carries
190    /// `FrictionLevel::L3` (TILT + guardrail proximity) or `L4`
191    /// (TILT + halt). The level is carried so re-open logic can
192    /// detect L3 → L4 escalations and bypass the dismiss-cooldown.
193    Friction(FrictionLevel),
194    /// The engine's `Risk.drawdown_pct` is within
195    /// [`AppState::GUARDRAIL_PROXIMITY_PP`] of its
196    /// `Risk.last_drawdown_alert_pct` threshold. This fires even
197    /// without an L3 classifier verdict — the engine's own
198    /// proximity reading is authoritative for "you are about to
199    /// trip a guardrail."
200    Proximity,
201}
202
203/// Runtime representation of a live overlay. This exists as a
204/// separate enum (not just `OverlayTarget`) so overlays can carry
205/// ephemeral state — scroll offset, filter text, a timer, a
206/// typed-confirm buffer — that the input + render layers mutate
207/// without going back through dispatch.
208#[derive(Debug, Clone)]
209pub enum ActiveOverlay {
210    /// The operator-state overview. Sourced every render from
211    /// `engine.operator_state`; carries no state of its own.
212    State,
213    /// Per-coin gate verdict. Carries the full
214    /// [`zero_engine_client::Evaluation`] returned by the engine
215    /// so the overlay renders exactly what the engine said — no
216    /// local recomputation, no synthetic fields.
217    Verdict(Box<zero_engine_client::Evaluation>),
218    /// Friction pause — visible countdown and, at L2+, a typed
219    /// confirmation before a risk-increasing command runs. The
220    /// overlay owns the pending [`Command`] so the event loop can
221    /// re-dispatch via `run_bypass_friction` on completion.
222    FrictionPause(FrictionPause),
223    /// **M2 §4** risk overlay. Opens automatically when the
224    /// operator-state snapshot reports L3/L4 or the engine reports
225    /// drawdown within [`AppState::GUARDRAIL_PROXIMITY_PP`] of the
226    /// last hard-alert threshold. The overlay never owns a
227    /// pending command — unlike [`ActiveOverlay::FrictionPause`],
228    /// it is a *context* surface, not a *gate*. The operator
229    /// dismisses with any key; the auto-open hook honors a
230    /// 60 s cooldown (see [`AppState::RISK_DISMISS_COOLDOWN`])
231    /// unless the trigger strictly escalates.
232    Risk {
233        trigger: RiskOverlayTrigger,
234        /// Monotonic `Instant::now()` at the moment the overlay
235        /// opened. Not used for timing inside this struct — the
236        /// rate-limiter anchors on
237        /// `AppState::risk_overlay_last_dismissed_at` — but kept
238        /// here so tests, logs, and future "overlay has been up
239        /// for Xs" surfaces have a single source of truth.
240        opened_at: Instant,
241    },
242}
243
244/// Ordering among Risk overlay triggers used by the auto-open
245/// hook to decide whether a *new* trigger observed on the
246/// current tick warrants re-opening an already-dismissed
247/// overlay before the 60 s cooldown expires. The rule is
248/// strictly "safety-upward": L4 beats L3 beats Proximity;
249/// equal-strength triggers never bypass the cooldown.
250fn trigger_rank(t: RiskOverlayTrigger) -> u8 {
251    match t {
252        RiskOverlayTrigger::Proximity => 1,
253        RiskOverlayTrigger::Friction(FrictionLevel::L3) => 2,
254        RiskOverlayTrigger::Friction(FrictionLevel::L4) => 3,
255        // Defensive: L0..L2 should never reach the auto-open
256        // hook (poll_risk_overlay filters by L3+/Proximity),
257        // but if they do, treat them as the weakest so they
258        // cannot accidentally bypass the cooldown.
259        RiskOverlayTrigger::Friction(_) => 0,
260    }
261}
262
263fn trigger_strictly_escalates(prev: RiskOverlayTrigger, next: RiskOverlayTrigger) -> bool {
264    trigger_rank(next) > trigger_rank(prev)
265}
266
267impl ActiveOverlay {
268    /// Construct an overlay from a [`OverlayTarget`] signal
269    /// emitted by the dispatcher. Only applies to self-contained
270    /// overlays; friction overlays are built separately because
271    /// they need the full [`FrictionDecision`] + pending
272    /// [`Command`].
273    #[must_use]
274    pub fn from_target(t: OverlayTarget) -> Self {
275        match t {
276            OverlayTarget::State => Self::State,
277            OverlayTarget::Verdict(eval) => Self::Verdict(eval),
278        }
279    }
280}
281
282/// Live state of a friction-pause overlay.
283///
284/// The overlay's lifecycle in M1:
285/// 1. Dispatcher returns `FrictionDecision::Pause | TypedConfirm`
286///    plus `pending_command = Some(cmd)`.
287/// 2. [`AppState::apply_dispatch`] opens a [`FrictionPause`] with
288///    `started_at = now`.
289/// 3. The TUI render path draws a countdown + (L2) an input box.
290/// 4. Operator hits `Esc` → [`AppState::dismiss_overlay`]; the
291///    pending command is discarded.
292/// 5. L1: the event loop's tick handler polls [`is_complete`];
293///    when the pause elapses the overlay is closed and the
294///    command is re-dispatched with
295///    [`zero_commands::run_bypass_friction`].
296/// 6. L2: the operator types into `confirm_input`; when the
297///    pause has also elapsed and the buffer matches
298///    `confirm_word`, the same completion path runs. Typing the
299///    word before the pause ends does nothing — the pause is
300///    mandatory (§3, Addendum A). The widget dims the input
301///    during the pause to make this visible.
302#[derive(Debug, Clone)]
303pub struct FrictionPause {
304    pub command: Command,
305    pub level: FrictionLevel,
306    pub started_at: Instant,
307    pub pause: Duration,
308    /// The word the operator must type at L2+. `None` at L1 (the
309    /// pause alone is the gate).
310    pub confirm_word: Option<String>,
311    /// Operator's in-progress typed confirmation. Empty at open.
312    /// Only mutated at L2+; input handling ignores it at L1.
313    pub confirm_input: String,
314}
315
316/// Why a friction-pause overlay ended. Used by the event loop to
317/// decide whether to re-dispatch the pending command or drop it.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum FrictionOutcome {
320    /// Pause (and, at L2+, confirmation) is complete — re-dispatch
321    /// the command via `run_bypass_friction`.
322    Confirmed,
323    /// Operator cancelled (Esc) or the overlay is still pending.
324    /// Not complete; do nothing.
325    Pending,
326}
327
328impl FrictionPause {
329    #[must_use]
330    pub fn from_decision(
331        command: Command,
332        decision: &FrictionDecision,
333        now: Instant,
334    ) -> Option<Self> {
335        match decision {
336            // `Proceed` has no pause to render. `HardStop` is
337            // the load-bearing refusal: it never opens a
338            // typeable widget — the operator sees the advisory
339            // (see `dispatch::friction_advisory`) and reaches
340            // for `Reduces`. Collapsing them into the same
341            // early-return is the honest render.
342            FrictionDecision::Proceed | FrictionDecision::HardStop { .. } => None,
343            FrictionDecision::Pause { pause, level } => Some(Self {
344                command,
345                level: *level,
346                started_at: now,
347                pause: *pause,
348                confirm_word: None,
349                confirm_input: String::new(),
350            }),
351            FrictionDecision::TypedConfirm { pause, level } => {
352                let word = decision.confirm_word().map_or_else(
353                    || zero_commands::TYPED_CONFIRM_WORD.to_string(),
354                    std::borrow::Cow::into_owned,
355                );
356                Some(Self {
357                    command,
358                    level: *level,
359                    started_at: now,
360                    pause: *pause,
361                    confirm_word: Some(word),
362                    confirm_input: String::new(),
363                })
364            }
365            // M2 §3: L3 opens the same typed-confirm style
366            // overlay as L2, but the typed target is the
367            // `phrase` (a full sentence) rather than a single
368            // word. The widget renders the phrase inside the
369            // overlay so the operator reads it while they type.
370            FrictionDecision::WaitAndReread {
371                pause,
372                level,
373                phrase,
374            } => Some(Self {
375                command,
376                level: *level,
377                started_at: now,
378                pause: *pause,
379                confirm_word: Some(phrase.clone()),
380                confirm_input: String::new(),
381            }),
382        }
383    }
384
385    /// Remaining pause duration at `now`. Zero when the pause has
386    /// elapsed. Saturating so callers don't see negatives.
387    #[must_use]
388    pub fn remaining(&self, now: Instant) -> Duration {
389        self.pause
390            .saturating_sub(now.saturating_duration_since(self.started_at))
391    }
392
393    /// Whether the mandatory pause window has elapsed. True gates
394    /// the typed-confirm input at L2+.
395    #[must_use]
396    pub fn pause_elapsed(&self, now: Instant) -> bool {
397        self.remaining(now).is_zero()
398    }
399
400    /// Whether the operator's typed input matches the confirm
401    /// word. Case-sensitive (matches the constant in
402    /// `zero-commands`), trimmed. Always false at L1.
403    #[must_use]
404    pub fn confirm_word_matches(&self) -> bool {
405        match &self.confirm_word {
406            None => false,
407            Some(word) => self.confirm_input.trim() == word.as_str(),
408        }
409    }
410
411    /// Evaluate completion state. The return discriminates only
412    /// between "ready to re-dispatch" and "still pending" —
413    /// cancellation is a separate path (the overlay is simply
414    /// dismissed). At L1 the pause alone completes the gate. At
415    /// L2+ both the pause and the typed confirmation are required.
416    #[must_use]
417    pub fn outcome(&self, now: Instant) -> FrictionOutcome {
418        if !self.pause_elapsed(now) {
419            return FrictionOutcome::Pending;
420        }
421        match self.confirm_word {
422            None => FrictionOutcome::Confirmed,
423            Some(_) => {
424                if self.confirm_word_matches() {
425                    FrictionOutcome::Confirmed
426                } else {
427                    FrictionOutcome::Pending
428                }
429            }
430        }
431    }
432
433    /// Append a character to the confirm buffer. No-op at L1 and
434    /// while the pause is still running — the widget dims the
435    /// field to make this legible. Max length is a tight bound so
436    /// a run-away Repeat cannot blow memory.
437    pub fn push_char(&mut self, c: char, now: Instant) {
438        if self.confirm_word.is_none() || !self.pause_elapsed(now) {
439            return;
440        }
441        if self.confirm_input.len() < 32 {
442            self.confirm_input.push(c);
443        }
444    }
445
446    /// Delete the last character from the confirm buffer. Same
447    /// gating as [`push_char`].
448    pub fn pop_char(&mut self, now: Instant) {
449        if self.confirm_word.is_none() || !self.pause_elapsed(now) {
450            return;
451        }
452        self.confirm_input.pop();
453    }
454}
455
456impl AppState {
457    /// New state with persistence disabled.
458    #[must_use]
459    pub fn new(engine: Arc<RwLock<EngineState>>) -> Self {
460        Self::new_with_sink(engine, None)
461    }
462
463    /// New state, optionally persisted.
464    #[must_use]
465    pub fn new_with_sink(engine: Arc<RwLock<EngineState>>, sink: Option<SessionSink>) -> Self {
466        // Pre-seed the first-live-trade latch based on whether
467        // the persistent store has already recorded the
468        // milestone. The two reasons for defaulting to `true`
469        // (suppressed) are spelled out in the field's docs.
470        let first_live_trade_recorded = match &sink {
471            None => true,
472            Some(s) => matches!(
473                s.store()
474                    .get_milestone(zero_session::milestones::FIRST_LIVE_TRADE_AT),
475                Ok(Some(_))
476            ),
477        };
478
479        let mut s = Self {
480            mode: Mode::default(),
481            log: ConversationLog::with_capacity(2048),
482            prompt: PromptBuffer::new(),
483            theme: Theme::default(),
484            engine,
485            should_quit: false,
486            pending_input: None,
487            sink,
488            overlay: None,
489            picker: None,
490            screen_reader: false,
491            log_scroll: 0,
492            event_ring: EventRing::new(),
493            live_stream_visible: false,
494            verbose: false,
495            wrap_off: false,
496            coaching_notices: Vec::new(),
497            rate_budget: None,
498            first_live_trade_recorded,
499            risk_overlay_last_dismissed_at: None,
500            risk_overlay_last_seen_alert_pct: None,
501            risk_overlay_last_trigger: None,
502        };
503        s.push(LogEntry::new(
504            EntryKind::System,
505            "zero — Ctrl+1..5 switch modes, Ctrl+C or /quit exits, /help for commands.",
506        ));
507        // M2 §4: prime the risk overlay on construction. If the
508        // engine mirror already carries an L3+/halted snapshot
509        // (e.g. session attach after an incident, or tests that
510        // seed the mirror up-front), the overlay must be visible
511        // from frame zero — operators reconnecting into a halted
512        // engine must land inside the context surface, not on an
513        // empty prompt.
514        s.poll_risk_overlay(Instant::now());
515        s
516    }
517
518    /// Append without persisting. Used during replay so rehydrated
519    /// rows are not rewritten.
520    pub fn append_silent(&mut self, entry: LogEntry) {
521        self.log.push(entry);
522    }
523
524    /// Append + persist. All runtime-generated entries flow here.
525    pub fn push(&mut self, entry: LogEntry) {
526        if let Some(s) = &self.sink {
527            s.record(&entry);
528        }
529        self.log.push(entry);
530    }
531
532    pub fn push_system(&mut self, text: impl Into<String>) {
533        self.push(LogEntry::new(EntryKind::System, text));
534    }
535
536    /// Submit the current prompt. Echoes the typed line into the
537    /// log and queues it for async dispatch by the event loop.
538    /// Short-circuits whitespace-only input with no log noise.
539    ///
540    /// Submission clears the picker and re-attaches the
541    /// conversation pane to the bottom: the operator sees their
542    /// command's output without having to hit PageDown first.
543    pub fn submit_prompt(&mut self) {
544        let Some(line) = self.prompt.take() else {
545            return;
546        };
547        if line.trim().is_empty() {
548            return;
549        }
550        // Echo the submitted line; newlines render as literal
551        // `\n` for compactness in the scrollback so a 6-line
552        // multi-line prompt does not eat six scrollback rows.
553        let echo = if line.contains('\n') {
554            line.replace('\n', " ↵ ")
555        } else {
556            line.clone()
557        };
558        self.push(LogEntry::new(EntryKind::Prompt, format!("> {echo}")));
559        self.pending_input = Some(line);
560        self.picker = None;
561        self.scroll_log_to_bottom();
562    }
563
564    /// Apply a [`DispatchOutput`] — append lines, switch modes,
565    /// flip flags. Called by the event loop after `dispatch` returns.
566    pub fn apply_dispatch(&mut self, out: DispatchOutput) {
567        if out.clear_log {
568            self.log = ConversationLog::with_capacity(2048);
569        }
570        for line in out.lines {
571            let (kind, text) = match line {
572                OutputLine::System(t) => (EntryKind::System, t),
573                OutputLine::Command(t) => (EntryKind::Command, t),
574                OutputLine::Warn(t) => (EntryKind::Warn, t),
575                OutputLine::Alert(t) => (EntryKind::Alert, t),
576            };
577            self.push(LogEntry::new(kind, text));
578        }
579        // Replay lines bypass the sink so `/resume` does not
580        // double-persist a prior session into the current one.
581        // We preserve the original `at_ms` so rendered "age"
582        // readings stay truthful — a freshly stamped clock on
583        // replay would silently rewrite the operator's history.
584        for rl in out.replay_lines {
585            let kind = replay_kind_to_entry(rl.kind);
586            let entry = if let Some(ts) =
587                chrono::DateTime::<chrono::Utc>::from_timestamp_millis(rl.at_ms)
588            {
589                LogEntry::new(kind, rl.text).at(ts)
590            } else {
591                LogEntry::new(kind, rl.text)
592            };
593            self.append_silent(entry);
594        }
595        if let Some(target) = out.mode_change {
596            self.mode = mode_from_target(target);
597        }
598        if let Some(ov) = out.show_overlay {
599            self.overlay = Some(ActiveOverlay::from_target(ov));
600        } else if out.dismiss_overlay {
601            // Explicit dismissal signal from the dispatcher.
602            // `show_overlay` wins above — opening and closing in
603            // the same tick would be contradictory and the
604            // open-path data is the caller's real intent.
605            // Otherwise this is how `/clear` and empty-evaluate
606            // errors tear down a stale modal so the operator is
607            // not left staring at an unrelated verdict card.
608            self.overlay = None;
609        }
610        // If the dispatcher returned a non-Proceed friction decision
611        // *with* a pending command, open the friction-pause overlay.
612        // This is the only path that can produce a FrictionPause —
613        // the dispatcher owns the Label → level → decision mapping
614        // and the TUI just renders the gate.
615        if let (Some(decision), Some(cmd)) = (out.friction, out.pending_command)
616            && !matches!(decision, FrictionDecision::Proceed)
617            && let Some(fp) = FrictionPause::from_decision(cmd, &decision, Instant::now())
618        {
619            self.overlay = Some(ActiveOverlay::FrictionPause(fp));
620        }
621        if out.quit {
622            self.should_quit = true;
623        }
624        // Verbose toggle — dispatcher resolves `/verbose toggle`
625        // into an absolute target, so applying here is a plain
626        // assignment. We do not emit a duplicate confirmation
627        // line; the dispatcher already pushed "verbose on/off"
628        // into `lines` above.
629        if let Some(v) = out.verbose_toggle {
630            self.verbose = v;
631        }
632        // `/wrap-off` resolves to an absolute target at dispatch
633        // time, same contract shape as verbose. We do not run
634        // the wrap generator here — the flag is only consumed
635        // at session-finalize time, which is not yet wired.
636        // The assignment alone carries the operator's intent
637        // across the boundary; the generator will pick it up
638        // when it lands.
639        if let Some(w) = out.wrap_off_toggle {
640            self.wrap_off = w;
641        }
642        // `/coaching reset` — empty the buffer. Today nothing
643        // pushes into `coaching_notices`, so this is a well-
644        // shaped no-op on the receiving side: the contract is
645        // stable for the eventual coaching stream wiring.
646        if out.coaching_reset {
647            self.coaching_notices.clear();
648        }
649    }
650
651    /// Dismiss the active overlay, if any. Idempotent. At a
652    /// friction-pause overlay this is the "cancel" path — the
653    /// pending command is dropped and never re-dispatched.
654    ///
655    /// For [`ActiveOverlay::Risk`], this stamps
656    /// [`Self::risk_overlay_last_dismissed_at`] with
657    /// `Instant::now()` and snapshots the engine's current
658    /// `last_drawdown_alert_pct` so the auto-open hook can
659    /// enforce the 60 s cooldown unless the trigger strictly
660    /// escalates. The snapshot is taken at dismiss time (not at
661    /// open time) so a fresh guardrail trip that happens *while*
662    /// the overlay is visible still counts — the operator sees
663    /// the new threshold in the current overlay and the next
664    /// reopen fires only for the *next* fresh trip.
665    pub fn dismiss_overlay(&mut self) {
666        self.dismiss_overlay_at(Instant::now());
667    }
668
669    /// Test-seam variant of [`Self::dismiss_overlay`] that takes
670    /// the "now" instant explicitly. Production uses `Instant::now`;
671    /// tests pin a fixed monotonic anchor to verify cooldown
672    /// arithmetic deterministically.
673    pub fn dismiss_overlay_at(&mut self, now: Instant) {
674        if matches!(self.overlay, Some(ActiveOverlay::Risk { .. })) {
675            self.risk_overlay_last_dismissed_at = Some(now);
676            let alert_pct = self
677                .engine
678                .read()
679                .risk
680                .as_ref()
681                .and_then(|r| r.value.last_drawdown_alert_pct);
682            self.risk_overlay_last_seen_alert_pct = alert_pct;
683        }
684        self.overlay = None;
685    }
686
687    /// Monotonic cooldown between a dismissed Risk overlay and
688    /// the auto-open hook re-opening one. 60 s per M2 §4. Does
689    /// not apply when the new trigger strictly escalates
690    /// (L3 → L4) or when the engine reports a fresh guardrail
691    /// threshold (`last_drawdown_alert_pct` changed value since
692    /// the dismiss).
693    pub const RISK_DISMISS_COOLDOWN: Duration = Duration::from_secs(60);
694
695    /// Proximity window, in *percentage points* of drawdown,
696    /// that triggers the Risk overlay via
697    /// [`RiskOverlayTrigger::Proximity`]. Strictly smaller than
698    /// `zero_operator_state::friction::RiskContext::PROXIMITY_PCT`
699    /// (1.0 pp) because the overlay is an *earlier* nudge than
700    /// the L3 friction escalation — operators should see the
701    /// context surface before they hit a typed-reread gate.
702    pub const GUARDRAIL_PROXIMITY_PP: f64 = 0.5;
703
704    /// **M2 §4** auto-open hook. Called once per TUI tick with
705    /// a monotonic `now`. Inspects the engine mirror's
706    /// operator-state snapshot and `Risk` block; opens
707    /// [`ActiveOverlay::Risk`] when either trigger fires and
708    /// the rate-limiter permits it. Never closes an overlay —
709    /// that is the operator's job (via [`Self::dismiss_overlay`]).
710    ///
711    /// Precedence of triggers:
712    /// 1. Friction L4 (halted) — always opens / stays open.
713    /// 2. Friction L3 (TILT + proximity) — opens unless cooldown.
714    /// 3. Drawdown proximity ≤ `GUARDRAIL_PROXIMITY_PP` — opens
715    ///    unless cooldown.
716    ///
717    /// Rules are layered so a single tick that satisfies both
718    /// L3 and Proximity surfaces as `Friction(L3)` (the
719    /// higher-signal cause), and an L3 → L4 escalation inside
720    /// the cooldown overrides it (safety beats user comfort).
721    ///
722    /// Invariant: this hook never touches a non-Risk overlay.
723    /// If the operator is inside `/state`, `/pool`, or a
724    /// friction pause, the Risk overlay defers until that
725    /// overlay closes. The guardrail signal does not vanish —
726    /// it re-fires on the next tick.
727    pub fn poll_risk_overlay(&mut self, now: Instant) {
728        // Do not stomp on another overlay. Operator-owned
729        // surfaces keep focus; Risk re-evaluates on the next tick.
730        if matches!(
731            self.overlay,
732            Some(
733                ActiveOverlay::State | ActiveOverlay::FrictionPause(_) | ActiveOverlay::Verdict(_)
734            )
735        ) {
736            return;
737        }
738
739        let (trigger, current_alert_pct) = {
740            let eng = self.engine.read();
741            let friction = eng.operator_state.as_ref().map(|s| s.value.friction);
742            let (drawdown, alert) = eng.risk.as_ref().map_or((None, None), |r| {
743                (r.value.drawdown_pct, r.value.last_drawdown_alert_pct)
744            });
745            let proximity_hit = match (drawdown, alert) {
746                (Some(d), Some(a)) => (d - a).abs() <= Self::GUARDRAIL_PROXIMITY_PP,
747                _ => false,
748            };
749            let trigger = match friction {
750                Some(FrictionLevel::L4) => Some(RiskOverlayTrigger::Friction(FrictionLevel::L4)),
751                Some(FrictionLevel::L3) => Some(RiskOverlayTrigger::Friction(FrictionLevel::L3)),
752                _ if proximity_hit => Some(RiskOverlayTrigger::Proximity),
753                _ => None,
754            };
755            (trigger, alert)
756        };
757
758        let Some(trigger) = trigger else {
759            return;
760        };
761
762        // If a Risk overlay is already up, consider upgrading
763        // its trigger on escalation (L3 → L4). Never *downgrade*.
764        if let Some(ActiveOverlay::Risk {
765            trigger: current, ..
766        }) = self.overlay
767        {
768            if trigger_strictly_escalates(current, trigger) {
769                self.overlay = Some(ActiveOverlay::Risk {
770                    trigger,
771                    opened_at: now,
772                });
773                self.risk_overlay_last_trigger = Some(trigger);
774            }
775            return;
776        }
777
778        // Fresh open — honor the dismiss cooldown unless the
779        // engine tripped a new guardrail threshold (distinct
780        // `last_drawdown_alert_pct`) or the new trigger strictly
781        // escalates the last-seen trigger.
782        if let Some(dismissed_at) = self.risk_overlay_last_dismissed_at {
783            let within_cooldown = now.duration_since(dismissed_at) < Self::RISK_DISMISS_COOLDOWN;
784            let fresh_alert = match (current_alert_pct, self.risk_overlay_last_seen_alert_pct) {
785                (Some(cur), Some(prev)) => (cur - prev).abs() > f64::EPSILON,
786                (Some(_), None) | (None, Some(_)) => true,
787                (None, None) => false,
788            };
789            let escalates = self
790                .risk_overlay_last_trigger
791                .is_some_and(|prev| trigger_strictly_escalates(prev, trigger));
792            if within_cooldown && !fresh_alert && !escalates {
793                return;
794            }
795        }
796
797        self.overlay = Some(ActiveOverlay::Risk {
798            trigger,
799            opened_at: now,
800        });
801        self.risk_overlay_last_trigger = Some(trigger);
802    }
803
804    /// Rebuild [`Self::picker`] from the current prompt. Picker
805    /// is suppressed whenever an overlay is active (the operator
806    /// is inside a modal; no ambient popup on top of it) or the
807    /// prompt's first row does not start with `/`.
808    ///
809    /// Selection is preserved across rebuilds when the previously
810    /// highlighted command name still appears in the new match
811    /// list. This keeps `Up/Down` + typing interleavable without
812    /// the selection jumping back to the top on every keystroke.
813    pub fn refresh_picker(&mut self) {
814        if self.overlay.is_some() {
815            self.picker = None;
816            return;
817        }
818        let first = self
819            .prompt
820            .line(0)
821            .map(|chars| chars.iter().collect::<String>())
822            .unwrap_or_default();
823        let prev_name = self
824            .picker
825            .as_ref()
826            .and_then(SlashPicker::selected)
827            .map(|m| m.info.name);
828        let new_picker = SlashPicker::from_prompt_line(&first);
829        self.picker = new_picker.map(|mut p| {
830            if let Some(name) = prev_name
831                && let Some(i) = p.matches().iter().position(|m| m.info.name == name)
832            {
833                for _ in 0..i {
834                    p.select_next();
835                }
836            }
837            p
838        });
839    }
840
841    /// Scroll the conversation pane up by `rows` (toward older
842    /// entries). A non-zero offset detaches the pane from the
843    /// bottom so new entries no longer auto-scroll.
844    pub fn scroll_log_up(&mut self, rows: u16) {
845        self.log_scroll = self.log_scroll.saturating_add(rows);
846    }
847
848    /// Scroll the conversation pane down by `rows` (toward the
849    /// newest entry). Hitting 0 re-attaches to the bottom.
850    pub fn scroll_log_down(&mut self, rows: u16) {
851        self.log_scroll = self.log_scroll.saturating_sub(rows);
852    }
853
854    /// Re-attach the conversation pane to the newest entry.
855    /// Called on prompt submit so the operator sees their command
856    /// output without having to scroll back manually.
857    pub fn scroll_log_to_bottom(&mut self) {
858        self.log_scroll = 0;
859    }
860
861    /// Toggle screen-reader mode (`Ctrl+R`). Returns the new
862    /// value so the caller can log it.
863    pub fn toggle_screen_reader(&mut self) -> bool {
864        self.screen_reader = !self.screen_reader;
865        self.screen_reader
866    }
867
868    /// Toggle the live-stream pane (`]`). Returns the new
869    /// visibility so callers can echo a confirmation line when
870    /// the pane is hidden and the operator needs the hint.
871    pub fn toggle_live_stream(&mut self) -> bool {
872        self.live_stream_visible = !self.live_stream_visible;
873        self.live_stream_visible
874    }
875
876    /// Append a decoded engine event to the live-stream ring.
877    /// Called by the event loop's broadcast-receiver arm.
878    pub fn record_engine_event(&mut self, evt: EngineEvent) {
879        self.maybe_fire_first_live_trade_ceremony(&evt);
880        self.event_ring.push_event(evt);
881    }
882
883    /// Record an engine event with an explicit wall-clock time.
884    /// Exists for snapshot tests that need to pin the rendered
885    /// timestamp regardless of when the test runs.
886    pub fn record_engine_event_at(&mut self, evt: EngineEvent, ts: chrono::DateTime<chrono::Utc>) {
887        self.maybe_fire_first_live_trade_ceremony(&evt);
888        self.event_ring.push_event_at(evt, ts);
889    }
890
891    /// First-live-trade ceremony (§8.4).
892    ///
893    /// The engine does not today emit a typed "first fill" or
894    /// "decision executed" event; the closest thing the CLI
895    /// can observe is a `Positions` push whose `items` is
896    /// non-empty. We treat that as the signal: if the
897    /// `FIRST_LIVE_TRADE_AT` milestone is unset, this is the
898    /// first open position this persistent store has ever
899    /// seen, and we render the ceremony + persist the
900    /// milestone.
901    ///
902    /// Honest caveats the implementation respects:
903    /// - **Pre-existing positions on first install** will
904    ///   fire the ceremony on the first `Positions` push.
905    ///   That is the correct behavior from the CLI's point of
906    ///   view: "first trade this tool has ever witnessed."
907    /// - **No-persist mode** suppresses the ceremony
908    ///   unconditionally; without a milestone store a
909    ///   ceremony every run is worse than silence.
910    /// - **Milestone write failure** still flips the in-
911    ///   memory latch so the ceremony does not loop in this
912    ///   session; the next session rechecks the store and
913    ///   will refire if the milestone never landed — better
914    ///   one duplicate ceremony than one infinite loop.
915    fn maybe_fire_first_live_trade_ceremony(&mut self, evt: &EngineEvent) {
916        if self.first_live_trade_recorded {
917            return;
918        }
919        let EngineEvent::Positions(p) = evt else {
920            return;
921        };
922        if p.items.is_empty() {
923            return;
924        }
925        self.first_live_trade_recorded = true;
926
927        // Persist the milestone first so a crash during the
928        // ceremony push still records "this happened." The
929        // timestamp is RFC-3339 to match the other milestone
930        // values (`WELCOME_SHOWN`, `LAST_DAILY_WRAP_AT`).
931        if let Some(sink) = &self.sink {
932            let now = chrono::Utc::now().to_rfc3339();
933            if let Err(e) = sink
934                .store()
935                .set_milestone(zero_session::milestones::FIRST_LIVE_TRADE_AT, &now)
936            {
937                tracing::warn!(err = %e, "first-live-trade milestone write failed");
938            }
939        }
940
941        // Medically honest ceremony copy — no confetti, no
942        // "congratulations", no gamified counter. Three
943        // system lines: what happened, what it means, what to
944        // do next. Reads the same way the welcome reads.
945        for text in CEREMONY_LINES {
946            self.push(LogEntry::new(EntryKind::System, *text));
947        }
948    }
949
950    /// Record that the broadcast receiver lagged `skipped`
951    /// frames. A synthetic marker lands in the ring so the
952    /// live-stream pane can surface the drop honestly instead
953    /// of letting the firehose look calm after a burst.
954    pub fn record_events_lagged(&mut self, skipped: u64) {
955        self.event_ring.push_lagged(skipped);
956    }
957
958    /// Deterministic sibling of [`Self::record_events_lagged`].
959    pub fn record_events_lagged_at(&mut self, skipped: u64, ts: chrono::DateTime<chrono::Utc>) {
960        self.event_ring.push_lagged_at(skipped, ts);
961    }
962
963    /// If a friction-pause overlay is currently open and its
964    /// outcome is `Confirmed` at `now`, take the pending command
965    /// and close the overlay. Returns `Some(cmd)` — the caller
966    /// (event loop) is responsible for dispatching it via
967    /// [`zero_commands::run_bypass_friction`] and applying the
968    /// resulting output. Returns `None` when there is no friction
969    /// overlay, the outcome is still pending, or the overlay is
970    /// not a friction variant.
971    #[must_use]
972    pub fn take_confirmed_friction_command(&mut self, now: Instant) -> Option<Command> {
973        let confirmed = match self.overlay.as_ref() {
974            Some(ActiveOverlay::FrictionPause(fp)) => {
975                matches!(fp.outcome(now), FrictionOutcome::Confirmed)
976            }
977            _ => false,
978        };
979        if !confirmed {
980            return None;
981        }
982        match self.overlay.take() {
983            Some(ActiveOverlay::FrictionPause(fp)) => Some(fp.command),
984            _ => None,
985        }
986    }
987}
988
989fn mode_from_target(t: ModeTarget) -> Mode {
990    match t {
991        ModeTarget::Conversation => Mode::Conversation,
992        ModeTarget::Positions => Mode::Positions,
993        ModeTarget::Decisions => Mode::Decisions,
994        ModeTarget::Heat => Mode::Heat,
995        ModeTarget::Cockpit => Mode::Cockpit,
996    }
997}
998
999/// Translate the dispatcher's replay-kind into the TUI's log
1000/// entry kind. Kept as a one-liner next to [`mode_from_target`]
1001/// so all dispatch-shape translations live in one place.
1002const fn replay_kind_to_entry(k: ReplayKind) -> EntryKind {
1003    match k {
1004        ReplayKind::Prompt => EntryKind::Prompt,
1005        ReplayKind::System => EntryKind::System,
1006        ReplayKind::Command => EntryKind::Command,
1007        ReplayKind::Warn => EntryKind::Warn,
1008        ReplayKind::Alert => EntryKind::Alert,
1009    }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use zero_commands::OverlayTarget;
1016
1017    fn mk() -> AppState {
1018        AppState::new(EngineState::shared())
1019    }
1020
1021    fn is_state(ov: Option<&ActiveOverlay>) -> bool {
1022        matches!(ov, Some(ActiveOverlay::State))
1023    }
1024
1025    fn is_friction(ov: Option<&ActiveOverlay>) -> bool {
1026        matches!(ov, Some(ActiveOverlay::FrictionPause(_)))
1027    }
1028
1029    #[test]
1030    fn apply_dispatch_honors_verbose_toggle_absolute() {
1031        // Dispatcher has already resolved `toggle` to an
1032        // absolute target before apply_dispatch sees it, so
1033        // the assignment is trivial — but this test pins the
1034        // contract in case a future contributor "simplifies"
1035        // it into a flip that would diverge from what the
1036        // dispatcher confirmed in the log line.
1037        let mut s = mk();
1038        assert!(!s.verbose);
1039        s.apply_dispatch(DispatchOutput {
1040            verbose_toggle: Some(true),
1041            ..Default::default()
1042        });
1043        assert!(s.verbose);
1044        // Setting to the same value must be a no-op without
1045        // side effects.
1046        s.apply_dispatch(DispatchOutput {
1047            verbose_toggle: Some(true),
1048            ..Default::default()
1049        });
1050        assert!(s.verbose);
1051        s.apply_dispatch(DispatchOutput {
1052            verbose_toggle: Some(false),
1053            ..Default::default()
1054        });
1055        assert!(!s.verbose);
1056        // None means "leave alone" — the flag must survive
1057        // unrelated dispatches.
1058        s.verbose = true;
1059        s.apply_dispatch(DispatchOutput::default());
1060        assert!(s.verbose);
1061    }
1062
1063    #[test]
1064    fn apply_dispatch_honors_wrap_off_absolute_and_leaves_unrelated_alone() {
1065        // /wrap-off must flip the flag to the dispatcher-
1066        // resolved target exactly. Unrelated dispatches must
1067        // not clobber it — otherwise the operator's opt-out
1068        // would silently re-arm mid-session.
1069        let mut s = mk();
1070        assert!(!s.wrap_off);
1071        s.apply_dispatch(DispatchOutput {
1072            wrap_off_toggle: Some(true),
1073            ..Default::default()
1074        });
1075        assert!(s.wrap_off);
1076        s.apply_dispatch(DispatchOutput::default());
1077        assert!(s.wrap_off, "unrelated dispatch must not clear the opt-out");
1078    }
1079
1080    #[test]
1081    fn apply_dispatch_honors_coaching_reset_signal() {
1082        // Even when the buffer is empty today, the contract is
1083        // the thing we are testing: signal => clear. A future
1084        // coaching-stream push must land in the same buffer
1085        // this clear empties. Seeding the buffer directly
1086        // simulates that world.
1087        let mut s = mk();
1088        s.coaching_notices.push("loss-reaction < 2m".into());
1089        s.coaching_notices.push("velocity 2x baseline".into());
1090        s.apply_dispatch(DispatchOutput {
1091            coaching_reset: true,
1092            ..Default::default()
1093        });
1094        assert!(s.coaching_notices.is_empty());
1095        // Negative control — an unrelated dispatch with
1096        // coaching_reset=false must leave the buffer alone.
1097        s.coaching_notices.push("fresh notice".into());
1098        s.apply_dispatch(DispatchOutput::default());
1099        assert_eq!(s.coaching_notices.len(), 1);
1100    }
1101
1102    #[test]
1103    fn apply_dispatch_sets_overlay_from_show_overlay() {
1104        let mut s = mk();
1105        assert!(s.overlay.is_none());
1106        let out = DispatchOutput {
1107            show_overlay: Some(OverlayTarget::State),
1108            ..Default::default()
1109        };
1110        s.apply_dispatch(out);
1111        assert!(is_state(s.overlay.as_ref()));
1112    }
1113
1114    #[test]
1115    fn dismiss_overlay_is_idempotent() {
1116        let mut s = mk();
1117        s.overlay = Some(ActiveOverlay::State);
1118        s.dismiss_overlay();
1119        assert!(s.overlay.is_none());
1120        // second call does nothing bad
1121        s.dismiss_overlay();
1122        assert!(s.overlay.is_none());
1123    }
1124
1125    #[test]
1126    fn apply_dispatch_preserves_overlay_when_not_signaled() {
1127        // A dispatch with no show_overlay must not clobber an
1128        // existing overlay — that would make any follow-up command
1129        // typed behind the modal accidentally close it, which
1130        // breaks the "any key dismisses" contract.
1131        let mut s = mk();
1132        s.overlay = Some(ActiveOverlay::State);
1133        let out = DispatchOutput {
1134            mode_change: Some(ModeTarget::Heat),
1135            ..Default::default()
1136        };
1137        s.apply_dispatch(out);
1138        assert!(
1139            is_state(s.overlay.as_ref()),
1140            "unrelated dispatches must not close the overlay"
1141        );
1142    }
1143
1144    #[test]
1145    fn apply_dispatch_honors_explicit_dismiss_overlay() {
1146        // `/clear` and `/evaluate`'s failure paths set
1147        // `dismiss_overlay = true` to tear down a stale modal so
1148        // new output is visible. The TUI must honor that signal.
1149        let mut s = mk();
1150        s.overlay = Some(ActiveOverlay::State);
1151        let out = DispatchOutput {
1152            dismiss_overlay: true,
1153            ..Default::default()
1154        };
1155        s.apply_dispatch(out);
1156        assert!(
1157            s.overlay.is_none(),
1158            "explicit dismiss_overlay must clear the overlay"
1159        );
1160    }
1161
1162    #[test]
1163    fn apply_dispatch_show_overlay_wins_over_dismiss() {
1164        // If a single dispatch set both flags we prefer the
1165        // open path — the dispatcher's *data* is the reason the
1166        // command ran, and opening-then-immediately-closing in the
1167        // same tick would be indistinguishable from a bug.
1168        let mut s = mk();
1169        s.overlay = Some(ActiveOverlay::State);
1170        let out = DispatchOutput {
1171            show_overlay: Some(OverlayTarget::State),
1172            dismiss_overlay: true,
1173            ..Default::default()
1174        };
1175        s.apply_dispatch(out);
1176        assert!(
1177            is_state(s.overlay.as_ref()),
1178            "show_overlay must win when both are set"
1179        );
1180    }
1181
1182    #[test]
1183    fn apply_dispatch_opens_friction_overlay_on_l1_pause() {
1184        let mut s = mk();
1185        let out = DispatchOutput {
1186            friction: Some(FrictionDecision::Pause {
1187                pause: Duration::from_secs(3),
1188                level: FrictionLevel::L1,
1189            }),
1190            pending_command: Some(Command::Execute),
1191            ..Default::default()
1192        };
1193        s.apply_dispatch(out);
1194        assert!(
1195            is_friction(s.overlay.as_ref()),
1196            "L1 should open friction overlay"
1197        );
1198        if let Some(ActiveOverlay::FrictionPause(fp)) = &s.overlay {
1199            assert_eq!(fp.level, FrictionLevel::L1);
1200            assert!(fp.confirm_word.is_none());
1201        }
1202    }
1203
1204    #[test]
1205    fn apply_dispatch_opens_friction_overlay_on_l2_typed_confirm() {
1206        let mut s = mk();
1207        let out = DispatchOutput {
1208            friction: Some(FrictionDecision::TypedConfirm {
1209                pause: Duration::from_secs(10),
1210                level: FrictionLevel::L2,
1211            }),
1212            pending_command: Some(Command::Execute),
1213            ..Default::default()
1214        };
1215        s.apply_dispatch(out);
1216        if let Some(ActiveOverlay::FrictionPause(fp)) = &s.overlay {
1217            assert_eq!(fp.level, FrictionLevel::L2);
1218            assert_eq!(fp.confirm_word.as_deref(), Some("execute"));
1219        } else {
1220            panic!("expected FrictionPause, got {:?}", s.overlay);
1221        }
1222    }
1223
1224    #[test]
1225    fn apply_dispatch_without_pending_command_does_not_open_friction() {
1226        // Defensive: if a dispatcher change drops pending_command
1227        // by mistake, we must not crash or open an empty overlay.
1228        let mut s = mk();
1229        let out = DispatchOutput {
1230            friction: Some(FrictionDecision::Pause {
1231                pause: Duration::from_secs(3),
1232                level: FrictionLevel::L1,
1233            }),
1234            pending_command: None,
1235            ..Default::default()
1236        };
1237        s.apply_dispatch(out);
1238        assert!(s.overlay.is_none());
1239    }
1240
1241    #[test]
1242    fn l1_pause_completes_after_duration_elapses() {
1243        let now = Instant::now();
1244        let fp = FrictionPause {
1245            command: Command::Execute,
1246            level: FrictionLevel::L1,
1247            started_at: now,
1248            pause: Duration::from_secs(3),
1249            confirm_word: None,
1250            confirm_input: String::new(),
1251        };
1252        assert_eq!(fp.outcome(now), FrictionOutcome::Pending);
1253        assert_eq!(
1254            fp.outcome(now + Duration::from_millis(2_999)),
1255            FrictionOutcome::Pending,
1256        );
1257        assert_eq!(
1258            fp.outcome(now + Duration::from_secs(3)),
1259            FrictionOutcome::Confirmed,
1260        );
1261    }
1262
1263    #[test]
1264    fn l2_requires_both_pause_and_word() {
1265        let now = Instant::now();
1266        let mut fp = FrictionPause {
1267            command: Command::Execute,
1268            level: FrictionLevel::L2,
1269            started_at: now,
1270            pause: Duration::from_secs(10),
1271            confirm_word: Some("execute".into()),
1272            confirm_input: String::new(),
1273        };
1274        let past_pause = now + Duration::from_secs(10);
1275
1276        // Still within pause — typing is rejected.
1277        for c in "execute".chars() {
1278            fp.push_char(c, now + Duration::from_secs(1));
1279        }
1280        assert!(
1281            fp.confirm_input.is_empty(),
1282            "input during mandatory pause must be ignored"
1283        );
1284        assert_eq!(fp.outcome(past_pause), FrictionOutcome::Pending);
1285
1286        // After pause — typing is accepted.
1287        for c in "exec".chars() {
1288            fp.push_char(c, past_pause);
1289        }
1290        assert_eq!(fp.confirm_input, "exec");
1291        assert_eq!(fp.outcome(past_pause), FrictionOutcome::Pending);
1292
1293        // Wrong word never completes.
1294        assert!(!fp.confirm_word_matches());
1295
1296        // Complete the word — now confirmed.
1297        for c in "ute".chars() {
1298            fp.push_char(c, past_pause);
1299        }
1300        assert_eq!(fp.confirm_input, "execute");
1301        assert!(fp.confirm_word_matches());
1302        assert_eq!(fp.outcome(past_pause), FrictionOutcome::Confirmed);
1303
1304        // Backspace — no longer confirmed.
1305        fp.pop_char(past_pause);
1306        assert_eq!(fp.outcome(past_pause), FrictionOutcome::Pending);
1307    }
1308
1309    #[test]
1310    fn take_confirmed_command_consumes_overlay() {
1311        let now = Instant::now();
1312        let fp = FrictionPause {
1313            command: Command::Execute,
1314            level: FrictionLevel::L1,
1315            started_at: now,
1316            pause: Duration::from_secs(0),
1317            confirm_word: None,
1318            confirm_input: String::new(),
1319        };
1320        let mut s = mk();
1321        s.overlay = Some(ActiveOverlay::FrictionPause(fp));
1322        let taken = s.take_confirmed_friction_command(now);
1323        assert_eq!(taken, Some(Command::Execute));
1324        assert!(s.overlay.is_none());
1325        // Second call is a no-op — the overlay is already gone.
1326        assert!(s.take_confirmed_friction_command(now).is_none());
1327    }
1328
1329    #[test]
1330    fn take_confirmed_leaves_pending_overlay_in_place() {
1331        let now = Instant::now();
1332        let fp = FrictionPause {
1333            command: Command::Execute,
1334            level: FrictionLevel::L1,
1335            started_at: now,
1336            pause: Duration::from_secs(3),
1337            confirm_word: None,
1338            confirm_input: String::new(),
1339        };
1340        let mut s = mk();
1341        s.overlay = Some(ActiveOverlay::FrictionPause(fp));
1342        let taken = s.take_confirmed_friction_command(now + Duration::from_secs(1));
1343        assert_eq!(taken, None, "still within pause window");
1344        assert!(matches!(s.overlay, Some(ActiveOverlay::FrictionPause(_))));
1345    }
1346
1347    #[test]
1348    fn take_confirmed_ignores_non_friction_overlays() {
1349        let mut s = mk();
1350        s.overlay = Some(ActiveOverlay::State);
1351        let taken = s.take_confirmed_friction_command(Instant::now());
1352        assert!(taken.is_none());
1353        assert!(is_state(s.overlay.as_ref()));
1354    }
1355
1356    #[test]
1357    fn live_stream_starts_hidden_and_toggles_round_trip() {
1358        let mut s = mk();
1359        assert!(
1360            !s.live_stream_visible,
1361            "new state must start with the pane hidden"
1362        );
1363        let on = s.toggle_live_stream();
1364        assert!(on && s.live_stream_visible);
1365        let off = s.toggle_live_stream();
1366        assert!(!off && !s.live_stream_visible);
1367    }
1368
1369    #[test]
1370    fn record_engine_event_appends_to_ring() {
1371        let mut s = mk();
1372        assert_eq!(s.event_ring.len(), 0);
1373        s.record_engine_event(EngineEvent::Heartbeat(chrono::Utc::now()));
1374        s.record_engine_event(EngineEvent::Heartbeat(chrono::Utc::now()));
1375        assert_eq!(s.event_ring.len(), 2);
1376    }
1377
1378    #[test]
1379    fn record_events_lagged_appends_marker_without_losing_prior_events() {
1380        let mut s = mk();
1381        s.record_engine_event(EngineEvent::Heartbeat(chrono::Utc::now()));
1382        s.record_events_lagged(4);
1383        // 1 event + 1 lag marker = 2 items; lag must not
1384        // replace or overwrite the preceding real event.
1385        assert_eq!(s.event_ring.len(), 2);
1386    }
1387
1388    // ------------------------------------------------------------
1389    // First-live-trade ceremony (§8.4).
1390    // ------------------------------------------------------------
1391
1392    fn mk_with_fresh_store() -> (AppState, std::sync::Arc<zero_session::Store>) {
1393        use zero_session::Store;
1394        let store = std::sync::Arc::new(Store::open_in_memory().unwrap());
1395        let id = store
1396            .start_session("01HCRM", None, "0.3.0-test", None)
1397            .unwrap();
1398        let sink = crate::app::session::SessionSink::new(
1399            std::sync::Arc::clone(&store),
1400            id,
1401            "01HCRM".into(),
1402        );
1403        (
1404            AppState::new_with_sink(EngineState::shared(), Some(sink)),
1405            store,
1406        )
1407    }
1408
1409    fn positions_with_items(n: usize) -> EngineEvent {
1410        use zero_engine_client::models::{Position, Positions};
1411        let items = (0..n)
1412            .map(|i| Position {
1413                symbol: format!("COIN{i}"),
1414                ..Position::default()
1415            })
1416            .collect();
1417        EngineEvent::Positions(Box::new(Positions {
1418            items,
1419            account_value: None,
1420            total_unrealized_pnl: None,
1421        }))
1422    }
1423
1424    #[test]
1425    fn ceremony_suppressed_without_sink() {
1426        let mut s = mk();
1427        // No sink → latch defaults to `true` → ceremony
1428        // unconditionally suppressed. Confirm by counting
1429        // log-lines-added: none beyond the preexisting
1430        // startup system line.
1431        let before = s.log.len();
1432        s.record_engine_event(positions_with_items(3));
1433        assert_eq!(
1434            s.log.len(),
1435            before,
1436            "no-persist run must not render the ceremony"
1437        );
1438        assert!(s.first_live_trade_recorded);
1439    }
1440
1441    #[test]
1442    fn ceremony_fires_on_first_nonempty_positions_and_persists_milestone() {
1443        use zero_session::milestones::FIRST_LIVE_TRADE_AT;
1444        let (mut s, store) = mk_with_fresh_store();
1445        assert!(!s.first_live_trade_recorded);
1446        assert_eq!(store.get_milestone(FIRST_LIVE_TRADE_AT).unwrap(), None);
1447
1448        let before = s.log.len();
1449        s.record_engine_event(positions_with_items(1));
1450
1451        // Every ceremony line landed in the log.
1452        assert_eq!(
1453            s.log.len() - before,
1454            CEREMONY_LINES.len(),
1455            "exactly one ceremony line per CEREMONY_LINES entry"
1456        );
1457
1458        // Latch flipped.
1459        assert!(s.first_live_trade_recorded);
1460
1461        // Milestone persisted as RFC-3339.
1462        let stored = store
1463            .get_milestone(FIRST_LIVE_TRADE_AT)
1464            .unwrap()
1465            .expect("milestone was set");
1466        assert!(
1467            chrono::DateTime::parse_from_rfc3339(&stored).is_ok(),
1468            "milestone value must be RFC-3339 (got {stored:?})"
1469        );
1470    }
1471
1472    #[test]
1473    fn ceremony_never_fires_on_empty_positions() {
1474        let (mut s, _store) = mk_with_fresh_store();
1475        let before = s.log.len();
1476        s.record_engine_event(positions_with_items(0));
1477        assert_eq!(
1478            s.log.len(),
1479            before,
1480            "empty positions must not fire ceremony"
1481        );
1482        assert!(!s.first_live_trade_recorded);
1483    }
1484
1485    #[test]
1486    fn ceremony_fires_at_most_once_per_session() {
1487        let (mut s, _store) = mk_with_fresh_store();
1488        s.record_engine_event(positions_with_items(1));
1489        let after_first = s.log.len();
1490        s.record_engine_event(positions_with_items(1));
1491        s.record_engine_event(positions_with_items(2));
1492        assert_eq!(
1493            s.log.len(),
1494            after_first,
1495            "subsequent Positions events must not re-fire the ceremony"
1496        );
1497    }
1498
1499    #[test]
1500    fn ceremony_suppressed_when_milestone_already_set() {
1501        use zero_session::Store;
1502        use zero_session::milestones::FIRST_LIVE_TRADE_AT;
1503        let store = std::sync::Arc::new(Store::open_in_memory().unwrap());
1504        // Pre-seed the milestone — the operator has traded
1505        // before this binary launch.
1506        store
1507            .set_milestone(FIRST_LIVE_TRADE_AT, "2026-04-20T12:00:00Z")
1508            .unwrap();
1509        let id = store
1510            .start_session("01HCRM2", None, "0.3.0-test", None)
1511            .unwrap();
1512        let sink = crate::app::session::SessionSink::new(
1513            std::sync::Arc::clone(&store),
1514            id,
1515            "01HCRM2".into(),
1516        );
1517        let mut s = AppState::new_with_sink(EngineState::shared(), Some(sink));
1518
1519        assert!(
1520            s.first_live_trade_recorded,
1521            "latch must pre-close on hydrate"
1522        );
1523        let before = s.log.len();
1524        s.record_engine_event(positions_with_items(5));
1525        assert_eq!(
1526            s.log.len(),
1527            before,
1528            "a seasoned operator must never see the first-trade ceremony"
1529        );
1530    }
1531
1532    // ── M2 §4: risk-overlay auto-open contract ─────────────────
1533    //
1534    // These tests cover the four edges of `poll_risk_overlay`:
1535    // fresh open on L3/L4/proximity, rate-limited re-open after
1536    // dismiss, escalation-overrides-cooldown (L3→L4), and
1537    // fresh-alert-overrides-cooldown. Each seeds the engine
1538    // mirror directly rather than routing through a dispatch
1539    // cycle — the hook is a pure function of the mirror + the
1540    // rate-limiter state, and isolating it makes failures
1541    // actionable.
1542
1543    mod risk_overlay {
1544        use super::*;
1545        use chrono::TimeZone;
1546        use zero_engine_client::{Risk, Source, Stat};
1547        use zero_operator_state::{Label, Snapshot, StateVector, friction::FrictionLevel};
1548
1549        fn frozen() -> chrono::DateTime<chrono::Utc> {
1550            chrono::Utc.with_ymd_and_hms(2026, 4, 21, 18, 0, 0).unwrap()
1551        }
1552
1553        fn snap_at(label: Label, friction: FrictionLevel) -> Stat<Snapshot> {
1554            let snap = Snapshot {
1555                label,
1556                friction,
1557                vector: StateVector::default(),
1558                as_of: frozen(),
1559                version: 1,
1560            };
1561            Stat::new(snap, Source::Ws).with_as_of(frozen())
1562        }
1563
1564        fn risk_stat(
1565            drawdown_pct: Option<f64>,
1566            alert_pct: Option<f64>,
1567            halted: bool,
1568        ) -> Stat<Risk> {
1569            let risk = Risk {
1570                drawdown_pct,
1571                last_drawdown_alert_pct: alert_pct,
1572                halted,
1573                ..Risk::default()
1574            };
1575            Stat::new(risk, Source::Ws).with_as_of(frozen())
1576        }
1577
1578        /// Build a fresh AppState whose engine carries no
1579        /// operator-state + no risk; `poll_risk_overlay` on a
1580        /// no-signal mirror must leave the overlay closed. The
1581        /// constructor already called the hook once, so
1582        /// starting from `overlay == None` is the contract.
1583        fn empty_state() -> AppState {
1584            let s = AppState::new_with_sink(EngineState::shared(), None);
1585            assert!(s.overlay.is_none(), "empty engine => no overlay");
1586            s
1587        }
1588
1589        fn seed_l3(state: &AppState) {
1590            let mut eng = state.engine.write();
1591            eng.operator_state = Some(snap_at(Label::Tilt, FrictionLevel::L3));
1592        }
1593
1594        fn seed_l4(state: &AppState) {
1595            let mut eng = state.engine.write();
1596            eng.operator_state = Some(snap_at(Label::Tilt, FrictionLevel::L4));
1597        }
1598
1599        fn seed_proximity(state: &AppState, drawdown: f64, alert: f64) {
1600            let mut eng = state.engine.write();
1601            eng.risk = Some(risk_stat(Some(drawdown), Some(alert), false));
1602        }
1603
1604        #[test]
1605        fn poll_opens_overlay_on_l3_snapshot() {
1606            let mut s = empty_state();
1607            seed_l3(&s);
1608            s.poll_risk_overlay(Instant::now());
1609            match &s.overlay {
1610                Some(ActiveOverlay::Risk { trigger, .. }) => {
1611                    assert_eq!(*trigger, RiskOverlayTrigger::Friction(FrictionLevel::L3));
1612                }
1613                other => panic!("expected Risk overlay, got {other:?}"),
1614            }
1615        }
1616
1617        #[test]
1618        fn poll_opens_overlay_on_l4_snapshot() {
1619            let mut s = empty_state();
1620            seed_l4(&s);
1621            s.poll_risk_overlay(Instant::now());
1622            match &s.overlay {
1623                Some(ActiveOverlay::Risk { trigger, .. }) => {
1624                    assert_eq!(*trigger, RiskOverlayTrigger::Friction(FrictionLevel::L4));
1625                }
1626                other => panic!("expected Risk overlay, got {other:?}"),
1627            }
1628        }
1629
1630        #[test]
1631        fn poll_opens_overlay_on_drawdown_proximity_below_threshold() {
1632            let mut s = empty_state();
1633            // 4.6% vs 5.0% = 0.4pp distance, inside the 0.5pp window.
1634            seed_proximity(&s, 4.6, 5.0);
1635            s.poll_risk_overlay(Instant::now());
1636            match &s.overlay {
1637                Some(ActiveOverlay::Risk { trigger, .. }) => {
1638                    assert_eq!(*trigger, RiskOverlayTrigger::Proximity);
1639                }
1640                other => panic!("expected Risk overlay, got {other:?}"),
1641            }
1642        }
1643
1644        #[test]
1645        fn poll_does_not_open_overlay_when_drawdown_far_from_alert() {
1646            let mut s = empty_state();
1647            // 3.0% vs 5.0% = 2.0pp distance, outside the 0.5pp window.
1648            seed_proximity(&s, 3.0, 5.0);
1649            s.poll_risk_overlay(Instant::now());
1650            assert!(s.overlay.is_none(), "2.0pp distance must not open");
1651        }
1652
1653        #[test]
1654        fn dismiss_then_poll_within_cooldown_does_not_reopen() {
1655            let mut s = empty_state();
1656            seed_l3(&s);
1657            let t0 = Instant::now();
1658            s.poll_risk_overlay(t0);
1659            assert!(matches!(s.overlay, Some(ActiveOverlay::Risk { .. })));
1660            s.dismiss_overlay_at(t0 + Duration::from_secs(5));
1661            assert!(s.overlay.is_none());
1662            // Still L3 on mirror; 10 s after dismissal (< 60 s
1663            // cooldown, same trigger).
1664            s.poll_risk_overlay(t0 + Duration::from_secs(15));
1665            assert!(
1666                s.overlay.is_none(),
1667                "same-trigger reopen inside cooldown must be suppressed"
1668            );
1669        }
1670
1671        #[test]
1672        fn dismiss_then_poll_after_cooldown_reopens() {
1673            let mut s = empty_state();
1674            seed_l3(&s);
1675            let t0 = Instant::now();
1676            s.poll_risk_overlay(t0);
1677            s.dismiss_overlay_at(t0 + Duration::from_secs(5));
1678            // Just after the cooldown expires.
1679            let t_reopen = t0 + Duration::from_secs(5) + AppState::RISK_DISMISS_COOLDOWN;
1680            s.poll_risk_overlay(t_reopen);
1681            assert!(
1682                matches!(s.overlay, Some(ActiveOverlay::Risk { .. })),
1683                "past cooldown, signal still live => must reopen, got {:?}",
1684                s.overlay,
1685            );
1686        }
1687
1688        #[test]
1689        fn escalation_l3_to_l4_overrides_cooldown() {
1690            let mut s = empty_state();
1691            seed_l3(&s);
1692            let t0 = Instant::now();
1693            s.poll_risk_overlay(t0);
1694            s.dismiss_overlay_at(t0 + Duration::from_secs(5));
1695            // Mirror escalates to L4 while we are still in the
1696            // 60 s cooldown.
1697            seed_l4(&s);
1698            s.poll_risk_overlay(t0 + Duration::from_secs(15));
1699            match &s.overlay {
1700                Some(ActiveOverlay::Risk { trigger, .. }) => {
1701                    assert_eq!(
1702                        *trigger,
1703                        RiskOverlayTrigger::Friction(FrictionLevel::L4),
1704                        "L4 escalation must override dismiss-cooldown",
1705                    );
1706                }
1707                other => panic!("expected L4 Risk overlay, got {other:?}"),
1708            }
1709        }
1710
1711        #[test]
1712        fn fresh_alert_threshold_overrides_cooldown() {
1713            let mut s = empty_state();
1714            // Start with a proximity trigger against alert=5.0%.
1715            seed_proximity(&s, 4.6, 5.0);
1716            let t0 = Instant::now();
1717            s.poll_risk_overlay(t0);
1718            assert!(matches!(s.overlay, Some(ActiveOverlay::Risk { .. })));
1719            s.dismiss_overlay_at(t0 + Duration::from_secs(5));
1720            // Engine trips a new alert threshold (5.0% -> 6.0%).
1721            // Drawdown nudges into its 0.5pp window (5.6%).
1722            {
1723                let mut eng = s.engine.write();
1724                eng.risk = Some(risk_stat(Some(5.6), Some(6.0), false));
1725            }
1726            s.poll_risk_overlay(t0 + Duration::from_secs(15));
1727            assert!(
1728                matches!(s.overlay, Some(ActiveOverlay::Risk { .. })),
1729                "fresh guardrail threshold must override cooldown",
1730            );
1731        }
1732
1733        #[test]
1734        fn poll_does_not_stomp_friction_pause_overlay() {
1735            let mut s = empty_state();
1736            // Seed an L3 snapshot but pretend a friction pause
1737            // is already on screen (e.g. operator ran an
1738            // /execute at TILT a tick earlier).
1739            seed_l3(&s);
1740            let fp = FrictionPause {
1741                command: Command::Help,
1742                level: FrictionLevel::L1,
1743                pause: Duration::from_secs(3),
1744                started_at: Instant::now(),
1745                confirm_word: None,
1746                confirm_input: String::new(),
1747            };
1748            s.overlay = Some(ActiveOverlay::FrictionPause(fp));
1749            s.poll_risk_overlay(Instant::now());
1750            assert!(
1751                matches!(s.overlay, Some(ActiveOverlay::FrictionPause(_))),
1752                "poll must defer to an active friction pause",
1753            );
1754        }
1755
1756        #[test]
1757        fn l4_mirror_on_construction_opens_overlay_with_hardstop_trigger() {
1758            // Session-attach scenario: engine was halted before
1759            // the TUI started. The constructor must surface the
1760            // overlay so the operator lands inside the context
1761            // card from frame zero.
1762            let engine = EngineState::shared();
1763            {
1764                let mut eng = engine.write();
1765                eng.operator_state = Some(snap_at(Label::Tilt, FrictionLevel::L4));
1766            }
1767            let s = AppState::new_with_sink(engine, None);
1768            match &s.overlay {
1769                Some(ActiveOverlay::Risk { trigger, .. }) => {
1770                    assert_eq!(*trigger, RiskOverlayTrigger::Friction(FrictionLevel::L4));
1771                }
1772                other => panic!("expected L4 Risk overlay at construction, got {other:?}"),
1773            }
1774        }
1775    }
1776}