Skip to main content

leviath_cli/commands/dashboard/
mod.rs

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