Skip to main content

leviath_cli/commands/dashboard/
mod.rs

1//! `lev dash` - Interactive terminal UI for managing concurrent agents.
2
3mod context_tree;
4mod graph;
5mod graph_layout;
6mod helpers;
7mod history;
8mod input;
9mod mcp;
10mod render;
11mod selection;
12mod state;
13#[cfg(test)]
14mod test_support;
15mod types;
16
17/// The palette lives in the crate-level [`crate::tui`] module, shared with the
18/// `lev setup` wizard and the markdown renderer. Aliased here so the existing
19/// `crate::commands::dashboard::theme::*` imports across `render/` keep
20/// resolving unchanged.
21use crate::tui::theme;
22
23pub use helpers::yank_to_clipboard_via;
24pub use types::{AgentDisplayStatus, DashboardAgent, DashboardArgs};
25
26/// The terminal seams are crate-level too ([`crate::tui`]) now that `lev setup`
27/// is a second ratatui surface driving the same `CrosstermSetup` from the
28/// binary. Re-exported here because `main.rs` and the dashboard tests import
29/// them through this path.
30pub use crate::tui::{CrosstermEventSource, EventSource, TerminalSetup};
31
32use crossterm::event::{Event, KeyEventKind};
33use leviath_runtime::control_socket::{ControlClient, ControlRequest, ControlResponse};
34use ratatui::Terminal;
35use std::time::Duration;
36use tokio::sync::mpsc;
37
38use state::Dashboard;
39use types::DaemonCommand;
40
41/// Background task that forwards the dashboard's control commands (cancel /
42/// answer-interaction / message) to the shared-world daemon over the control
43/// socket. The dashboard is a pure client: it never drives agents itself.
44async fn daemon_background_loop(
45    control: ControlClient,
46    mut cmd_rx: mpsc::UnboundedReceiver<DaemonCommand>,
47    outcomes: mpsc::UnboundedSender<types::DaemonOutcome>,
48) {
49    while let Some(cmd) = cmd_rx.recv().await {
50        let (run_id, request, what) = match cmd {
51            DaemonCommand::Cancel { run_id } => {
52                (run_id.clone(), ControlRequest::Cancel { run_id }, "cancel")
53            }
54            DaemonCommand::Pause { run_id } => {
55                (run_id.clone(), ControlRequest::Pause { run_id }, "pause")
56            }
57            DaemonCommand::Resume { run_id } => {
58                (run_id.clone(), ControlRequest::Resume { run_id }, "resume")
59            }
60            DaemonCommand::Answer { response } => (
61                response.request_id.clone(),
62                ControlRequest::AnswerInteraction { response },
63                "answer",
64            ),
65            DaemonCommand::Message { agent_id, content } => (
66                agent_id.clone(),
67                ControlRequest::Message {
68                    agent_id,
69                    content,
70                    target_region: None,
71                },
72                "message",
73            ),
74        };
75        // Report what actually happened. Discarding this would make a cancel
76        // the daemon refused indistinguishable from one that worked.
77        let outcome = match control.request(&request).await {
78            Ok(ControlResponse::Ok { ok: true }) => types::DaemonOutcome {
79                run_id,
80                message: String::new(),
81                ok: true,
82            },
83            Ok(ControlResponse::Ok { ok: false }) => types::DaemonOutcome {
84                run_id,
85                message: format!("the daemon has no such run to {what}"),
86                ok: false,
87            },
88            Ok(other) => types::DaemonOutcome {
89                run_id,
90                message: format!("unexpected daemon response to {what}: {other:?}"),
91                ok: false,
92            },
93            Err(e) => types::DaemonOutcome {
94                run_id,
95                message: format!("{what} failed: {e}"),
96                ok: false,
97            },
98        };
99        // A closed receiver means the dashboard has exited; nothing to report to.
100        if outcomes.send(outcome).is_err() {
101            return;
102        }
103    }
104}
105
106/// Terminal-independent core: runs the dashboard event loop after terminal
107/// setup, driven in tests via [`TerminalSetup`] + [`EventSource`] without a real
108/// TTY.
109///
110/// Generic over `S: TerminalSetup` and `E: EventSource`. The only
111/// `TerminalSetup` in the library is the test double [`TestSetup`] (the real
112/// `CrosstermSetup` lives in the binary), so every monomorphization here - and
113/// in [`run_dashboard_loop`] - runs against a `ratatui::backend::TestBackend`
114/// with canned events, keeping the whole function covered.
115async fn execute_core<S: TerminalSetup, E: EventSource>(
116    dashboard: &mut Dashboard,
117    control: &ControlClient,
118    setup: &mut S,
119    events: &mut E,
120) -> anyhow::Result<()> {
121    setup.enable()?;
122    let mut terminal = setup.create_terminal()?;
123    let tick_rate = Duration::from_millis(100);
124    run_dashboard_loop(dashboard, control, &mut terminal, events, tick_rate).await?;
125    setup.disable();
126    setup.print_done();
127    Ok(())
128}
129
130/// The dashboard's per-tick render/input loop, extracted from [`execute`] so
131/// it can run against a [`ratatui::backend::TestBackend`] and a canned
132/// [`EventSource`] in tests, instead of a real terminal. Exits (returning
133/// `Ok(())`) once `dashboard.should_quit` is set; propagates the first I/O
134/// error from drawing or event polling, leaving raw mode / the alternate
135/// screen untouched on error - restoring those is `execute`'s
136/// responsibility, not this loop's.
137///
138/// Generic over `B: Backend` and `impl EventSource`; in the measured test
139/// build it is only ever instantiated once - with the single
140/// [`TestBackendHarness`] backend and the single [`TestEventSource`] (both
141/// carry an injectable-failure switch, so the draw-error and poll-error `?`
142/// arms are exercised within that one monomorphization), never a real
143/// terminal backend.
144///
145/// The draw error is mapped explicitly rather than propagated with a bare `?`.
146/// On ratatui 0.29 `Backend::Error` is `io::Error` and either works; from 0.30
147/// it becomes an associated type with no `Send + Sync` bound, and a bare `?`
148/// into `anyhow::Error` stops compiling. Mapping here keeps this loop working
149/// across that change without a `B::Error: Send + Sync + 'static` bound that
150/// would have to be repeated on every caller and on `TerminalSetup::B`.
151async fn run_dashboard_loop<B: ratatui::backend::Backend>(
152    dashboard: &mut Dashboard,
153    control: &ControlClient,
154    terminal: &mut Terminal<B>,
155    events: &mut impl EventSource,
156    tick_rate: Duration,
157) -> anyhow::Result<()> {
158    loop {
159        dashboard.tick_count += 1;
160        dashboard.tick_toasts();
161
162        // Pull the daemon's open interactions (best-effort; ignore if the daemon
163        // is unreachable) so waiting agents show their prompt.
164        dashboard.sync_interactions(control).await;
165
166        // …and which runs it actually holds, so a run on disk that nothing is
167        // driving can be shown as stale rather than ACTIVE.
168        dashboard.sync_daemon_runs(control).await;
169
170        // Sync background runs from on-disk run-state dir (the daemon persists
171        // meta/context/stages there).
172        dashboard.sync_from_run_state();
173
174        // Surface any completed MCP login/test as a toast.
175        dashboard.drain_mcp_outcomes();
176
177        // Report what the daemon did with this tick's commands.
178        dashboard.drain_daemon_outcomes();
179
180        // Draw
181        terminal
182            .draw(|frame| dashboard.draw(frame))
183            .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
184
185        // Handle input
186        if let Some(event) = events.poll_event(tick_rate)? {
187            match event {
188                Event::Key(key) if key.kind == KeyEventKind::Press => {
189                    dashboard.handle_key(key);
190                }
191                // Wheel scrolling and click-drag text selection, handled in one
192                // place (`selection.rs`) so they cannot disagree about state.
193                Event::Mouse(m) => dashboard.handle_mouse(m),
194                Event::Resize(_, _) => {
195                    // Terminal will redraw automatically on next tick
196                }
197                _ => {}
198            }
199        }
200
201        if dashboard.should_quit {
202            return Ok(());
203        }
204    }
205}
206
207/// Builds the [`Dashboard`], starts the daemon-control background loop, and
208/// seeds the startup log line. Split out of [`execute`] purely so this
209/// (entirely terminal-independent) setup is unit-testable on its own, separate
210/// from the real-terminal I/O sliver.
211fn init_dashboard(control: ControlClient, yank_fn: fn(&str) -> bool) -> Dashboard {
212    let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
213    // The real MCP screen operates on the user's config + token store, opens a
214    // real browser for login, and reads the wall clock.
215    let mcp_ctx = types::McpContext {
216        config_path: crate::config::Config::config_path(),
217        store_path: leviath_mcp::AuthStore::default_path().unwrap_or_default(),
218        opener: std::sync::Arc::new(leviath_sys::open_url),
219        clock: mcp_system_now,
220    };
221    let mut dashboard = Dashboard::new_with_log_path(
222        cmd_tx,
223        crate::runstate::dashboard_log_path(),
224        yank_fn,
225        mcp_ctx,
226    );
227
228    // Forward the dashboard's control commands to the daemon, and report each
229    // result back so a refused command is surfaced rather than swallowed. A
230    // freshly-built dashboard always has its outcome sender.
231    let daemon_outcome_tx = dashboard
232        .take_daemon_outcome_tx()
233        .expect("a fresh dashboard has its daemon outcome sender");
234    tokio::spawn(daemon_background_loop(control, cmd_rx, daemon_outcome_tx));
235
236    // Run MCP logins/tests off the UI loop. A freshly-built dashboard always
237    // has its background channel ends.
238    let (mcp_cmd_rx, mcp_outcome_tx) = dashboard
239        .take_mcp_bg_ends()
240        .expect("a fresh dashboard has its MCP background channel ends");
241    tokio::spawn(mcp::mcp_background_loop(
242        dashboard.mcp_context(),
243        mcp_cmd_rx,
244        mcp_outcome_tx,
245    ));
246
247    dashboard.add_log("Dashboard started. Use `lev run <agent>` to start an agent.".to_string());
248
249    dashboard
250}
251
252/// Wall-clock Unix time in seconds, for the production MCP context.
253fn mcp_system_now() -> u64 {
254    std::time::SystemTime::now()
255        .duration_since(std::time::UNIX_EPOCH)
256        .map(|d| d.as_secs())
257        .unwrap_or(0)
258}
259
260/// Load config, build the dashboard + engine, and run the event loop against
261/// the injected [`TerminalSetup`] and [`EventSource`]. This is the whole
262/// `lev dash` command minus the two real-terminal doubles - so it is fully
263/// unit-testable (drive it with `TestSetup` + a canned `TestEventSource`), and
264/// the binary's `real_dashboard` supplies the real crossterm `CrosstermSetup`
265/// + [`CrosstermEventSource`].
266///
267/// The real terminal wiring cannot live here: constructing `CrosstermSetup`
268/// enables actual raw mode / the alternate screen and blocks forever on real
269/// keyboard input (an `is_terminal()` guard does not prevent the hang - it
270/// still hangs a real editor terminal full-screen). That irreducible sliver is
271/// the binary's job; everything it composes is exercised here.
272/// `yank_fn` is the clipboard implementation the dashboard's `y` keypress uses;
273/// the binary passes the real native-tool/OSC52 clipboard (which can write the
274/// real terminal), tests pass a no-op.
275pub async fn execute_with<S: TerminalSetup, E: EventSource>(
276    control: ControlClient,
277    setup: &mut S,
278    events: &mut E,
279    yank_fn: fn(&str) -> bool,
280) -> anyhow::Result<()> {
281    let mut dashboard = init_dashboard(control.clone(), yank_fn);
282    execute_core(&mut dashboard, &control, setup, events).await
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    use crate::commands::dashboard::test_support::make_test_dashboard;
290
291    #[test]
292    fn dashboard_args_can_be_constructed() {
293        let _args = DashboardArgs {};
294    }
295
296    #[test]
297    fn mcp_system_now_advances_past_the_epoch() {
298        assert!(mcp_system_now() > 1_600_000_000);
299    }
300
301    #[test]
302    fn agent_display_status_variants_display() {
303        let statuses = vec![
304            AgentDisplayStatus::Active,
305            AgentDisplayStatus::Waiting,
306            AgentDisplayStatus::Complete,
307            AgentDisplayStatus::CompleteInteractive,
308            AgentDisplayStatus::Error("test error".to_string()),
309            AgentDisplayStatus::Idle,
310            AgentDisplayStatus::Cancelled,
311        ];
312        for status in statuses {
313            let display = format!("{}", status);
314            assert!(!display.is_empty());
315        }
316    }
317
318    /// A control client pointing at a socket with no daemon behind it; requests
319    /// fail fast, which the dashboard treats as "nothing to observe".
320    fn no_daemon_control() -> ControlClient {
321        let dir = std::env::temp_dir().join("leviath-dash-no-daemon");
322        ControlClient::new(leviath_runtime::control_socket::control_id(&dir))
323    }
324
325    /// A fake daemon that accepts one connection, records the request line it
326    /// receives, replies `{"result":"ok","ok":true}`, and returns the request.
327    fn recording_daemon(dir: &std::path::Path) -> (ControlClient, tokio::task::JoinHandle<String>) {
328        use leviath_runtime::control_socket::{bind_control_listener, control_id};
329        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
330        let id = control_id(dir);
331        let mut listener = bind_control_listener(&id).unwrap();
332        let handle = tokio::spawn(async move {
333            let stream = listener
334                .accept()
335                .await
336                .expect("accept succeeds")
337                .expect("our own connection is admitted");
338            let (read_half, mut write_half) = tokio::io::split(stream);
339            let mut lines = BufReader::new(read_half).lines();
340            let req = lines.next_line().await.unwrap().unwrap_or_default();
341            write_half
342                .write_all(b"{\"result\":\"ok\",\"ok\":true}\n")
343                .await
344                .unwrap();
345            req
346        });
347        (ControlClient::new(id), handle)
348    }
349
350    /// A daemon that replies with `reply` (verbatim, newline added) to one
351    /// request. `None` closes the connection without replying.
352    fn replying_daemon(
353        dir: &std::path::Path,
354        reply: Option<&'static str>,
355    ) -> (ControlClient, tokio::task::JoinHandle<()>) {
356        use leviath_runtime::control_socket::{bind_control_listener, control_id};
357        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
358        let id = control_id(dir);
359        let mut listener = bind_control_listener(&id).unwrap();
360        let handle = tokio::spawn(async move {
361            let stream = listener
362                .accept()
363                .await
364                .expect("accept succeeds")
365                .expect("our own connection is admitted");
366            let (read_half, mut write_half) = tokio::io::split(stream);
367            let mut lines = BufReader::new(read_half).lines();
368            let _ = lines.next_line().await;
369            if let Some(reply) = reply {
370                let _ = write_half.write_all(format!("{reply}\n").as_bytes()).await;
371            }
372        });
373        (ControlClient::new(id), handle)
374    }
375
376    /// Drive one cancel through the loop and return the reported outcome.
377    async fn cancel_outcome(reply: Option<&'static str>) -> types::DaemonOutcome {
378        let dir = tempfile::tempdir().unwrap();
379        let (control, server) = replying_daemon(dir.path(), reply);
380        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
381        let (out_tx, mut out_rx) = mpsc::unbounded_channel();
382        tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
383        cmd_tx
384            .send(DaemonCommand::Cancel {
385                run_id: "run-1".to_string(),
386            })
387            .unwrap();
388        let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv())
389            .await
390            .expect("an outcome was reported")
391            .expect("the loop is alive");
392        let _ = server.await;
393        outcome
394    }
395
396    /// Every response shape the daemon can give is reported back, so the
397    /// dashboard can tell a kill that worked from one that did not.
398    #[tokio::test]
399    async fn daemon_background_loop_reports_each_outcome() {
400        let ok = cancel_outcome(Some(r#"{"result":"ok","ok":true}"#)).await;
401        assert!(ok.ok, "an applied cancel is reported as success");
402        assert_eq!(ok.run_id, "run-1");
403
404        let missing = cancel_outcome(Some(r#"{"result":"ok","ok":false}"#)).await;
405        assert!(!missing.ok);
406        assert!(missing.message.contains("no such run to cancel"));
407
408        let odd = cancel_outcome(Some(r#"{"result":"spawned","run_id":"x"}"#)).await;
409        assert!(!odd.ok);
410        assert!(odd.message.contains("unexpected daemon response"));
411
412        // Connection closed with no reply → a transport error, surfaced as such.
413        let broken = cancel_outcome(None).await;
414        assert!(!broken.ok);
415        assert!(
416            broken.message.contains("cancel failed"),
417            "got: {}",
418            broken.message
419        );
420    }
421
422    /// Drive one arbitrary command through the loop and return the outcome.
423    async fn command_outcome(
424        cmd: DaemonCommand,
425        reply: Option<&'static str>,
426    ) -> types::DaemonOutcome {
427        let dir = tempfile::tempdir().unwrap();
428        let (control, server) = replying_daemon(dir.path(), reply);
429        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
430        let (out_tx, mut out_rx) = mpsc::unbounded_channel();
431        tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
432        cmd_tx.send(cmd).unwrap();
433        let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv())
434            .await
435            .expect("an outcome was reported")
436            .expect("the loop is alive");
437        let _ = server.await;
438        outcome
439    }
440
441    /// Pause and resume ride the same forwarding path as cancel; the daemon's
442    /// refusal is labelled with the verb the user actually pressed.
443    #[tokio::test]
444    async fn daemon_background_loop_forwards_pause_and_resume() {
445        let ok = command_outcome(
446            DaemonCommand::Pause {
447                run_id: "run-1".to_string(),
448            },
449            Some(r#"{"result":"ok","ok":true}"#),
450        )
451        .await;
452        assert!(ok.ok);
453        assert_eq!(ok.run_id, "run-1");
454
455        let refused = command_outcome(
456            DaemonCommand::Pause {
457                run_id: "run-1".to_string(),
458            },
459            Some(r#"{"result":"ok","ok":false}"#),
460        )
461        .await;
462        assert!(!refused.ok);
463        assert!(refused.message.contains("no such run to pause"));
464
465        let ok = command_outcome(
466            DaemonCommand::Resume {
467                run_id: "run-1".to_string(),
468            },
469            Some(r#"{"result":"ok","ok":true}"#),
470        )
471        .await;
472        assert!(ok.ok);
473
474        let refused = command_outcome(
475            DaemonCommand::Resume {
476                run_id: "run-1".to_string(),
477            },
478            Some(r#"{"result":"ok","ok":false}"#),
479        )
480        .await;
481        assert!(!refused.ok);
482        assert!(refused.message.contains("no such run to resume"));
483    }
484
485    /// The loop stops when the dashboard has gone away, rather than spinning on
486    /// a channel nobody reads.
487    #[tokio::test]
488    async fn daemon_background_loop_exits_when_the_dashboard_is_gone() {
489        let dir = tempfile::tempdir().unwrap();
490        let (control, _server) = replying_daemon(dir.path(), Some(r#"{"result":"ok","ok":true}"#));
491        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
492        let (out_tx, out_rx) = mpsc::unbounded_channel();
493        drop(out_rx); // the dashboard exited
494        let handle = tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
495        cmd_tx
496            .send(DaemonCommand::Cancel {
497                run_id: "run-1".to_string(),
498            })
499            .unwrap();
500        tokio::time::timeout(std::time::Duration::from_secs(5), handle)
501            .await
502            .expect("the loop returned")
503            .unwrap();
504    }
505
506    // ─── init_dashboard ──────────────────────────────────────────────────
507
508    #[tokio::test]
509    async fn init_dashboard_seeds_startup_log_and_forwards_commands() {
510        crate::runstate::with_isolated_runs_dir_async(
511            "init_dashboard_seeds_startup_log",
512            |_d| async move {
513                let dashboard = init_dashboard(no_daemon_control(), |_| false);
514                assert!(
515                    dashboard
516                        .log
517                        .iter()
518                        .any(|entry| entry.message.contains("Dashboard started"))
519                );
520                // The background loop is live: a command on the dashboard's own
521                // cmd_tx is accepted (delivered to the unreachable daemon).
522                dashboard
523                    .cmd_tx
524                    .send(DaemonCommand::Cancel {
525                        run_id: "nope".to_string(),
526                    })
527                    .unwrap();
528                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
529            },
530        )
531        .await;
532    }
533
534    // ─── daemon_background_loop ───────────────────────────────────────────
535
536    #[tokio::test]
537    async fn daemon_background_loop_forwards_cancel() {
538        let dir = tempfile::tempdir().unwrap();
539        let (control, server) = recording_daemon(dir.path());
540        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
541        let (out_tx, _out_rx) = mpsc::unbounded_channel();
542        tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
543        cmd_tx
544            .send(DaemonCommand::Cancel {
545                run_id: "run-1".to_string(),
546            })
547            .unwrap();
548        let req = server.await.unwrap();
549        assert!(req.contains("cancel"));
550        assert!(req.contains("run-1"));
551    }
552
553    #[tokio::test]
554    async fn daemon_background_loop_forwards_answer() {
555        let dir = tempfile::tempdir().unwrap();
556        let (control, server) = recording_daemon(dir.path());
557        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
558        let (out_tx, _out_rx) = mpsc::unbounded_channel();
559        tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
560        cmd_tx
561            .send(DaemonCommand::Answer {
562                response: leviath_core::interaction::InteractionResponse::text("q1", "yes"),
563            })
564            .unwrap();
565        let req = server.await.unwrap();
566        assert!(req.contains("answer_interaction"));
567        assert!(req.contains("q1"));
568    }
569
570    #[tokio::test]
571    async fn daemon_background_loop_forwards_message() {
572        let dir = tempfile::tempdir().unwrap();
573        let (control, server) = recording_daemon(dir.path());
574        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
575        let (out_tx, _out_rx) = mpsc::unbounded_channel();
576        tokio::spawn(daemon_background_loop(control, cmd_rx, out_tx));
577        cmd_tx
578            .send(DaemonCommand::Message {
579                agent_id: "a1".to_string(),
580                content: "hi there".to_string(),
581            })
582            .unwrap();
583        let req = server.await.unwrap();
584        assert!(req.contains("message"));
585        assert!(req.contains("hi there"));
586    }
587
588    #[tokio::test]
589    async fn daemon_background_loop_exits_when_channel_dropped() {
590        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<DaemonCommand>();
591        let (out_tx, _out_rx) = mpsc::unbounded_channel();
592        let handle = tokio::spawn(daemon_background_loop(no_daemon_control(), cmd_rx, out_tx));
593        drop(cmd_tx);
594        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
595        assert!(result.is_ok());
596    }
597
598    // ─── Dashboard basic integration ──────────────────────────────────────
599
600    #[test]
601    fn dashboard_new_and_initial_state() {
602        let dash = make_test_dashboard();
603        assert!(!dash.should_quit);
604        assert!(!dash.detail_view);
605        assert!(!dash.show_help);
606    }
607
608    #[test]
609    fn dashboard_draw_renders_without_panic() {
610        use ratatui::Terminal;
611        use ratatui::backend::TestBackend;
612        let backend = TestBackend::new(120, 40);
613        let mut terminal = Terminal::new(backend).unwrap();
614        let mut dash = make_test_dashboard();
615        terminal.draw(|f| dash.draw(f)).unwrap();
616    }
617
618    #[test]
619    fn dashboard_agent_struct_fields_from_mod() {
620        let agent = DashboardAgent {
621            id: "run-test".to_string(),
622            blueprint_name: "tester".to_string(),
623            stage: "init".to_string(),
624            stage_index: 0,
625            num_stages: 1,
626            status: AgentDisplayStatus::Idle,
627            tokens_in: 0,
628            tokens_out: 0,
629            cached_tokens: 0,
630            iteration: 0,
631            waiting_prompt: None,
632            pending_request: None,
633            last_answered_request_id: None,
634            context_snapshot: None,
635            stages: vec![],
636            workdir: "/tmp".to_string(),
637            task: "test task".to_string(),
638            title: None,
639            model: None,
640            parent_id: None,
641            depth: 0,
642            started_at: 0,
643            last_progress_at: None,
644            active_until: None,
645            waiting_secs: 0,
646            graph_info: None,
647            accepts_messages: false,
648            taint_summary: vec![],
649        };
650        assert_eq!(agent.id, "run-test");
651        assert_eq!(agent.blueprint_name, "tester");
652        assert_eq!(agent.stage, "init");
653    }
654
655    // ─── run_dashboard_loop ─────────────────────────────────────────────────
656    //
657    // The terminal doubles these tests drive (`TestEventSource`,
658    // `TestBackendHarness`, `TestSetup`, `key`) are crate-level, in
659    // [`crate::tui`], and shared with the `lev setup` wizard's tests. Keeping
660    // exactly one implementation of each is load-bearing for coverage: it means
661    // [`execute_core`] and [`run_dashboard_loop`] monomorphize over a single
662    // concrete backend / event source in the measured test build, so
663    // `cargo-llvm-cov`'s per-instantiation region report has no partially
664    // covered sibling monomorphization to undercount.
665
666    use crate::tui::{TestBackendHarness, TestEventSource, TestSetup, key, test_terminal};
667    use crossterm::event::KeyCode;
668
669    #[tokio::test]
670    async fn run_dashboard_loop_quits_on_q_from_main_list() {
671        let mut dashboard = make_test_dashboard();
672        let control = no_daemon_control();
673        let mut terminal = test_terminal();
674        // A no-op Resize tick, then both wheel directions, a full
675        // press-drag-release selection over the log panel, and the q that
676        // triggers quit - covers every arm of the event match, including the
677        // mouse one that carries scrolling and selection.
678        let mouse = |kind, column, row| {
679            Event::Mouse(crossterm::event::MouseEvent {
680                kind,
681                column,
682                row,
683                modifiers: crossterm::event::KeyModifiers::NONE,
684            })
685        };
686        use crossterm::event::{MouseButton, MouseEventKind};
687        let mut events = TestEventSource::new(vec![
688            Event::Resize(80, 24),
689            mouse(MouseEventKind::ScrollUp, 0, 0),
690            mouse(MouseEventKind::ScrollDown, 0, 0),
691            // Cursor motion without a button is not a gesture and must be
692            // ignored rather than moving the view under the user.
693            mouse(MouseEventKind::Moved, 0, 0),
694            mouse(MouseEventKind::Down(MouseButton::Left), 3, 15),
695            mouse(MouseEventKind::Drag(MouseButton::Left), 20, 16),
696            mouse(MouseEventKind::Up(MouseButton::Left), 20, 16),
697            key(KeyCode::Char('q')),
698        ]);
699
700        let result = run_dashboard_loop(
701            &mut dashboard,
702            &control,
703            &mut terminal,
704            &mut events,
705            Duration::from_millis(1),
706        )
707        .await;
708
709        assert!(result.is_ok());
710        assert!(dashboard.should_quit);
711    }
712
713    #[tokio::test]
714    async fn run_dashboard_loop_no_event_tick_then_quits() {
715        // Tick 1: `poll_event` returns `None` (simulated poll-timeout - no input
716        // pending); tick 2: q quits.  The `None` entry exercises the
717        // `if let Some(event)` fallthrough path (line 127 in mod.rs).
718        let mut dashboard = make_test_dashboard();
719        let control = no_daemon_control();
720        let mut terminal = test_terminal();
721        // `None` entry → poll returns Ok(None) on tick 1 (no-event path);
722        // `Some(q)` → poll returns Ok(Some(q)) on tick 2 → quit.
723        let mut events = TestEventSource::new_with_nones(vec![None, Some(key(KeyCode::Char('q')))]);
724
725        let result = run_dashboard_loop(
726            &mut dashboard,
727            &control,
728            &mut terminal,
729            &mut events,
730            Duration::from_millis(1),
731        )
732        .await;
733
734        assert!(result.is_ok());
735        assert!(dashboard.should_quit);
736    }
737
738    #[tokio::test]
739    async fn run_dashboard_loop_ignores_non_press_and_other_events() {
740        // A key release (not Press) and a mouse-like "other" event are both
741        // ignored by the `_ => {}` arm; only the trailing q actually quits.
742        let mut dashboard = make_test_dashboard();
743        let control = no_daemon_control();
744        let mut terminal = test_terminal();
745        let release = Event::Key(crossterm::event::KeyEvent::new_with_kind(
746            KeyCode::Char('x'),
747            crossterm::event::KeyModifiers::empty(),
748            crossterm::event::KeyEventKind::Release,
749        ));
750        let mut events =
751            TestEventSource::new(vec![release, Event::FocusGained, key(KeyCode::Char('q'))]);
752
753        let result = run_dashboard_loop(
754            &mut dashboard,
755            &control,
756            &mut terminal,
757            &mut events,
758            Duration::from_millis(1),
759        )
760        .await;
761
762        assert!(result.is_ok());
763        assert!(dashboard.should_quit);
764    }
765
766    #[tokio::test]
767    async fn run_dashboard_loop_propagates_event_source_error() {
768        let mut dashboard = make_test_dashboard();
769        let control = no_daemon_control();
770        let mut terminal = test_terminal();
771        let mut events = TestEventSource::failing();
772
773        let result = run_dashboard_loop(
774            &mut dashboard,
775            &control,
776            &mut terminal,
777            &mut events,
778            Duration::from_millis(1),
779        )
780        .await;
781
782        assert!(result.is_err());
783    }
784
785    // `crossterm::event::poll` cannot be called from a real unit test: it
786    // hangs for 60+ seconds under a real pty, even in complete isolation
787    // (`--test-threads=1`, nothing else running). Root cause:
788    // `crossterm::event::poll`'s internal
789    // `INTERNAL_EVENT_READER` is a lazily-constructed, process-wide
790    // singleton (`parking_lot::Mutex<Option<InternalEventReader>>`); the
791    // passed timeout only bounds *acquiring that mutex*
792    // (`try_lock_for(timeout)`), not the one-time construction of the
793    // underlying `mio`-based event source that happens the first time it's
794    // ever used in the process, nor whatever `mio::Poll::poll` actually
795    // observes against a `script`-allocated pty's fd. There is no
796    // "1ms-bounded, side-effect-free" way to touch real crossterm event
797    // polling from a test at all - so this doesn't get a test, matching
798    // every other real-terminal entry point in this file (`execute`,
799    // `open_controlling_tty` equivalent, etc.).
800
801    // ─── draw-error propagation ─────────────────────────────────────────────
802
803    #[tokio::test]
804    async fn run_dashboard_loop_propagates_draw_error() {
805        // Exercises the `terminal.draw(…)?` error-propagation path using the
806        // single `TestBackendHarness` backend with `fail_draw` set, so this
807        // shares run_dashboard_loop's one monomorphization with the
808        // success-path tests (the draw `?` has both arms covered there).
809        let mut dashboard = make_test_dashboard();
810        let control = no_daemon_control();
811        let mut terminal = Terminal::new(TestBackendHarness::failing(120, 40)).unwrap();
812        let mut events = TestEventSource::new(vec![]); // never reached
813
814        let result = run_dashboard_loop(
815            &mut dashboard,
816            &control,
817            &mut terminal,
818            &mut events,
819            Duration::from_millis(1),
820        )
821        .await;
822
823        assert!(result.is_err());
824    }
825
826    // Caveat for anyone tempted to bound `run_dashboard_loop` with a
827    // `tokio::time::timeout`: its `loop { ... }` has no `.await` point
828    // (`poll_event`, `try_lock`, `terminal.draw` are all synchronous), so on
829    // the default current-thread `#[tokio::test]` runtime the executor's
830    // single thread never regains control long enough for the timeout's own
831    // timer to fire - a future that never yields can't be preempted by a
832    // sibling future racing it. In a non-TTY sandbox
833    // `CrosstermEventSource::poll_event` fails immediately (so it looks
834    // bounded in headless testing); on a real terminal, with no scripted key
835    // ever setting `should_quit`, it hangs indefinitely.
836
837    // `CrosstermEventSource`'s own poll/read branches and
838    // `TestBackendHarness`'s delegated trait methods are covered where those
839    // types now live, in `crate::tui`.
840
841    // ─── execute_core / TestSetup ───────────────────────────────────────────
842    //
843    // `execute_core` and the `run_dashboard_loop` it calls are generic over
844    // `TerminalSetup`/`EventSource`. The only `TerminalSetup` in the library is
845    // the [`TestSetup`] double (the real `CrosstermSetup` lives in the binary),
846    // so these tests drive every arm of `execute_core` against a `TestBackend`
847    // deterministically, without touching a real terminal.
848
849    #[tokio::test]
850    async fn execute_core_happy_path_quits_on_esc() {
851        crate::runstate::with_isolated_runs_dir_async(
852            "execute_core_happy_path_quits_on_esc",
853            |_d| async move {
854                let control = no_daemon_control();
855                let mut dashboard = init_dashboard(control.clone(), |_| false);
856                let mut setup = TestSetup::new();
857                let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
858                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
859                assert!(result.is_ok());
860                assert!(dashboard.should_quit);
861            },
862        )
863        .await;
864    }
865
866    #[tokio::test]
867    async fn execute_with_loads_config_inits_and_runs_the_loop() {
868        // Drives the whole `execute_with` composition root (Config::load +
869        // init_dashboard + execute_core) against the test terminal doubles,
870        // with both the config path and the dashboard log path isolated so
871        // nothing touches the developer's real ~/.leviath.
872        crate::config::with_isolated_config_path_async(
873            "execute_with_dashboard",
874            |_fake_dir| async move {
875                crate::runstate::with_isolated_runs_dir_async(
876                    "execute_with_dashboard",
877                    |_d| async move {
878                        let mut setup = TestSetup::new();
879                        let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
880                        let result =
881                            execute_with(no_daemon_control(), &mut setup, &mut events, |_| false)
882                                .await;
883                        assert!(result.is_ok());
884                    },
885                )
886                .await;
887            },
888        )
889        .await;
890    }
891
892    #[tokio::test]
893    async fn execute_core_enable_error_propagates() {
894        crate::runstate::with_isolated_runs_dir_async(
895            "execute_core_enable_error_propagates",
896            |_d| async move {
897                let control = no_daemon_control();
898                let mut dashboard = init_dashboard(control.clone(), |_| false);
899                // `setup.enable()?` fails first, so the loop is never reached.
900                let mut setup = TestSetup {
901                    enable_should_fail: true,
902                    create_should_fail: false,
903                };
904                let mut events = TestEventSource::new(vec![]);
905                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
906                assert!(result.is_err());
907            },
908        )
909        .await;
910    }
911
912    #[tokio::test]
913    async fn execute_core_create_terminal_error_propagates() {
914        crate::runstate::with_isolated_runs_dir_async(
915            "execute_core_create_terminal_error_propagates",
916            |_d| async move {
917                let control = no_daemon_control();
918                let mut dashboard = init_dashboard(control.clone(), |_| false);
919                // `enable()` succeeds, then `create_terminal()?` fails - deterministic
920                // (no real backend / TTY involved), so this can never hang.
921                let mut setup = TestSetup {
922                    enable_should_fail: false,
923                    create_should_fail: true,
924                };
925                let mut events = TestEventSource::new(vec![]);
926                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
927                assert!(result.is_err());
928            },
929        )
930        .await;
931    }
932
933    #[tokio::test]
934    async fn execute_core_loop_error_propagates() {
935        crate::runstate::with_isolated_runs_dir_async(
936            "execute_core_loop_error_propagates",
937            |_d| async move {
938                let control = no_daemon_control();
939                let mut dashboard = init_dashboard(control.clone(), |_| false);
940                let mut setup = TestSetup::new();
941                let mut events = TestEventSource::failing();
942                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
943                assert!(result.is_err());
944            },
945        )
946        .await;
947    }
948
949    // The real `lev dash` wiring (the crossterm `CrosstermSetup` + the real
950    // `CrosstermEventSource` + the `Config::load`/`init_dashboard`/`execute_core`
951    // composition) lives in the binary's `real_dashboard`; the fully-tested
952    // seam it composes, `execute_with`, is covered by
953    // `execute_with_loads_config_inits_and_runs_the_loop` above.
954}