zero_tui/app/mod.rs
1//! App module — event loop, state, input dispatch, render.
2//!
3//! The [`App`] entry point takes a shared `EngineState` handle
4//! (fed by a `WsSubscriber` owned elsewhere) and a
5//! [`zero_commands::DispatchContext`], then runs the TUI event
6//! loop until the operator exits.
7
8pub mod event_ring;
9pub mod input;
10pub mod log;
11pub mod mode;
12pub mod picker;
13pub mod prompt;
14pub mod render;
15pub mod session;
16pub mod state;
17pub mod terminal;
18
19use std::io;
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22
23use crossterm::event::{Event, EventStream};
24use futures::StreamExt;
25use parking_lot::RwLock;
26use thiserror::Error;
27use tokio::sync::broadcast;
28use zero_commands::{DispatchContext, run_bypass_friction};
29use zero_engine_client::{EngineEvent, EngineState};
30
31pub use mode::Mode;
32pub use session::SessionSink;
33pub use state::{ActiveOverlay, AppState, FrictionOutcome, FrictionPause};
34
35#[derive(Debug, Error)]
36pub enum AppError {
37 #[error("io: {0}")]
38 Io(#[from] io::Error),
39}
40
41/// Summary returned by [`App::run`] on a clean shutdown.
42///
43/// Carries the pieces of session state the caller needs for
44/// post-session bookkeeping — today just the `wrap_off` flag
45/// the operator may have toggled with `/wrap-off`, so the
46/// caller knows whether to run the daily wrap generator.
47///
48/// Kept as a `#[non_exhaustive]` struct so adding another
49/// post-session signal later (e.g. an explicit wrap-format
50/// override, or a milestone pending write) is additive — no
51/// caller will break by reading `wrap_off` today.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53#[non_exhaustive]
54pub struct AppExit {
55 /// The operator used `/wrap-off` this session — suppress
56 /// the daily wrap. Per Addendum A §9.1 the suppression is
57 /// session-scoped; next session's wrap runs again.
58 pub wrap_off: bool,
59}
60
61/// Interactive application entry point.
62#[derive(Debug)]
63pub struct App {
64 state: AppState,
65 ctx: DispatchContext,
66 /// Optional tap on the `WsSubscriber`'s broadcast channel.
67 /// When set, the event loop drains typed `EngineEvent`s into
68 /// `state.event_ring` for the live-stream pane. When `None`
69 /// (no subscriber, or caller opted out), the pane still
70 /// renders — just with its honest empty state.
71 events: Option<broadcast::Receiver<EngineEvent>>,
72}
73
74impl App {
75 #[must_use]
76 pub fn new(engine: Arc<RwLock<EngineState>>, ctx: DispatchContext) -> Self {
77 let rate_budget = ctx.http.as_ref().and_then(|c| c.rate_budget().cloned());
78 let mut state = AppState::new(engine);
79 state.rate_budget = rate_budget;
80 Self {
81 state,
82 ctx,
83 events: None,
84 }
85 }
86
87 /// Construct with an active session sink — prompts and
88 /// dispatcher output will be persisted.
89 #[must_use]
90 pub fn new_with_sink(
91 engine: Arc<RwLock<EngineState>>,
92 ctx: DispatchContext,
93 sink: SessionSink,
94 ) -> Self {
95 let rate_budget = ctx.http.as_ref().and_then(|c| c.rate_budget().cloned());
96 let mut state = AppState::new_with_sink(engine, Some(sink));
97 state.rate_budget = rate_budget;
98 Self {
99 state,
100 ctx,
101 events: None,
102 }
103 }
104
105 /// Attach a broadcast receiver sourced from
106 /// `WsSubscriber::events()`. Received events land in
107 /// `AppState::event_ring` and — on broadcast lag — a
108 /// synthetic "lagged" marker is recorded so the operator
109 /// sees the drop instead of a silent pane. Takes `self` by
110 /// value for a fluent `App::new(...).with_events(rx)` pattern.
111 #[must_use]
112 pub fn with_events(mut self, rx: broadcast::Receiver<EngineEvent>) -> Self {
113 self.events = Some(rx);
114 self
115 }
116
117 /// Mutable access for pre-launch seeding (welcome messages,
118 /// retry counters, etc.).
119 pub fn state_mut(&mut self) -> &mut AppState {
120 &mut self.state
121 }
122
123 /// Run the event loop until the user quits.
124 ///
125 /// Returns an [`AppExit`] summary on a clean shutdown so
126 /// the caller can run post-session bookkeeping (daily wrap,
127 /// milestone writes) without having to poke at `App`'s
128 /// internal state. On an error, the error is returned; the
129 /// caller must assume the session did not end cleanly and
130 /// skip post-session I/O.
131 ///
132 /// # Errors
133 /// Propagates any terminal I/O error.
134 pub async fn run(mut self) -> Result<AppExit, AppError> {
135 let mut term = terminal::TerminalGuard::init()?;
136 let mut events = EventStream::new();
137 let mut ticker = tokio::time::interval(Duration::from_millis(100));
138 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
139
140 // Initial draw so the operator sees the shell immediately
141 // rather than a blank terminal on the first event wait.
142 term.tty.draw(|f| render::render(f, &self.state))?;
143
144 let run_result = self.drive(&mut term, &mut events, &mut ticker).await;
145
146 // Close the session row regardless of how we got here.
147 if let Some(sink) = &self.state.sink {
148 sink.end();
149 }
150
151 run_result.map(|()| AppExit {
152 wrap_off: self.state.wrap_off,
153 })
154 }
155
156 async fn drive(
157 &mut self,
158 term: &mut terminal::TerminalGuard,
159 events: &mut EventStream,
160 ticker: &mut tokio::time::Interval,
161 ) -> Result<(), AppError> {
162 while !self.state.should_quit {
163 tokio::select! {
164 _ = ticker.tick() => {
165 self.tick_friction().await;
166 term.tty.draw(|f| render::render(f, &self.state))?;
167 }
168 maybe_event = events.next() => {
169 match maybe_event {
170 Some(Ok(Event::Key(key))) => {
171 // Only react to key *presses*. On
172 // platforms that emit release events
173 // (KittyKeyboard), we drop them so a
174 // single key press does not double-fire.
175 if matches!(
176 key.kind,
177 crossterm::event::KeyEventKind::Press
178 | crossterm::event::KeyEventKind::Repeat,
179 ) {
180 input::handle_key(&mut self.state, key);
181 }
182 }
183 // Resize triggers a redraw at the end of
184 // the match. All other non-key events are
185 // dropped silently.
186 Some(Ok(_)) => {}
187 Some(Err(e)) => {
188 tracing::warn!(err = %e, "event stream error");
189 }
190 None => break,
191 }
192 // Drain any input the user submitted this tick.
193 if let Some(line) = self.state.pending_input.take() {
194 // Snapshot the TUI's current verbose state
195 // onto the context so `/verbose toggle`
196 // resolves into an absolute target at
197 // dispatch time. Cheap — `DispatchContext`
198 // is `Clone` and the with_verbose builder
199 // is a two-field copy.
200 let ctx = self
201 .ctx
202 .clone()
203 .with_verbose(self.state.verbose)
204 .with_wrap_off(self.state.wrap_off);
205 match zero_commands::dispatch(&ctx, &line).await {
206 Ok(Some(out)) => self.state.apply_dispatch(out),
207 Ok(None) => {}
208 Err(e) => tracing::warn!(err = ?e, "dispatch error"),
209 }
210 }
211 // A key might have completed a friction gate
212 // (L2: typed the confirm word); check every
213 // turn, not just on tick.
214 self.tick_friction().await;
215 term.tty.draw(|f| render::render(f, &self.state))?;
216 }
217 // Tap on the WS subscriber's broadcast channel.
218 // Runs in the same select! so events update the
219 // ring + trigger a redraw without waiting for the
220 // 100 ms ticker — the live-stream pane should feel
221 // near-instant. The arm is always present even when
222 // no receiver is attached (falls through to a
223 // pending future) so we do not have to rewrite the
224 // macro based on construction config.
225 ev = Self::next_engine_event(&mut self.events) => {
226 match ev {
227 Ok(event) => {
228 self.state.record_engine_event(event);
229 term.tty.draw(|f| render::render(f, &self.state))?;
230 }
231 Err(broadcast::error::RecvError::Lagged(skipped)) => {
232 // Honest: mark the drop in the ring so
233 // the pane cannot look calm after we
234 // threw frames away. The subscriber's
235 // own broadcast buffer is 128 slots —
236 // a Lagged here means a genuine burst,
237 // not a pathological slow consumer.
238 tracing::warn!(skipped, "ws broadcast lagged");
239 self.state.record_events_lagged(skipped);
240 term.tty.draw(|f| render::render(f, &self.state))?;
241 }
242 Err(broadcast::error::RecvError::Closed) => {
243 // Subscriber shut down. Disable the arm
244 // for the rest of the session so we do
245 // not busy-loop on a closed channel;
246 // the pane retains whatever history
247 // was already captured.
248 tracing::info!("ws broadcast channel closed");
249 self.events = None;
250 }
251 }
252 }
253 }
254 }
255
256 Ok(())
257 }
258
259 /// Helper future for the broadcast-channel branch of the
260 /// event loop's `select!`. When no receiver is attached it
261 /// stays pending forever — the other branches still drive
262 /// the app normally. Keeps the select! macro readable
263 /// without conditional compilation tricks.
264 async fn next_engine_event(
265 rx: &mut Option<broadcast::Receiver<EngineEvent>>,
266 ) -> Result<EngineEvent, broadcast::error::RecvError> {
267 match rx.as_mut() {
268 Some(r) => r.recv().await,
269 None => std::future::pending().await,
270 }
271 }
272
273 /// If a friction-pause overlay is in the `Confirmed` state,
274 /// consume it and re-dispatch the pending command via the
275 /// bypass path. This is the only call site of
276 /// [`run_bypass_friction`] inside the TUI — keeping it here
277 /// preserves the rule that the dispatcher alone decides when
278 /// to skip the friction ladder.
279 async fn tick_friction(&mut self) {
280 let now = Instant::now();
281 if let Some(cmd) = self.state.take_confirmed_friction_command(now) {
282 let out = run_bypass_friction(&self.ctx, cmd).await;
283 self.state.apply_dispatch(out);
284 }
285 // M2 §4: after any friction-gate completion (which may
286 // have reopened the prompt), re-evaluate the engine
287 // mirror for L3+/guardrail-proximity and surface the
288 // Risk overlay. This runs every 100 ms tick *and* every
289 // input event so the overlay is visibly responsive
290 // without flooding — the rate-limiter inside
291 // `poll_risk_overlay` is what prevents re-open spam.
292 self.state.poll_risk_overlay(now);
293 }
294}