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}
217
218impl Screen {
219 /// Whether this screen is a terminal end-state (no further interaction).
220 #[must_use]
221 pub fn is_terminal(&self) -> bool {
222 matches!(self, Screen::Completed { .. } | Screen::Failed { .. })
223 }
224}
225
226/// Active tab on the main screen.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum StepTab {
229 /// Pre-step ceremony overview: name, description, declared
230 /// materials, step count. Default tab on boot; the operator
231 /// confirms here before the first step runs.
232 Overview,
233 /// Unified ceremony view: timestamped log feed with past-step
234 /// dimming and the pending prompt pinned at the bottom.
235 Ceremony,
236 /// Deviations side panel. Reserved for non-log content (operator
237 /// notes, attestation summary, etc.) once those land.
238 Deviations,
239 /// Environment dashboard: build/host identity header plus the live
240 /// device inventory (disks today; peripherals and network later).
241 /// Situational awareness during the run, not part of the transcript.
242 System,
243}
244
245/// Lifecycle phase of the ceremony as observed by the UI.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum RunningState {
248 /// Ceremony is still running.
249 Active,
250 /// User has requested abort; waiting for the executor to unwind.
251 Aborting,
252 /// Done (success or failure). Loop exits.
253 Done,
254}
255
256/// Snapshot of the currently-executing step.
257#[derive(Debug, Clone)]
258pub struct StepView {
259 /// Step identifier.
260 pub id: StepId,
261 /// Step label as authored in the DSL.
262 pub label: String,
263 /// Human-readable role name for display.
264 pub role_name: String,
265}
266
267/// Bounded log feed entry.
268#[derive(Debug, Clone)]
269pub enum LogLine {
270 /// Regular log line emitted by the runtime as a `UiSignal::LogLine`.
271 Entry {
272 /// Visual cue.
273 icon: Icon,
274 /// Line text.
275 text: String,
276 /// Step the entry belongs to (the current step at push time).
277 /// Rendered as a per-row step column on the Ceremony tab.
278 step: Option<String>,
279 /// Wall-clock instant at push time; rendered as `HH:MM:SS` in
280 /// the Ceremony tab's timestamp column.
281 at: DateTime<Local>,
282 },
283 /// Visual marker pushed at every `StepStarted`. Replaces the old
284 /// role-transition modal, operators see the boundary inline.
285 StepDivider {
286 /// Step label as authored in the DSL (e.g. `"2.1"`).
287 label: String,
288 /// Human-readable role name for the new step.
289 role_name: String,
290 },
291 /// Visual marker pushed at every `ActStarted`. Acts subdivide
292 /// ceremonies and contain steps; the divider visualises the
293 /// hierarchical boundary above [`Self::StepDivider`].
294 ActDivider {
295 /// Act label as authored in the DSL.
296 label: String,
297 },
298}
299
300/// Deviation as recorded in the live UI.
301#[derive(Debug, Clone)]
302pub struct DeviationView {
303 /// Step in which the deviation was recorded, if any.
304 pub step: Option<StepId>,
305 /// Verbatim deviation text.
306 pub text: String,
307 /// Wall-clock instant from the fact's timestamp, rendered as
308 /// `HH:MM:SS` in the Deviations tab.
309 pub at: DateTime<Local>,
310}
311
312/// In-flight prompt the UI is awaiting an answer to.
313#[derive(Debug, Clone)]
314pub struct PendingPrompt {
315 /// Prompt identifier (echoed back in the response command).
316 pub prompt_id: PromptId,
317 /// The prompt itself.
318 pub prompt: Prompt,
319 /// Partial user input (used for text/literal/secret prompts).
320 pub input: String,
321 /// Most recent rejection reason from the validator, if any.
322 pub rejection: Option<String>,
323}