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        // An empty dashboard still draws its chrome. Without this the test
617        // would pass just as happily against a `draw` that returned early.
618        let buf = crate::commands::dashboard::test_support::rendered_buffer(&terminal);
619        assert!(buf.contains("Agents"), "{buf}");
620    }
621
622    #[test]
623    fn dashboard_agent_struct_fields_from_mod() {
624        let agent = DashboardAgent {
625            id: "run-test".to_string(),
626            blueprint_name: "tester".to_string(),
627            stage: "init".to_string(),
628            stage_index: 0,
629            num_stages: 1,
630            status: AgentDisplayStatus::Idle,
631            tokens_in: 0,
632            tokens_out: 0,
633            cached_tokens: 0,
634            iteration: 0,
635            waiting_prompt: None,
636            pending_request: None,
637            last_answered_request_id: None,
638            context_snapshot: None,
639            stages: vec![],
640            workdir: "/tmp".to_string(),
641            task: "test task".to_string(),
642            title: None,
643            model: None,
644            parent_id: None,
645            depth: 0,
646            started_at: 0,
647            last_progress_at: None,
648            active_until: None,
649            waiting_secs: 0,
650            graph_info: None,
651            accepts_messages: false,
652            taint_summary: vec![],
653        };
654        assert_eq!(agent.id, "run-test");
655        assert_eq!(agent.blueprint_name, "tester");
656        assert_eq!(agent.stage, "init");
657    }
658
659    // ─── run_dashboard_loop ─────────────────────────────────────────────────
660    //
661    // The terminal doubles these tests drive (`TestEventSource`,
662    // `TestBackendHarness`, `TestSetup`, `key`) are crate-level, in
663    // [`crate::tui`], and shared with the `lev setup` wizard's tests. Keeping
664    // exactly one implementation of each is load-bearing for coverage: it means
665    // [`execute_core`] and [`run_dashboard_loop`] monomorphize over a single
666    // concrete backend / event source in the measured test build, so
667    // `cargo-llvm-cov`'s per-instantiation region report has no partially
668    // covered sibling monomorphization to undercount.
669
670    use crate::tui::{TestBackendHarness, TestEventSource, TestSetup, key, test_terminal};
671    use crossterm::event::KeyCode;
672
673    #[tokio::test]
674    async fn run_dashboard_loop_quits_on_q_from_main_list() {
675        let mut dashboard = make_test_dashboard();
676        let control = no_daemon_control();
677        let mut terminal = test_terminal();
678        // A no-op Resize tick, then both wheel directions, a full
679        // press-drag-release selection over the log panel, and the q that
680        // triggers quit - covers every arm of the event match, including the
681        // mouse one that carries scrolling and selection.
682        let mouse = |kind, column, row| {
683            Event::Mouse(crossterm::event::MouseEvent {
684                kind,
685                column,
686                row,
687                modifiers: crossterm::event::KeyModifiers::NONE,
688            })
689        };
690        use crossterm::event::{MouseButton, MouseEventKind};
691        let mut events = TestEventSource::new(vec![
692            Event::Resize(80, 24),
693            mouse(MouseEventKind::ScrollUp, 0, 0),
694            mouse(MouseEventKind::ScrollDown, 0, 0),
695            // Cursor motion without a button is not a gesture and must be
696            // ignored rather than moving the view under the user.
697            mouse(MouseEventKind::Moved, 0, 0),
698            mouse(MouseEventKind::Down(MouseButton::Left), 3, 15),
699            mouse(MouseEventKind::Drag(MouseButton::Left), 20, 16),
700            mouse(MouseEventKind::Up(MouseButton::Left), 20, 16),
701            key(KeyCode::Char('q')),
702        ]);
703
704        let result = run_dashboard_loop(
705            &mut dashboard,
706            &control,
707            &mut terminal,
708            &mut events,
709            Duration::from_millis(1),
710        )
711        .await;
712
713        assert!(result.is_ok());
714        assert!(dashboard.should_quit);
715    }
716
717    #[tokio::test]
718    async fn run_dashboard_loop_no_event_tick_then_quits() {
719        // Tick 1: `poll_event` returns `None` (simulated poll-timeout - no input
720        // pending); tick 2: q quits.  The `None` entry exercises the
721        // `if let Some(event)` fallthrough path (line 127 in mod.rs).
722        let mut dashboard = make_test_dashboard();
723        let control = no_daemon_control();
724        let mut terminal = test_terminal();
725        // `None` entry → poll returns Ok(None) on tick 1 (no-event path);
726        // `Some(q)` → poll returns Ok(Some(q)) on tick 2 → quit.
727        let mut events = TestEventSource::new_with_nones(vec![None, Some(key(KeyCode::Char('q')))]);
728
729        let result = run_dashboard_loop(
730            &mut dashboard,
731            &control,
732            &mut terminal,
733            &mut events,
734            Duration::from_millis(1),
735        )
736        .await;
737
738        assert!(result.is_ok());
739        assert!(dashboard.should_quit);
740    }
741
742    #[tokio::test]
743    async fn run_dashboard_loop_ignores_non_press_and_other_events() {
744        // A key release (not Press) and a mouse-like "other" event are both
745        // ignored by the `_ => {}` arm; only the trailing q actually quits.
746        let mut dashboard = make_test_dashboard();
747        let control = no_daemon_control();
748        let mut terminal = test_terminal();
749        let release = Event::Key(crossterm::event::KeyEvent::new_with_kind(
750            KeyCode::Char('x'),
751            crossterm::event::KeyModifiers::empty(),
752            crossterm::event::KeyEventKind::Release,
753        ));
754        let mut events =
755            TestEventSource::new(vec![release, Event::FocusGained, key(KeyCode::Char('q'))]);
756
757        let result = run_dashboard_loop(
758            &mut dashboard,
759            &control,
760            &mut terminal,
761            &mut events,
762            Duration::from_millis(1),
763        )
764        .await;
765
766        assert!(result.is_ok());
767        assert!(dashboard.should_quit);
768    }
769
770    #[tokio::test]
771    async fn run_dashboard_loop_propagates_event_source_error() {
772        let mut dashboard = make_test_dashboard();
773        let control = no_daemon_control();
774        let mut terminal = test_terminal();
775        let mut events = TestEventSource::failing();
776
777        let result = run_dashboard_loop(
778            &mut dashboard,
779            &control,
780            &mut terminal,
781            &mut events,
782            Duration::from_millis(1),
783        )
784        .await;
785
786        assert!(result.is_err());
787    }
788
789    // `crossterm::event::poll` cannot be called from a real unit test: it
790    // hangs for 60+ seconds under a real pty, even in complete isolation
791    // (`--test-threads=1`, nothing else running). Root cause:
792    // `crossterm::event::poll`'s internal
793    // `INTERNAL_EVENT_READER` is a lazily-constructed, process-wide
794    // singleton (`parking_lot::Mutex<Option<InternalEventReader>>`); the
795    // passed timeout only bounds *acquiring that mutex*
796    // (`try_lock_for(timeout)`), not the one-time construction of the
797    // underlying `mio`-based event source that happens the first time it's
798    // ever used in the process, nor whatever `mio::Poll::poll` actually
799    // observes against a `script`-allocated pty's fd. There is no
800    // "1ms-bounded, side-effect-free" way to touch real crossterm event
801    // polling from a test at all - so this doesn't get a test, matching
802    // every other real-terminal entry point in this file (`execute`,
803    // `open_controlling_tty` equivalent, etc.).
804
805    // ─── draw-error propagation ─────────────────────────────────────────────
806
807    #[tokio::test]
808    async fn run_dashboard_loop_propagates_draw_error() {
809        // Exercises the `terminal.draw(…)?` error-propagation path using the
810        // single `TestBackendHarness` backend with `fail_draw` set, so this
811        // shares run_dashboard_loop's one monomorphization with the
812        // success-path tests (the draw `?` has both arms covered there).
813        let mut dashboard = make_test_dashboard();
814        let control = no_daemon_control();
815        let mut terminal = Terminal::new(TestBackendHarness::failing(120, 40)).unwrap();
816        let mut events = TestEventSource::new(vec![]); // never reached
817
818        let result = run_dashboard_loop(
819            &mut dashboard,
820            &control,
821            &mut terminal,
822            &mut events,
823            Duration::from_millis(1),
824        )
825        .await;
826
827        assert!(result.is_err());
828    }
829
830    // Caveat for anyone tempted to bound `run_dashboard_loop` with a
831    // `tokio::time::timeout`: its `loop { ... }` has no `.await` point
832    // (`poll_event`, `try_lock`, `terminal.draw` are all synchronous), so on
833    // the default current-thread `#[tokio::test]` runtime the executor's
834    // single thread never regains control long enough for the timeout's own
835    // timer to fire - a future that never yields can't be preempted by a
836    // sibling future racing it. In a non-TTY sandbox
837    // `CrosstermEventSource::poll_event` fails immediately (so it looks
838    // bounded in headless testing); on a real terminal, with no scripted key
839    // ever setting `should_quit`, it hangs indefinitely.
840
841    // `CrosstermEventSource`'s own poll/read branches and
842    // `TestBackendHarness`'s delegated trait methods are covered where those
843    // types now live, in `crate::tui`.
844
845    // ─── execute_core / TestSetup ───────────────────────────────────────────
846    //
847    // `execute_core` and the `run_dashboard_loop` it calls are generic over
848    // `TerminalSetup`/`EventSource`. The only `TerminalSetup` in the library is
849    // the [`TestSetup`] double (the real `CrosstermSetup` lives in the binary),
850    // so these tests drive every arm of `execute_core` against a `TestBackend`
851    // deterministically, without touching a real terminal.
852
853    #[tokio::test]
854    async fn execute_core_happy_path_quits_on_esc() {
855        crate::runstate::with_isolated_runs_dir_async(
856            "execute_core_happy_path_quits_on_esc",
857            |_d| async move {
858                let control = no_daemon_control();
859                let mut dashboard = init_dashboard(control.clone(), |_| false);
860                let mut setup = TestSetup::new();
861                let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
862                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
863                assert!(result.is_ok());
864                assert!(dashboard.should_quit);
865            },
866        )
867        .await;
868    }
869
870    #[tokio::test]
871    async fn execute_with_loads_config_inits_and_runs_the_loop() {
872        // Drives the whole `execute_with` composition root (Config::load +
873        // init_dashboard + execute_core) against the test terminal doubles,
874        // with both the config path and the dashboard log path isolated so
875        // nothing touches the developer's real ~/.leviath.
876        crate::config::with_isolated_config_path_async(
877            "execute_with_dashboard",
878            |_fake_dir| async move {
879                crate::runstate::with_isolated_runs_dir_async(
880                    "execute_with_dashboard",
881                    |_d| async move {
882                        let mut setup = TestSetup::new();
883                        let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
884                        let result =
885                            execute_with(no_daemon_control(), &mut setup, &mut events, |_| false)
886                                .await;
887                        assert!(result.is_ok());
888                    },
889                )
890                .await;
891            },
892        )
893        .await;
894    }
895
896    #[tokio::test]
897    async fn execute_core_enable_error_propagates() {
898        crate::runstate::with_isolated_runs_dir_async(
899            "execute_core_enable_error_propagates",
900            |_d| async move {
901                let control = no_daemon_control();
902                let mut dashboard = init_dashboard(control.clone(), |_| false);
903                // `setup.enable()?` fails first, so the loop is never reached.
904                let mut setup = TestSetup {
905                    enable_should_fail: true,
906                    create_should_fail: false,
907                    draw_should_fail: false,
908                };
909                let mut events = TestEventSource::new(vec![]);
910                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
911                assert!(result.is_err());
912            },
913        )
914        .await;
915    }
916
917    #[tokio::test]
918    async fn execute_core_create_terminal_error_propagates() {
919        crate::runstate::with_isolated_runs_dir_async(
920            "execute_core_create_terminal_error_propagates",
921            |_d| async move {
922                let control = no_daemon_control();
923                let mut dashboard = init_dashboard(control.clone(), |_| false);
924                // `enable()` succeeds, then `create_terminal()?` fails - deterministic
925                // (no real backend / TTY involved), so this can never hang.
926                let mut setup = TestSetup {
927                    enable_should_fail: false,
928                    create_should_fail: true,
929                    draw_should_fail: false,
930                };
931                let mut events = TestEventSource::new(vec![]);
932                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
933                assert!(result.is_err());
934            },
935        )
936        .await;
937    }
938
939    #[tokio::test]
940    async fn execute_core_loop_error_propagates() {
941        crate::runstate::with_isolated_runs_dir_async(
942            "execute_core_loop_error_propagates",
943            |_d| async move {
944                let control = no_daemon_control();
945                let mut dashboard = init_dashboard(control.clone(), |_| false);
946                let mut setup = TestSetup::new();
947                let mut events = TestEventSource::failing();
948                let result = execute_core(&mut dashboard, &control, &mut setup, &mut events).await;
949                assert!(result.is_err());
950            },
951        )
952        .await;
953    }
954
955    // The real `lev dash` wiring (the crossterm `CrosstermSetup` + the real
956    // `CrosstermEventSource` + the `Config::load`/`init_dashboard`/`execute_core`
957    // composition) lives in the binary's `real_dashboard`; the fully-tested
958    // seam it composes, `execute_with`, is covered by
959    // `execute_with_loads_config_inits_and_runs_the_loop` above.
960}