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}