Skip to main content

netget/tui/
event_loop.rs

1//! Terminal lifecycle and the main select loop.
2//!
3//! All four legacy tick cadences are preserved: 100ms UI, 1s scheduled
4//! tasks + feedback, 1s stats/projection, 5s state reapers. The task tick in
5//! particular is load-bearing — without it scheduled tasks never fire.
6//!
7//! The loop itself ([`run_loop`]) is generic over the ratatui backend it draws
8//! to and the stream of input events it reads, and owns no terminal. [`run`]
9//! is the native front: raw mode, alternate screen, `CrosstermBackend` on
10//! stdout and crossterm's `EventStream`. The browser build
11//! (`crates/netget-web`) calls `run_loop` with a backend that emits ANSI into
12//! xterm.js and a channel of events built from DOM `KeyboardEvent`s, and the
13//! dashboard cannot tell the difference.
14
15#[cfg(not(target_arch = "wasm32"))]
16use std::io::{stdout, Stdout};
17use std::sync::Arc;
18use std::time::Duration;
19
20use anyhow::Result;
21#[cfg(not(target_arch = "wasm32"))]
22use crossterm::event::{DisableMouseCapture, EnableMouseCapture, EventStream};
23use crossterm::event::{Event, KeyEventKind};
24#[cfg(not(target_arch = "wasm32"))]
25use crossterm::execute;
26#[cfg(not(target_arch = "wasm32"))]
27use crossterm::terminal::{
28    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
29};
30use futures::{Stream, StreamExt};
31use ratatui::backend::Backend;
32#[cfg(not(target_arch = "wasm32"))]
33use ratatui::backend::CrosstermBackend;
34use ratatui::Terminal;
35use tokio::sync::{mpsc, Mutex};
36use tokio::time::interval;
37use tracing::{debug, info};
38
39use crate::events::EventHandler;
40use crate::llm::OllamaClient;
41use crate::settings::Settings;
42use crate::state::app_state::AppState;
43use crate::tui::app::DashboardApp;
44use crate::tui::chat::DRAIN_CAP_PER_FRAME;
45use crate::tui::keymap::{self, Outcome};
46use crate::tui::modal::Modal;
47use crate::tui::{projection, render};
48
49const CLEANUP_INTERVAL_SECS: u64 = 5;
50const SERVER_CLEANUP_TIMEOUT_SECS: u64 = 30;
51const CONNECTION_CLEANUP_TIMEOUT_SECS: u64 = 10;
52const CONNECTIONLESS_CLEANUP_TIMEOUT_SECS: u64 = 10;
53
54/// Restores the terminal on drop, so a panic cannot leave it wedged.
55#[cfg(not(target_arch = "wasm32"))]
56struct TerminalGuard;
57
58#[cfg(not(target_arch = "wasm32"))]
59impl Drop for TerminalGuard {
60    fn drop(&mut self) {
61        let _ = execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen);
62        let _ = disable_raw_mode();
63    }
64}
65
66pub struct LoopContext {
67    pub state: AppState,
68    pub event_handler: EventHandler,
69    pub llm_client: OllamaClient,
70    pub settings: Arc<Mutex<Settings>>,
71    pub status_tx: mpsc::UnboundedSender<String>,
72    pub status_rx: mpsc::UnboundedReceiver<String>,
73    pub web_approval_rx: mpsc::UnboundedReceiver<crate::state::app_state::WebApprovalRequest>,
74    /// Results of spawned create/update/send actions.
75    pub ui_rx: mpsc::UnboundedReceiver<crate::tui::uimsg::UiMsg>,
76}
77
78/// Run the dashboard on the process's own terminal.
79#[cfg(not(target_arch = "wasm32"))]
80pub async fn run(app: DashboardApp, ctx: LoopContext) -> Result<()> {
81    // Arm the native-crash restorer BEFORE raw mode, with the extra bytes that
82    // leave the alternate screen and mouse capture.
83    crate::cli::crash_restore::install(crate::cli::crash_restore::ALT_SCREEN_EXTRA);
84
85    enable_raw_mode()?;
86    let _guard = TerminalGuard;
87    execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
88
89    // A panic must restore the terminal before the message is printed, or the
90    // backtrace lands in the alternate screen and vanishes.
91    let default_hook = std::panic::take_hook();
92    std::panic::set_hook(Box::new(move |info| {
93        let _ = execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen);
94        let _ = disable_raw_mode();
95        default_hook(info);
96    }));
97
98    let backend = CrosstermBackend::new(stdout());
99    let mut terminal: Terminal<CrosstermBackend<Stdout>> = Terminal::new(backend)?;
100    terminal.clear()?;
101
102    let event_stream = EventStream::new();
103    run_loop(&mut terminal, event_stream, app, ctx).await
104}
105
106/// The dashboard loop over any backend and any source of input events.
107///
108/// Returns when the user quits or `events` ends. The caller owns the terminal
109/// and whatever it took to set it up.
110pub async fn run_loop<B, S>(
111    terminal: &mut Terminal<B>,
112    mut events: S,
113    mut app: DashboardApp,
114    mut ctx: LoopContext,
115) -> Result<()>
116where
117    B: Backend,
118    S: Stream<Item = std::io::Result<Event>> + Unpin,
119{
120    let mut ui_tick = interval(Duration::from_millis(100));
121    let mut task_tick = interval(Duration::from_secs(1));
122    task_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
123    let mut stats_tick = interval(Duration::from_secs(1));
124    let mut cleanup_tick = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
125
126    refresh_status(&mut app, &ctx.state).await;
127    let snapshot = projection::build_snapshot(&ctx.state).await;
128    app.absorb_snapshot(snapshot);
129
130    info!("Dashboard TUI started");
131
132    loop {
133        // Drain the status channel (bounded per iteration so a flood cannot
134        // starve rendering).
135        let mut drained = 0;
136        let mut needs_repoll = false;
137        while drained < DRAIN_CAP_PER_FRAME {
138            match ctx.status_rx.try_recv() {
139                Ok(line) => {
140                    if line == "__UPDATE_UI__" {
141                        needs_repoll = true;
142                    } else {
143                        route_line(&mut app, &line);
144                    }
145                    drained += 1;
146                }
147                Err(_) => break,
148            }
149        }
150        if needs_repoll {
151            let snapshot = projection::build_snapshot(&ctx.state).await;
152            app.absorb_snapshot(snapshot);
153        }
154
155        tokio::select! {
156            maybe_event = events.next() => {
157                match maybe_event {
158                    Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => {
159                        let outcome = keymap::handle_key(
160                            &mut app, key, &ctx.state, &ctx.event_handler, &ctx.status_tx,
161                        ).await;
162                        if matches!(outcome, Outcome::Quit) || app.should_quit {
163                            break;
164                        }
165                        // A UI-initiated mutation should show immediately.
166                        let snapshot = projection::build_snapshot(&ctx.state).await;
167                        app.absorb_snapshot(snapshot);
168                    }
169                    Some(Ok(Event::Mouse(mouse))) => {
170                        let outcome = keymap::handle_mouse(&mut app, mouse, &ctx.state).await;
171                        if matches!(outcome, Outcome::Quit) {
172                            break;
173                        }
174                        let snapshot = projection::build_snapshot(&ctx.state).await;
175                        app.absorb_snapshot(snapshot);
176                    }
177                    Some(Ok(Event::Resize(_, _))) => {
178                        app.dirty = true;
179                    }
180                    Some(Ok(_)) => {}
181                    Some(Err(e)) => {
182                        debug!("Dashboard input error: {e}");
183                    }
184                    None => break,
185                }
186            }
187            Some(msg) = ctx.ui_rx.recv() => {
188                keymap::handle_ui_msg(&mut app, msg);
189                let snapshot = projection::build_snapshot(&ctx.state).await;
190                app.absorb_snapshot(snapshot);
191            }
192            Some(request) = ctx.web_approval_rx.recv() => {
193                app.modals.push(Modal::WebApproval {
194                    url: request.url,
195                    response_tx: request.response_tx,
196                });
197                app.dirty = true;
198            }
199            _ = ui_tick.tick() => {
200                app.tick_llm_activity(ctx.state.llm_activity.snapshot());
201            }
202            _ = task_tick.tick() => {
203                // Load-bearing: without this scheduled tasks never fire.
204                crate::cli::execute_due_tasks_public(
205                    &ctx.state, &ctx.llm_client, &ctx.status_tx,
206                ).await;
207                crate::llm::feedback::execute_due_feedback(
208                    &ctx.state, &ctx.llm_client, &ctx.status_tx,
209                ).await;
210            }
211            _ = stats_tick.tick() => {
212                refresh_status(&mut app, &ctx.state).await;
213                let snapshot = projection::build_snapshot(&ctx.state).await;
214                app.absorb_snapshot(snapshot);
215                // Once a second, so a sample is bytes per second.
216                app.sample_metrics();
217            }
218            _ = cleanup_tick.tick() => {
219                ctx.state.cleanup_old_servers(SERVER_CLEANUP_TIMEOUT_SECS).await;
220                ctx.state.cleanup_closed_connections(CONNECTION_CLEANUP_TIMEOUT_SECS).await;
221                ctx.state.cleanup_old_connections(CONNECTIONLESS_CLEANUP_TIMEOUT_SECS).await;
222                ctx.state.cleanup_old_conversations().await;
223                app.dirty = true;
224            }
225        }
226
227        if app.dirty {
228            terminal.draw(|frame| render::draw(frame, &mut app))?;
229            app.dirty = false;
230        }
231    }
232
233    let _ = app.core.save_history();
234    // Persist any settings the toggles changed.
235    let _ = ctx.settings.lock().await;
236    Ok(())
237}
238
239/// Put one status-channel line into the stream: `[LEVEL]` lines as log
240/// entries (filtered by level), the model's reasoning and replies and
241/// command output as conversation (never filtered).
242fn route_line(app: &mut DashboardApp, line: &str) {
243    use crate::tui::chat::{route_status_line, Routed};
244    match route_status_line(line) {
245        Routed::Control => {}
246        Routed::Activity(level, text) => {
247            app.activity.push_log(level, text);
248            app.dirty = true;
249        }
250        Routed::Chat(kind, text) => {
251            app.push_chat(kind, text);
252        }
253    }
254}
255
256async fn refresh_status(app: &mut DashboardApp, state: &AppState) {
257    app.status.model = state.get_ollama_model().await.unwrap_or_default();
258    app.status.web_search = format!("{:?}", state.get_web_search_mode().await).to_uppercase();
259    app.status.handler_mode = format!("{:?}", state.get_event_handler_mode().await).to_uppercase();
260    app.status.scripting = state
261        .get_selected_scripting_mode()
262        .await
263        .as_str()
264        .to_string();
265    let (input, output, calls) = state.get_llm_stats().await;
266    app.status.input_tokens = input;
267    app.status.output_tokens = output;
268    app.status.llm_calls = calls;
269    app.status.active_conversations = state.llm_activity.snapshot().len();
270}