Skip to main content

rite_tui/
model.rs

1//! UI state. Pure data, never reaches for I/O or terminal handles.
2
3use std::collections::VecDeque;
4
5use chrono::{DateTime, Local, Timelike};
6use rite_model::{Prompt, StepId};
7use rite_runtime::{Environment, ExecEvent, Icon, MaterialOverview, PromptId, SystemInfo};
8
9/// Maximum length of a deviation note. Anything longer is rejected before
10/// the command is sent, keeps the transcript line readable.
11pub(crate) const DEVIATION_INPUT_MAX: usize = 280;
12
13/// Maximum number of log lines retained for the visible feed.
14pub(crate) const LOG_CAPACITY: usize = 200;
15
16/// Top-level UI state. A single [`Screen`] discriminant captures what
17/// the user is looking at, so the borrow checker enforces that we can't
18/// render two contradictory views.
19#[derive(Debug)]
20pub struct Model {
21    /// Ceremony name, as soon as `CeremonyStarted` arrives.
22    pub ceremony_name: Option<String>,
23    /// Ceremony description, as authored in the DSL.
24    pub ceremony_description: Option<String>,
25    /// Declared materials. Populated from `CeremonyStarted`.
26    pub ceremony_materials: Vec<MaterialOverview>,
27    /// Total number of steps in the execution plan, when known.
28    pub ceremony_step_count: Option<usize>,
29    /// Static build/host identity for the System tab. Populated from
30    /// `UiSignal::SystemInfo`, emitted once at ceremony start.
31    pub system_info: Option<SystemInfo>,
32    /// Live device environment for the System tab. Populated from
33    /// `UiSignal::Environment`; replaced wholesale on each re-emission.
34    pub environment: Option<Environment>,
35    /// Current screen.
36    pub screen: Screen,
37    /// Screen to restore when a modal is dismissed. `None` when the
38    /// current screen is itself the underlying step screen.
39    pub return_to: Option<Screen>,
40    /// Most-recent step being executed, if any.
41    pub current_step: Option<StepView>,
42    /// Bounded log feed.
43    pub log: VecDeque<LogLine>,
44    /// Deviations recorded so far.
45    pub deviations: Vec<DeviationView>,
46    /// Pending prompt the runtime is awaiting an answer to.
47    pub pending_prompt: Option<PendingPrompt>,
48    /// Lifecycle phase.
49    pub running: RunningState,
50    /// Frame counter for spinner animation. Advances on every `Tick`.
51    pub tick: u64,
52    /// Wall-clock snapshot, refreshed on every `Tick`. Rendered in the
53    /// header bar; kept in the model so `view` stays pure and snapshot
54    /// tests can pin a deterministic time.
55    pub now: DateTime<Local>,
56    /// Log-tab scroll offset, counted in lines *up from the tail*.
57    /// `0` = following the tail; positive = scrolled into history.
58    /// Clamped to `log.len()` whenever the feed is pushed or evicted.
59    pub log_scroll: usize,
60    /// FIFO of executor events waiting to be applied. `Msg::Exec` pushes
61    /// here; `Msg::Tick` pops at most one per `LOG_DRIP_TICKS` ticks so
62    /// a burst of facts from a single step types out one line at a time
63    /// instead of landing in the log all at once.
64    pub(crate) pending_events: VecDeque<ExecEvent>,
65}
66
67impl Default for Model {
68    fn default() -> Self {
69        Self {
70            ceremony_name: None,
71            ceremony_description: None,
72            ceremony_materials: Vec::new(),
73            ceremony_step_count: None,
74            system_info: None,
75            environment: None,
76            screen: Screen::Step {
77                tab: StepTab::Overview,
78            },
79            return_to: None,
80            current_step: None,
81            log: VecDeque::with_capacity(LOG_CAPACITY),
82            deviations: Vec::new(),
83            pending_prompt: None,
84            running: RunningState::Active,
85            tick: 0,
86            now: Local::now(),
87            log_scroll: 0,
88            pending_events: VecDeque::new(),
89        }
90    }
91}
92
93impl Model {
94    /// Build a fresh model.
95    #[must_use]
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Push a log line, evicting the oldest entry if the bounded feed is full.
101    ///
102    /// When the user is scrolled up (`log_scroll > 0`), the visible content
103    /// is held in place by clamping `log_scroll` to the new length.
104    ///
105    /// Any incoming line implies the previously-spinning operation has
106    /// finished, so a trailing `Icon::Spinner` entry is converted to
107    /// `Icon::Checkmark` before the new line is pushed, the spinner
108    /// stops animating in the feed.
109    pub(crate) fn push_log(&mut self, line: LogLine) {
110        if let Some(LogLine::Entry {
111            icon: icon @ Icon::Spinner,
112            ..
113        }) = self.log.back_mut()
114        {
115            *icon = Icon::Checkmark;
116        }
117        if self.log.len() == LOG_CAPACITY {
118            self.log.pop_front();
119        }
120        self.log.push_back(line);
121        if self.log_scroll > self.log.len() {
122            self.log_scroll = self.log.len();
123        }
124    }
125
126    /// Push a regular log entry, tagging it with the current step label
127    /// and the model's wall-clock snapshot so the Ceremony tab can render
128    /// a per-row step and timestamp column.
129    pub(crate) fn push_entry(&mut self, icon: Icon, text: String) {
130        self.push_entry_at(icon, text, self.now);
131    }
132
133    /// Push a log entry stamped with an explicit event time. Used for facts so
134    /// the Ceremony tab's timestamp column shows when the event actually
135    /// happened, not when the drip queue drained it. `push_entry` (for signals,
136    /// which have no event time) delegates here with the live `now`.
137    pub(crate) fn push_entry_at(&mut self, icon: Icon, text: String, at: DateTime<Local>) {
138        let step = self.current_step.as_ref().map(|s| s.label.clone());
139        self.push_log(LogLine::Entry {
140            icon,
141            text,
142            step,
143            at,
144        });
145    }
146
147    /// Push a modal in front of the current screen, remembering what to
148    /// return to when it is dismissed. Idempotent if already inside a modal.
149    pub(crate) fn open_modal(&mut self, modal: Screen) {
150        if self.return_to.is_none() {
151            self.return_to = Some(self.screen.clone());
152        }
153        self.screen = modal;
154    }
155
156    /// Restore the saved screen if any. No-op when called outside a modal.
157    pub(crate) fn close_modal(&mut self) {
158        if let Some(prev) = self.return_to.take() {
159            self.screen = prev;
160        }
161    }
162
163    /// Whether the log feed currently ends with an active spinner that
164    /// must keep animating. Invariant: `push_log` converts any prior
165    /// trailing `Icon::Spinner` to a `Checkmark`, so at most the tail
166    /// entry can be an active spinner.
167    #[must_use]
168    pub fn has_active_spinner(&self) -> bool {
169        matches!(
170            self.log.back(),
171            Some(LogLine::Entry {
172                icon: Icon::Spinner,
173                ..
174            })
175        )
176    }
177
178    /// Equivalence key for the visible header clock. The colon blinks
179    /// on second parity and the minute is the smallest displayed unit,
180    /// so the rendered header changes iff this key changes.
181    #[must_use]
182    pub fn clock_display_key(&self) -> (u32, u32) {
183        (self.now.minute(), self.now.second() % 2)
184    }
185}
186
187/// Single source of truth for which screen is visible. Because only one
188/// variant is active at a time, we can't render two contradictory views
189/// or sit "in a modal over a different modal", exactly one screen,
190/// always.
191#[derive(Debug, Clone)]
192pub enum Screen {
193    /// Step execution screen. Default and most common.
194    Step {
195        /// Active tab within the step screen.
196        tab: StepTab,
197    },
198    /// Operator is composing a deviation note.
199    DeviationModal {
200        /// Partial deviation text the operator is composing.
201        input: String,
202    },
203    /// Confirming abort. `y` sends `UiCommand::Abort`; `n` / Esc returns.
204    AbortConfirm,
205    /// Terminal "ceremony completed" screen.
206    Completed {
207        /// Final transcript fingerprint, populated by `ExecEvent::Finalized`
208        /// that arrives right after the `CeremonyCompleted` fact.
209        fingerprint: Option<String>,
210    },
211    /// Terminal "ceremony failed" screen.
212    Failed {
213        /// Failure reason.
214        reason: String,
215    },
216    /// Terminal "ceremony aborted" screen. Distinct from `Failed`: an abort is
217    /// a deliberate operator decision, not an error.
218    Aborted,
219}
220
221impl Screen {
222    /// Whether this screen is a terminal end-state (no further interaction).
223    #[must_use]
224    pub fn is_terminal(&self) -> bool {
225        matches!(
226            self,
227            Screen::Completed { .. } | Screen::Failed { .. } | Screen::Aborted
228        )
229    }
230}
231
232/// Active tab on the main screen.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum StepTab {
235    /// Pre-step ceremony overview: name, description, declared
236    /// materials, step count. Default tab on boot; the operator
237    /// confirms here before the first step runs.
238    Overview,
239    /// Unified ceremony view: timestamped log feed with past-step
240    /// dimming and the pending prompt pinned at the bottom.
241    Ceremony,
242    /// Deviations side panel. Reserved for non-log content (operator
243    /// notes, attestation summary, etc.) once those land.
244    Deviations,
245    /// Environment dashboard: build/host identity header plus the live
246    /// device inventory (disks today; peripherals and network later).
247    /// Situational awareness during the run, not part of the transcript.
248    System,
249}
250
251/// Lifecycle phase of the ceremony as observed by the UI.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum RunningState {
254    /// Ceremony is still running.
255    Active,
256    /// User has requested abort; waiting for the executor to unwind.
257    Aborting,
258    /// Done (success or failure). Loop exits.
259    Done,
260}
261
262/// Snapshot of the currently-executing step.
263#[derive(Debug, Clone)]
264pub struct StepView {
265    /// Step identifier.
266    pub id: StepId,
267    /// Step label as authored in the DSL.
268    pub label: String,
269    /// Human-readable role name for display.
270    pub role_name: String,
271}
272
273/// Bounded log feed entry.
274#[derive(Debug, Clone)]
275pub enum LogLine {
276    /// Regular log line emitted by the runtime as a `UiSignal::LogLine`.
277    Entry {
278        /// Visual cue.
279        icon: Icon,
280        /// Line text.
281        text: String,
282        /// Step the entry belongs to (the current step at push time).
283        /// Rendered as a per-row step column on the Ceremony tab.
284        step: Option<String>,
285        /// Wall-clock instant at push time; rendered as `HH:MM:SS` in
286        /// the Ceremony tab's timestamp column.
287        at: DateTime<Local>,
288    },
289    /// Visual marker pushed at every `StepStarted`. Replaces the old
290    /// role-transition modal, operators see the boundary inline.
291    StepDivider {
292        /// Step label as authored in the DSL (e.g. `"2.1"`).
293        label: String,
294        /// Human-readable role name for the new step.
295        role_name: String,
296    },
297    /// Visual marker pushed at every `ActStarted`. Acts subdivide
298    /// ceremonies and contain steps; the divider visualises the
299    /// hierarchical boundary above [`Self::StepDivider`].
300    ActDivider {
301        /// Act label as authored in the DSL.
302        label: String,
303    },
304}
305
306/// Deviation as recorded in the live UI.
307#[derive(Debug, Clone)]
308pub struct DeviationView {
309    /// Step in which the deviation was recorded, if any.
310    pub step: Option<StepId>,
311    /// Verbatim deviation text.
312    pub text: String,
313    /// Wall-clock instant from the fact's timestamp, rendered as
314    /// `HH:MM:SS` in the Deviations tab.
315    pub at: DateTime<Local>,
316}
317
318/// In-flight prompt the UI is awaiting an answer to.
319#[derive(Debug, Clone)]
320pub struct PendingPrompt {
321    /// Prompt identifier (echoed back in the response command).
322    pub prompt_id: PromptId,
323    /// The prompt itself.
324    pub prompt: Prompt,
325    /// Partial user input (used for text/literal/secret prompts).
326    pub input: String,
327    /// Most recent rejection reason from the validator, if any.
328    pub rejection: Option<String>,
329}