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        let step = self.current_step.as_ref().map(|s| s.label.clone());
131        let at = self.now;
132        self.push_log(LogLine::Entry {
133            icon,
134            text,
135            step,
136            at,
137        });
138    }
139
140    /// Push a modal in front of the current screen, remembering what to
141    /// return to when it is dismissed. Idempotent if already inside a modal.
142    pub(crate) fn open_modal(&mut self, modal: Screen) {
143        if self.return_to.is_none() {
144            self.return_to = Some(self.screen.clone());
145        }
146        self.screen = modal;
147    }
148
149    /// Restore the saved screen if any. No-op when called outside a modal.
150    pub(crate) fn close_modal(&mut self) {
151        if let Some(prev) = self.return_to.take() {
152            self.screen = prev;
153        }
154    }
155
156    /// Whether the log feed currently ends with an active spinner that
157    /// must keep animating. Invariant: `push_log` converts any prior
158    /// trailing `Icon::Spinner` to a `Checkmark`, so at most the tail
159    /// entry can be an active spinner.
160    #[must_use]
161    pub fn has_active_spinner(&self) -> bool {
162        matches!(
163            self.log.back(),
164            Some(LogLine::Entry {
165                icon: Icon::Spinner,
166                ..
167            })
168        )
169    }
170
171    /// Equivalence key for the visible header clock. The colon blinks
172    /// on second parity and the minute is the smallest displayed unit,
173    /// so the rendered header changes iff this key changes.
174    #[must_use]
175    pub fn clock_display_key(&self) -> (u32, u32) {
176        (self.now.minute(), self.now.second() % 2)
177    }
178}
179
180/// Single source of truth for which screen is visible. Because only one
181/// variant is active at a time, we can't render two contradictory views
182/// or sit "in a modal over a different modal", exactly one screen,
183/// always.
184#[derive(Debug, Clone)]
185pub enum Screen {
186    /// Step execution screen. Default and most common.
187    Step {
188        /// Active tab within the step screen.
189        tab: StepTab,
190    },
191    /// Operator is composing a deviation note.
192    DeviationModal {
193        /// Partial deviation text the operator is composing.
194        input: String,
195    },
196    /// Confirming abort. `y` sends `UiCommand::Abort`; `n` / Esc returns.
197    AbortConfirm,
198    /// Terminal "ceremony completed" screen.
199    Completed {
200        /// Final transcript fingerprint, populated by `ExecEvent::Finalized`
201        /// that arrives right after the `CeremonyCompleted` fact.
202        fingerprint: Option<String>,
203    },
204    /// Terminal "ceremony failed" screen.
205    Failed {
206        /// Failure reason.
207        reason: String,
208    },
209}
210
211impl Screen {
212    /// Whether this screen is a terminal end-state (no further interaction).
213    #[must_use]
214    pub fn is_terminal(&self) -> bool {
215        matches!(self, Screen::Completed { .. } | Screen::Failed { .. })
216    }
217}
218
219/// Active tab on the main screen.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum StepTab {
222    /// Pre-step ceremony overview: name, description, declared
223    /// materials, step count. Default tab on boot; the operator
224    /// confirms here before the first step runs.
225    Overview,
226    /// Unified ceremony view: timestamped log feed with past-step
227    /// dimming and the pending prompt pinned at the bottom.
228    Ceremony,
229    /// Deviations side panel. Reserved for non-log content (operator
230    /// notes, attestation summary, etc.) once those land.
231    Deviations,
232    /// Environment dashboard: build/host identity header plus the live
233    /// device inventory (disks today; peripherals and network later).
234    /// Situational awareness during the run, not part of the transcript.
235    System,
236}
237
238/// Lifecycle phase of the ceremony as observed by the UI.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum RunningState {
241    /// Ceremony is still running.
242    Active,
243    /// User has requested abort; waiting for the executor to unwind.
244    Aborting,
245    /// Done (success or failure). Loop exits.
246    Done,
247}
248
249/// Snapshot of the currently-executing step.
250#[derive(Debug, Clone)]
251pub struct StepView {
252    /// Step identifier.
253    pub id: StepId,
254    /// Step label as authored in the DSL.
255    pub label: String,
256    /// Human-readable role name for display.
257    pub role_name: String,
258}
259
260/// Bounded log feed entry.
261#[derive(Debug, Clone)]
262pub enum LogLine {
263    /// Regular log line emitted by the runtime as a `UiSignal::LogLine`.
264    Entry {
265        /// Visual cue.
266        icon: Icon,
267        /// Line text.
268        text: String,
269        /// Step the entry belongs to (the current step at push time).
270        /// Rendered as a per-row step column on the Ceremony tab.
271        step: Option<String>,
272        /// Wall-clock instant at push time; rendered as `HH:MM:SS` in
273        /// the Ceremony tab's timestamp column.
274        at: DateTime<Local>,
275    },
276    /// Visual marker pushed at every `StepStarted`. Replaces the old
277    /// role-transition modal, operators see the boundary inline.
278    StepDivider {
279        /// Step label as authored in the DSL (e.g. `"2.1"`).
280        label: String,
281        /// Human-readable role name for the new step.
282        role_name: String,
283    },
284    /// Visual marker pushed at every `ActStarted`. Acts subdivide
285    /// ceremonies and contain steps; the divider visualises the
286    /// hierarchical boundary above [`Self::StepDivider`].
287    ActDivider {
288        /// Act label as authored in the DSL.
289        label: String,
290    },
291}
292
293/// Deviation as recorded in the live UI.
294#[derive(Debug, Clone)]
295pub struct DeviationView {
296    /// Step in which the deviation was recorded, if any.
297    pub step: Option<StepId>,
298    /// Verbatim deviation text.
299    pub text: String,
300    /// Wall-clock instant from the fact's timestamp, rendered as
301    /// `HH:MM:SS` in the Deviations tab.
302    pub at: DateTime<Local>,
303}
304
305/// In-flight prompt the UI is awaiting an answer to.
306#[derive(Debug, Clone)]
307pub struct PendingPrompt {
308    /// Prompt identifier (echoed back in the response command).
309    pub prompt_id: PromptId,
310    /// The prompt itself.
311    pub prompt: Prompt,
312    /// Partial user input (used for text/literal/secret prompts).
313    pub input: String,
314    /// Most recent rejection reason from the validator, if any.
315    pub rejection: Option<String>,
316}