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