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}