Skip to main content

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}