1#[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#[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 pub ui_rx: mpsc::UnboundedReceiver<crate::tui::uimsg::UiMsg>,
76}
77
78#[cfg(not(target_arch = "wasm32"))]
80pub async fn run(app: DashboardApp, ctx: LoopContext) -> Result<()> {
81 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 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
106pub 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 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 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 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 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 let _ = ctx.settings.lock().await;
236 Ok(())
237}
238
239fn 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}