Skip to main content

leviath_cli/commands/dashboard/
mod.rs

1//! `lev dash` - Interactive terminal UI for managing concurrent agents.
2
3mod graph;
4mod helpers;
5mod input;
6mod mcp;
7mod render;
8mod selection;
9mod state;
10#[cfg(test)]
11mod test_support;
12mod types;
13
14/// The palette lives in the crate-level [`crate::tui`] module, shared with the
15/// `lev setup` wizard and the markdown renderer. Aliased here so the existing
16/// `crate::commands::dashboard::theme::*` imports across `render/` keep
17/// resolving unchanged.
18use crate::tui::theme;
19
20pub use helpers::yank_to_clipboard_via;
21pub use types::{AgentDisplayStatus, DashboardAgent, DashboardArgs};
22
23/// The terminal seams are crate-level too ([`crate::tui`]) now that `lev setup`
24/// is a second ratatui surface driving the same `CrosstermSetup` from the
25/// binary. Re-exported here because `main.rs` and the dashboard tests import
26/// them through this path.
27pub 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
38/// Background task that forwards the dashboard's control commands (cancel /
39/// answer-interaction / message) to the shared-world daemon over the control
40/// socket. The dashboard is a pure client: it never drives agents itself.
41async 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        // Report what actually happened. Discarding this would make a cancel
67        // the daemon refused indistinguishable from one that worked.
68        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        // A closed receiver means the dashboard has exited; nothing to report to.
91        if outcomes.send(outcome).is_err() {
92            return;
93        }
94    }
95}
96
97/// Terminal-independent core: runs the dashboard event loop after terminal
98/// setup, driven in tests via [`TerminalSetup`] + [`EventSource`] without a real
99/// TTY.
100///
101/// Generic over `S: TerminalSetup` and `E: EventSource`. The only
102/// `TerminalSetup` in the library is the test double [`TestSetup`] (the real
103/// `CrosstermSetup` lives in the binary), so every monomorphization here - and
104/// in [`run_dashboard_loop`] - runs against a `ratatui::backend::TestBackend`
105/// with canned events, keeping the whole function covered.
106async 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
121/// The dashboard's per-tick render/input loop, extracted from [`execute`] so
122/// it can run against a [`ratatui::backend::TestBackend`] and a canned
123/// [`EventSource`] in tests, instead of a real terminal. Exits (returning
124/// `Ok(())`) once `dashboard.should_quit` is set; propagates the first I/O
125/// error from drawing or event polling, leaving raw mode / the alternate
126/// screen untouched on error - restoring those is `execute`'s
127/// responsibility, not this loop's.
128///
129/// Generic over `B: Backend` and `impl EventSource`; in the measured test
130/// build it is only ever instantiated once - with the single
131/// [`TestBackendHarness`] backend and the single [`TestEventSource`] (both
132/// carry an injectable-failure switch, so the draw-error and poll-error `?`
133/// arms are exercised within that one monomorphization), never a real
134/// terminal backend.
135///
136/// The draw error is mapped explicitly rather than propagated with a bare `?`.
137/// On ratatui 0.29 `Backend::Error` is `io::Error` and either works; from 0.30
138/// it becomes an associated type with no `Send + Sync` bound, and a bare `?`
139/// into `anyhow::Error` stops compiling. Mapping here keeps this loop working
140/// across that change without a `B::Error: Send + Sync + 'static` bound that
141/// would have to be repeated on every caller and on `TerminalSetup::B`.
142async 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        // Pull the daemon's open interactions (best-effort; ignore if the daemon
154        // is unreachable) so waiting agents show their prompt.
155        dashboard.sync_interactions(control).await;
156
157        // …and which runs it actually holds, so a run on disk that nothing is
158        // driving can be shown as stale rather than ACTIVE.
159        dashboard.sync_daemon_runs(control).await;
160
161        // Sync background runs from on-disk run-state dir (the daemon persists
162        // meta/context/stages there).
163        dashboard.sync_from_run_state();
164
165        // Surface any completed MCP login/test as a toast.
166        dashboard.drain_mcp_outcomes();
167
168        // Report what the daemon did with this tick's commands.
169        dashboard.drain_daemon_outcomes();
170
171        // Draw
172        terminal
173            .draw(|frame| dashboard.draw(frame))
174            .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
175
176        // Handle input
177        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                // Wheel scrolling and click-drag text selection, handled in one
183                // place (`selection.rs`) so they cannot disagree about state.
184                Event::Mouse(m) => dashboard.handle_mouse(m),
185                Event::Resize(_, _) => {
186                    // Terminal will redraw automatically on next tick
187                }
188                _ => {}
189            }
190        }
191
192        if dashboard.should_quit {
193            return Ok(());
194        }
195    }
196}
197
198/// Builds the [`Dashboard`], starts the daemon-control background loop, and
199/// seeds the startup log line. Split out of [`execute`] purely so this
200/// (entirely terminal-independent) setup is unit-testable on its own, separate
201/// from the real-terminal I/O sliver.
202fn init_dashboard(control: ControlClient, yank_fn: fn(&str) -> bool) -> Dashboard {
203    let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
204    // The real MCP screen operates on the user's config + token store, opens a
205    // real browser for login, and reads the wall clock.
206    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    // Forward the dashboard's control commands to the daemon, and report each
220    // result back so a refused command is surfaced rather than swallowed. A
221    // freshly-built dashboard always has its outcome sender.
222    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    // Run MCP logins/tests off the UI loop. A freshly-built dashboard always
228    // has its background channel ends.
229    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
243/// Wall-clock Unix time in seconds, for the production MCP context.
244fn 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
251/// Load config, build the dashboard + engine, and run the event loop against
252/// the injected [`TerminalSetup`] and [`EventSource`]. This is the whole
253/// `lev dash` command minus the two real-terminal doubles - so it is fully
254/// unit-testable (drive it with `TestSetup` + a canned `TestEventSource`), and
255/// the binary's `real_dashboard` supplies the real crossterm `CrosstermSetup`
256/// + [`CrosstermEventSource`].
257///
258/// The real terminal wiring cannot live here: constructing `CrosstermSetup`
259/// enables actual raw mode / the alternate screen and blocks forever on real
260/// keyboard input (an `is_terminal()` guard does not prevent the hang - it
261/// still hangs a real editor terminal full-screen). That irreducible sliver is
262/// the binary's job; everything it composes is exercised here.
263/// `yank_fn` is the clipboard implementation the dashboard's `y` keypress uses;
264/// the binary passes the real native-tool/OSC52 clipboard (which can write the
265/// real terminal), tests pass a no-op.
266pub 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    /// A control client pointing at a socket with no daemon behind it; requests
310    /// fail fast, which the dashboard treats as "nothing to observe".
311    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    /// A fake daemon that accepts one connection, records the request line it
317    /// receives, replies `{"result":"ok","ok":true}`, and returns the request.
318    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    /// A daemon that replies with `reply` (verbatim, newline added) to one
342    /// request. `None` closes the connection without replying.
343    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    /// Drive one cancel through the loop and return the reported outcome.
368    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    /// Every response shape the daemon can give is reported back, so the
388    /// dashboard can tell a kill that worked from one that did not.
389    #[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        // Connection closed with no reply → a transport error, surfaced as such.
404        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    /// The loop stops when the dashboard has gone away, rather than spinning on
414    /// a channel nobody reads.
415    #[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); // the dashboard exited
422        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    // ─── init_dashboard ──────────────────────────────────────────────────
435
436    #[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                // The background loop is live: a command on the dashboard's own
449                // cmd_tx is accepted (delivered to the unreachable daemon).
450                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    // ─── daemon_background_loop ───────────────────────────────────────────
463
464    #[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    // ─── Dashboard basic integration ──────────────────────────────────────
527
528    #[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    // ─── run_dashboard_loop ─────────────────────────────────────────────────
583    //
584    // The terminal doubles these tests drive (`TestEventSource`,
585    // `TestBackendHarness`, `TestSetup`, `key`) are crate-level, in
586    // [`crate::tui`], and shared with the `lev setup` wizard's tests. Keeping
587    // exactly one implementation of each is load-bearing for coverage: it means
588    // [`execute_core`] and [`run_dashboard_loop`] monomorphize over a single
589    // concrete backend / event source in the measured test build, so
590    // `cargo-llvm-cov`'s per-instantiation region report has no partially
591    // covered sibling monomorphization to undercount.
592
593    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        // A no-op Resize tick, then both wheel directions, a full
602        // press-drag-release selection over the log panel, and the Esc that
603        // triggers quit - covers every arm of the event match, including the
604        // mouse one that carries scrolling and selection.
605        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            // Cursor motion without a button is not a gesture and must be
619            // ignored rather than moving the view under the user.
620            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        // Tick 1: `poll_event` returns `None` (simulated poll-timeout - no input
643        // pending); tick 2: Esc quits.  The `None` entry exercises the
644        // `if let Some(event)` fallthrough path (line 127 in mod.rs).
645        let mut dashboard = make_test_dashboard();
646        let control = no_daemon_control();
647        let mut terminal = test_terminal();
648        // `None` entry → poll returns Ok(None) on tick 1 (no-event path);
649        // `Some(Esc)` → poll returns Ok(Some(Esc)) on tick 2 → quit.
650        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        // A key release (not Press) and a mouse-like "other" event are both
668        // ignored by the `_ => {}` arm; only the trailing Esc actually quits.
669        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    // `crossterm::event::poll` cannot be called from a real unit test: it
712    // hangs for 60+ seconds under a real pty, even in complete isolation
713    // (`--test-threads=1`, nothing else running). Root cause:
714    // `crossterm::event::poll`'s internal
715    // `INTERNAL_EVENT_READER` is a lazily-constructed, process-wide
716    // singleton (`parking_lot::Mutex<Option<InternalEventReader>>`); the
717    // passed timeout only bounds *acquiring that mutex*
718    // (`try_lock_for(timeout)`), not the one-time construction of the
719    // underlying `mio`-based event source that happens the first time it's
720    // ever used in the process, nor whatever `mio::Poll::poll` actually
721    // observes against a `script`-allocated pty's fd. There is no
722    // "1ms-bounded, side-effect-free" way to touch real crossterm event
723    // polling from a test at all - so this doesn't get a test, matching
724    // every other real-terminal entry point in this file (`execute`,
725    // `open_controlling_tty` equivalent, etc.).
726
727    // ─── draw-error propagation ─────────────────────────────────────────────
728
729    #[tokio::test]
730    async fn run_dashboard_loop_propagates_draw_error() {
731        // Exercises the `terminal.draw(…)?` error-propagation path using the
732        // single `TestBackendHarness` backend with `fail_draw` set, so this
733        // shares run_dashboard_loop's one monomorphization with the
734        // success-path tests (the draw `?` has both arms covered there).
735        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![]); // never reached
739
740        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    // Caveat for anyone tempted to bound `run_dashboard_loop` with a
753    // `tokio::time::timeout`: its `loop { ... }` has no `.await` point
754    // (`poll_event`, `try_lock`, `terminal.draw` are all synchronous), so on
755    // the default current-thread `#[tokio::test]` runtime the executor's
756    // single thread never regains control long enough for the timeout's own
757    // timer to fire - a future that never yields can't be preempted by a
758    // sibling future racing it. In a non-TTY sandbox
759    // `CrosstermEventSource::poll_event` fails immediately (so it looks
760    // bounded in headless testing); on a real terminal, with no scripted key
761    // ever setting `should_quit`, it hangs indefinitely.
762
763    // `CrosstermEventSource`'s own poll/read branches and
764    // `TestBackendHarness`'s delegated trait methods are covered where those
765    // types now live, in `crate::tui`.
766
767    // ─── execute_core / TestSetup ───────────────────────────────────────────
768    //
769    // `execute_core` and the `run_dashboard_loop` it calls are generic over
770    // `TerminalSetup`/`EventSource`. The only `TerminalSetup` in the library is
771    // the [`TestSetup`] double (the real `CrosstermSetup` lives in the binary),
772    // so these tests drive every arm of `execute_core` against a `TestBackend`
773    // deterministically, without touching a real terminal.
774
775    #[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        // Drives the whole `execute_with` composition root (Config::load +
795        // init_dashboard + execute_core) against the test terminal doubles,
796        // with both the config path and the dashboard log path isolated so
797        // nothing touches the developer's real ~/.leviath.
798        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                // `setup.enable()?` fails first, so the loop is never reached.
826                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                // `enable()` succeeds, then `create_terminal()?` fails - deterministic
846                // (no real backend / TTY involved), so this can never hang.
847                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    // The real `lev dash` wiring (the crossterm `CrosstermSetup` + the real
876    // `CrosstermEventSource` + the `Config::load`/`init_dashboard`/`execute_core`
877    // composition) lives in the binary's `real_dashboard`; the fully-tested
878    // seam it composes, `execute_with`, is covered by
879    // `execute_with_loads_config_inits_and_runs_the_loop` above.
880}