1mod graph;
4mod helpers;
5mod input;
6mod mcp;
7mod render;
8mod selection;
9mod state;
10#[cfg(test)]
11mod test_support;
12mod types;
13
14use crate::tui::theme;
19
20pub use helpers::yank_to_clipboard_via;
21pub use types::{AgentDisplayStatus, DashboardAgent, DashboardArgs};
22
23pub use crate::tui::{CrosstermEventSource, EventSource, TerminalSetup};
28
29use crossterm::event::{Event, KeyEventKind};
30use leviath_runtime::control_socket::{ControlClient, ControlRequest, ControlResponse};
31use ratatui::Terminal;
32use std::time::Duration;
33use tokio::sync::mpsc;
34
35use state::Dashboard;
36use types::DaemonCommand;
37
38async fn daemon_background_loop(
42 control: ControlClient,
43 mut cmd_rx: mpsc::UnboundedReceiver<DaemonCommand>,
44 outcomes: mpsc::UnboundedSender<types::DaemonOutcome>,
45) {
46 while let Some(cmd) = cmd_rx.recv().await {
47 let (run_id, request, what) = match cmd {
48 DaemonCommand::Cancel { run_id } => {
49 (run_id.clone(), ControlRequest::Cancel { run_id }, "cancel")
50 }
51 DaemonCommand::Answer { response } => (
52 response.request_id.clone(),
53 ControlRequest::AnswerInteraction { response },
54 "answer",
55 ),
56 DaemonCommand::Message { agent_id, content } => (
57 agent_id.clone(),
58 ControlRequest::Message {
59 agent_id,
60 content,
61 target_region: None,
62 },
63 "message",
64 ),
65 };
66 let outcome = match control.request(&request).await {
69 Ok(ControlResponse::Ok { ok: true }) => types::DaemonOutcome {
70 run_id,
71 message: String::new(),
72 ok: true,
73 },
74 Ok(ControlResponse::Ok { ok: false }) => types::DaemonOutcome {
75 run_id,
76 message: format!("the daemon has no such run to {what}"),
77 ok: false,
78 },
79 Ok(other) => types::DaemonOutcome {
80 run_id,
81 message: format!("unexpected daemon response to {what}: {other:?}"),
82 ok: false,
83 },
84 Err(e) => types::DaemonOutcome {
85 run_id,
86 message: format!("{what} failed: {e}"),
87 ok: false,
88 },
89 };
90 if outcomes.send(outcome).is_err() {
92 return;
93 }
94 }
95}
96
97async fn execute_core<S: TerminalSetup, E: EventSource>(
107 dashboard: &mut Dashboard,
108 control: &ControlClient,
109 setup: &mut S,
110 events: &mut E,
111) -> anyhow::Result<()> {
112 setup.enable()?;
113 let mut terminal = setup.create_terminal()?;
114 let tick_rate = Duration::from_millis(100);
115 run_dashboard_loop(dashboard, control, &mut terminal, events, tick_rate).await?;
116 setup.disable();
117 setup.print_done();
118 Ok(())
119}
120
121async fn run_dashboard_loop<B: ratatui::backend::Backend>(
143 dashboard: &mut Dashboard,
144 control: &ControlClient,
145 terminal: &mut Terminal<B>,
146 events: &mut impl EventSource,
147 tick_rate: Duration,
148) -> anyhow::Result<()> {
149 loop {
150 dashboard.tick_count += 1;
151 dashboard.tick_toasts();
152
153 dashboard.sync_interactions(control).await;
156
157 dashboard.sync_daemon_runs(control).await;
160
161 dashboard.sync_from_run_state();
164
165 dashboard.drain_mcp_outcomes();
167
168 dashboard.drain_daemon_outcomes();
170
171 terminal
173 .draw(|frame| dashboard.draw(frame))
174 .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
175
176 if let Some(event) = events.poll_event(tick_rate)? {
178 match event {
179 Event::Key(key) if key.kind == KeyEventKind::Press => {
180 dashboard.handle_key(key);
181 }
182 Event::Mouse(m) => dashboard.handle_mouse(m),
185 Event::Resize(_, _) => {
186 }
188 _ => {}
189 }
190 }
191
192 if dashboard.should_quit {
193 return Ok(());
194 }
195 }
196}
197
198fn init_dashboard(control: ControlClient, yank_fn: fn(&str) -> bool) -> Dashboard {
203 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
204 let mcp_ctx = types::McpContext {
207 config_path: crate::config::Config::config_path(),
208 store_path: leviath_mcp::AuthStore::default_path().unwrap_or_default(),
209 opener: std::sync::Arc::new(leviath_sys::open_url),
210 clock: mcp_system_now,
211 };
212 let mut dashboard = Dashboard::new_with_log_path(
213 cmd_tx,
214 crate::runstate::dashboard_log_path(),
215 yank_fn,
216 mcp_ctx,
217 );
218
219 let daemon_outcome_tx = dashboard
223 .take_daemon_outcome_tx()
224 .expect("a fresh dashboard has its daemon outcome sender");
225 tokio::spawn(daemon_background_loop(control, cmd_rx, daemon_outcome_tx));
226
227 let (mcp_cmd_rx, mcp_outcome_tx) = dashboard
230 .take_mcp_bg_ends()
231 .expect("a fresh dashboard has its MCP background channel ends");
232 tokio::spawn(mcp::mcp_background_loop(
233 dashboard.mcp_context(),
234 mcp_cmd_rx,
235 mcp_outcome_tx,
236 ));
237
238 dashboard.add_log("Dashboard started. Use `lev run <agent>` to start an agent.".to_string());
239
240 dashboard
241}
242
243fn mcp_system_now() -> u64 {
245 std::time::SystemTime::now()
246 .duration_since(std::time::UNIX_EPOCH)
247 .map(|d| d.as_secs())
248 .unwrap_or(0)
249}
250
251pub async fn execute_with<S: TerminalSetup, E: EventSource>(
267 control: ControlClient,
268 setup: &mut S,
269 events: &mut E,
270 yank_fn: fn(&str) -> bool,
271) -> anyhow::Result<()> {
272 let mut dashboard = init_dashboard(control.clone(), yank_fn);
273 execute_core(&mut dashboard, &control, setup, events).await
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 use crate::commands::dashboard::test_support::make_test_dashboard;
281
282 #[test]
283 fn dashboard_args_can_be_constructed() {
284 let _args = DashboardArgs {};
285 }
286
287 #[test]
288 fn mcp_system_now_advances_past_the_epoch() {
289 assert!(mcp_system_now() > 1_600_000_000);
290 }
291
292 #[test]
293 fn agent_display_status_variants_display() {
294 let statuses = vec![
295 AgentDisplayStatus::Active,
296 AgentDisplayStatus::Waiting,
297 AgentDisplayStatus::Complete,
298 AgentDisplayStatus::CompleteInteractive,
299 AgentDisplayStatus::Error("test error".to_string()),
300 AgentDisplayStatus::Idle,
301 AgentDisplayStatus::Cancelled,
302 ];
303 for status in statuses {
304 let display = format!("{}", status);
305 assert!(!display.is_empty());
306 }
307 }
308
309 fn no_daemon_control() -> ControlClient {
312 let dir = std::env::temp_dir().join("leviath-dash-no-daemon");
313 ControlClient::new(leviath_runtime::control_socket::control_id(&dir))
314 }
315
316 fn recording_daemon(dir: &std::path::Path) -> (ControlClient, tokio::task::JoinHandle<String>) {
319 use leviath_runtime::control_socket::{bind_control_listener, control_id};
320 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
321 let id = control_id(dir);
322 let mut listener = bind_control_listener(&id).unwrap();
323 let handle = tokio::spawn(async move {
324 let stream = listener
325 .accept()
326 .await
327 .expect("accept succeeds")
328 .expect("our own connection is admitted");
329 let (read_half, mut write_half) = tokio::io::split(stream);
330 let mut lines = BufReader::new(read_half).lines();
331 let req = lines.next_line().await.unwrap().unwrap_or_default();
332 write_half
333 .write_all(b"{\"result\":\"ok\",\"ok\":true}\n")
334 .await
335 .unwrap();
336 req
337 });
338 (ControlClient::new(id), handle)
339 }
340
341 fn replying_daemon(
344 dir: &std::path::Path,
345 reply: Option<&'static str>,
346 ) -> (ControlClient, tokio::task::JoinHandle<()>) {
347 use leviath_runtime::control_socket::{bind_control_listener, control_id};
348 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
349 let id = control_id(dir);
350 let mut listener = bind_control_listener(&id).unwrap();
351 let handle = tokio::spawn(async move {
352 let stream = listener
353 .accept()
354 .await
355 .expect("accept succeeds")
356 .expect("our own connection is admitted");
357 let (read_half, mut write_half) = tokio::io::split(stream);
358 let mut lines = BufReader::new(read_half).lines();
359 let _ = lines.next_line().await;
360 if let Some(reply) = reply {
361 let _ = write_half.write_all(format!("{reply}\n").as_bytes()).await;
362 }
363 });
364 (ControlClient::new(id), handle)
365 }
366
367 async fn cancel_outcome(reply: Option<&'static str>) -> types::DaemonOutcome {
369 let dir = tempfile::tempdir().unwrap();
370 let (control, server) = replying_daemon(dir.path(), reply);
371 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
372 let (out_tx, mut out_rx) = mpsc::unbounded_channel();
373 tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
374 cmd_tx
375 .send(DaemonCommand::Cancel {
376 run_id: "run-1".to_string(),
377 })
378 .unwrap();
379 let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv())
380 .await
381 .expect("an outcome was reported")
382 .expect("the loop is alive");
383 let _ = server.await;
384 outcome
385 }
386
387 #[tokio::test]
390 async fn daemon_background_loop_reports_each_outcome() {
391 let ok = cancel_outcome(Some(r#"{"result":"ok","ok":true}"#)).await;
392 assert!(ok.ok, "an applied cancel is reported as success");
393 assert_eq!(ok.run_id, "run-1");
394
395 let missing = cancel_outcome(Some(r#"{"result":"ok","ok":false}"#)).await;
396 assert!(!missing.ok);
397 assert!(missing.message.contains("no such run to cancel"));
398
399 let odd = cancel_outcome(Some(r#"{"result":"spawned","run_id":"x"}"#)).await;
400 assert!(!odd.ok);
401 assert!(odd.message.contains("unexpected daemon response"));
402
403 let broken = cancel_outcome(None).await;
405 assert!(!broken.ok);
406 assert!(
407 broken.message.contains("cancel failed"),
408 "got: {}",
409 broken.message
410 );
411 }
412
413 #[tokio::test]
416 async fn daemon_background_loop_exits_when_the_dashboard_is_gone() {
417 let dir = tempfile::tempdir().unwrap();
418 let (control, _server) = replying_daemon(dir.path(), Some(r#"{"result":"ok","ok":true}"#));
419 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
420 let (out_tx, out_rx) = mpsc::unbounded_channel();
421 drop(out_rx); let handle = tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
423 cmd_tx
424 .send(DaemonCommand::Cancel {
425 run_id: "run-1".to_string(),
426 })
427 .unwrap();
428 tokio::time::timeout(std::time::Duration::from_secs(5), handle)
429 .await
430 .expect("the loop returned")
431 .unwrap();
432 }
433
434 #[tokio::test]
437 async fn init_dashboard_seeds_startup_log_and_forwards_commands() {
438 crate::runstate::with_isolated_runs_dir_async(
439 "init_dashboard_seeds_startup_log",
440 |_d| async move {
441 let dashboard = init_dashboard(no_daemon_control(), |_| false);
442 assert!(
443 dashboard
444 .log
445 .iter()
446 .any(|entry| entry.message.contains("Dashboard started"))
447 );
448 dashboard
451 .cmd_tx
452 .send(DaemonCommand::Cancel {
453 run_id: "nope".to_string(),
454 })
455 .unwrap();
456 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
457 },
458 )
459 .await;
460 }
461
462 #[tokio::test]
465 async fn daemon_background_loop_forwards_cancel() {
466 let dir = tempfile::tempdir().unwrap();
467 let (control, server) = recording_daemon(dir.path());
468 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
469 let (out_tx, _out_rx) = mpsc::unbounded_channel();
470 tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
471 cmd_tx
472 .send(DaemonCommand::Cancel {
473 run_id: "run-1".to_string(),
474 })
475 .unwrap();
476 let req = server.await.unwrap();
477 assert!(req.contains("cancel"));
478 assert!(req.contains("run-1"));
479 }
480
481 #[tokio::test]
482 async fn daemon_background_loop_forwards_answer() {
483 let dir = tempfile::tempdir().unwrap();
484 let (control, server) = recording_daemon(dir.path());
485 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
486 let (out_tx, _out_rx) = mpsc::unbounded_channel();
487 tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
488 cmd_tx
489 .send(DaemonCommand::Answer {
490 response: leviath_core::interaction::InteractionResponse::text("q1", "yes"),
491 })
492 .unwrap();
493 let req = server.await.unwrap();
494 assert!(req.contains("answer_interaction"));
495 assert!(req.contains("q1"));
496 }
497
498 #[tokio::test]
499 async fn daemon_background_loop_forwards_message() {
500 let dir = tempfile::tempdir().unwrap();
501 let (control, server) = recording_daemon(dir.path());
502 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
503 let (out_tx, _out_rx) = mpsc::unbounded_channel();
504 tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
505 cmd_tx
506 .send(DaemonCommand::Message {
507 agent_id: "a1".to_string(),
508 content: "hi there".to_string(),
509 })
510 .unwrap();
511 let req = server.await.unwrap();
512 assert!(req.contains("message"));
513 assert!(req.contains("hi there"));
514 }
515
516 #[tokio::test]
517 async fn daemon_background_loop_exits_when_channel_dropped() {
518 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
519 let (out_tx, _out_rx) = mpsc::unbounded_channel();
520 let handle = tokio::spawn(daemon_background_loop(no_daemon_control(), cmd_rx, out_tx));
521 drop(cmd_tx);
522 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
523 assert!(result.is_ok());
524 }
525
526 #[test]
529 fn dashboard_new_and_initial_state() {
530 let dash = make_test_dashboard();
531 assert!(!dash.should_quit);
532 assert!(!dash.detail_view);
533 assert!(!dash.show_help);
534 }
535
536 #[test]
537 fn dashboard_draw_renders_without_panic() {
538 use ratatui::Terminal;
539 use ratatui::backend::TestBackend;
540 let backend = TestBackend::new(120, 40);
541 let mut terminal = Terminal::new(backend).unwrap();
542 let mut dash = make_test_dashboard();
543 terminal.draw(|f| dash.draw(f)).unwrap();
544 }
545
546 #[test]
547 fn dashboard_agent_struct_fields_from_mod() {
548 let agent = DashboardAgent {
549 id: "run-test".to_string(),
550 blueprint_name: "tester".to_string(),
551 stage: "init".to_string(),
552 stage_index: 0,
553 num_stages: 1,
554 status: AgentDisplayStatus::Idle,
555 tokens_in: 0,
556 tokens_out: 0,
557 cached_tokens: 0,
558 iteration: 0,
559 waiting_prompt: None,
560 pending_request: None,
561 last_answered_request_id: None,
562 context_snapshot: None,
563 stages: vec![],
564 workdir: "/tmp".to_string(),
565 task: "test task".to_string(),
566 title: None,
567 model: None,
568 parent_id: None,
569 depth: 0,
570 started_at: 0,
571 active_until: None,
572 waiting_secs: 0,
573 graph_info: None,
574 accepts_messages: false,
575 taint_summary: vec![],
576 };
577 assert_eq!(agent.id, "run-test");
578 assert_eq!(agent.blueprint_name, "tester");
579 assert_eq!(agent.stage, "init");
580 }
581
582 use crate::tui::{TestBackendHarness, TestEventSource, TestSetup, key, test_terminal};
594 use crossterm::event::KeyCode;
595
596 #[tokio::test]
597 async fn run_dashboard_loop_quits_on_esc_from_main_list() {
598 let mut dashboard = make_test_dashboard();
599 let control = no_daemon_control();
600 let mut terminal = test_terminal();
601 let mouse = |kind, column, row| {
606 Event::Mouse(crossterm::event::MouseEvent {
607 kind,
608 column,
609 row,
610 modifiers: crossterm::event::KeyModifiers::NONE,
611 })
612 };
613 use crossterm::event::{MouseButton, MouseEventKind};
614 let mut events = TestEventSource::new(vec![
615 Event::Resize(80, 24),
616 mouse(MouseEventKind::ScrollUp, 0, 0),
617 mouse(MouseEventKind::ScrollDown, 0, 0),
618 mouse(MouseEventKind::Moved, 0, 0),
621 mouse(MouseEventKind::Down(MouseButton::Left), 3, 15),
622 mouse(MouseEventKind::Drag(MouseButton::Left), 20, 16),
623 mouse(MouseEventKind::Up(MouseButton::Left), 20, 16),
624 key(KeyCode::Esc),
625 ]);
626
627 let result = run_dashboard_loop(
628 &mut dashboard,
629 &control,
630 &mut terminal,
631 &mut events,
632 Duration::from_millis(1),
633 )
634 .await;
635
636 assert!(result.is_ok());
637 assert!(dashboard.should_quit);
638 }
639
640 #[tokio::test]
641 async fn run_dashboard_loop_no_event_tick_then_quits() {
642 let mut dashboard = make_test_dashboard();
646 let control = no_daemon_control();
647 let mut terminal = test_terminal();
648 let mut events = TestEventSource::new_with_nones(vec![None, Some(key(KeyCode::Esc))]);
651
652 let result = run_dashboard_loop(
653 &mut dashboard,
654 &control,
655 &mut terminal,
656 &mut events,
657 Duration::from_millis(1),
658 )
659 .await;
660
661 assert!(result.is_ok());
662 assert!(dashboard.should_quit);
663 }
664
665 #[tokio::test]
666 async fn run_dashboard_loop_ignores_non_press_and_other_events() {
667 let mut dashboard = make_test_dashboard();
670 let control = no_daemon_control();
671 let mut terminal = test_terminal();
672 let release = Event::Key(crossterm::event::KeyEvent::new_with_kind(
673 KeyCode::Char('x'),
674 crossterm::event::KeyModifiers::empty(),
675 crossterm::event::KeyEventKind::Release,
676 ));
677 let mut events = TestEventSource::new(vec![release, Event::FocusGained, key(KeyCode::Esc)]);
678
679 let result = run_dashboard_loop(
680 &mut dashboard,
681 &control,
682 &mut terminal,
683 &mut events,
684 Duration::from_millis(1),
685 )
686 .await;
687
688 assert!(result.is_ok());
689 assert!(dashboard.should_quit);
690 }
691
692 #[tokio::test]
693 async fn run_dashboard_loop_propagates_event_source_error() {
694 let mut dashboard = make_test_dashboard();
695 let control = no_daemon_control();
696 let mut terminal = test_terminal();
697 let mut events = TestEventSource::failing();
698
699 let result = run_dashboard_loop(
700 &mut dashboard,
701 &control,
702 &mut terminal,
703 &mut events,
704 Duration::from_millis(1),
705 )
706 .await;
707
708 assert!(result.is_err());
709 }
710
711 #[tokio::test]
730 async fn run_dashboard_loop_propagates_draw_error() {
731 let mut dashboard = make_test_dashboard();
736 let control = no_daemon_control();
737 let mut terminal = Terminal::new(TestBackendHarness::failing(120, 40)).unwrap();
738 let mut events = TestEventSource::new(vec![]); let result = run_dashboard_loop(
741 &mut dashboard,
742 &control,
743 &mut terminal,
744 &mut events,
745 Duration::from_millis(1),
746 )
747 .await;
748
749 assert!(result.is_err());
750 }
751
752 #[tokio::test]
776 async fn execute_core_happy_path_quits_on_esc() {
777 crate::runstate::with_isolated_runs_dir_async(
778 "execute_core_happy_path_quits_on_esc",
779 |_d| async move {
780 let control = no_daemon_control();
781 let mut dashboard = init_dashboard(control.clone(), |_| false);
782 let mut setup = TestSetup::new();
783 let mut events = TestEventSource::new(vec![key(KeyCode::Esc)]);
784 let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
785 assert!(result.is_ok());
786 assert!(dashboard.should_quit);
787 },
788 )
789 .await;
790 }
791
792 #[tokio::test]
793 async fn execute_with_loads_config_inits_and_runs_the_loop() {
794 crate::config::with_isolated_config_path_async(
799 "execute_with_dashboard",
800 |_fake_dir| async move {
801 crate::runstate::with_isolated_runs_dir_async(
802 "execute_with_dashboard",
803 |_d| async move {
804 let mut setup = TestSetup::new();
805 let mut events = TestEventSource::new(vec![key(KeyCode::Esc)]);
806 let result =
807 execute_with(no_daemon_control(), &mut setup, &mut events, |_| false)
808 .await;
809 assert!(result.is_ok());
810 },
811 )
812 .await;
813 },
814 )
815 .await;
816 }
817
818 #[tokio::test]
819 async fn execute_core_enable_error_propagates() {
820 crate::runstate::with_isolated_runs_dir_async(
821 "execute_core_enable_error_propagates",
822 |_d| async move {
823 let control = no_daemon_control();
824 let mut dashboard = init_dashboard(control.clone(), |_| false);
825 let mut setup = TestSetup {
827 enable_should_fail: true,
828 create_should_fail: false,
829 };
830 let mut events = TestEventSource::new(vec![]);
831 let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
832 assert!(result.is_err());
833 },
834 )
835 .await;
836 }
837
838 #[tokio::test]
839 async fn execute_core_create_terminal_error_propagates() {
840 crate::runstate::with_isolated_runs_dir_async(
841 "execute_core_create_terminal_error_propagates",
842 |_d| async move {
843 let control = no_daemon_control();
844 let mut dashboard = init_dashboard(control.clone(), |_| false);
845 let mut setup = TestSetup {
848 enable_should_fail: false,
849 create_should_fail: true,
850 };
851 let mut events = TestEventSource::new(vec![]);
852 let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
853 assert!(result.is_err());
854 },
855 )
856 .await;
857 }
858
859 #[tokio::test]
860 async fn execute_core_loop_error_propagates() {
861 crate::runstate::with_isolated_runs_dir_async(
862 "execute_core_loop_error_propagates",
863 |_d| async move {
864 let control = no_daemon_control();
865 let mut dashboard = init_dashboard(control.clone(), |_| false);
866 let mut setup = TestSetup::new();
867 let mut events = TestEventSource::failing();
868 let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
869 assert!(result.is_err());
870 },
871 )
872 .await;
873 }
874
875 }