Skip to main content

leviath_cli/commands/dashboard/
types.rs

1//! Type definitions for the dashboard: display status, agent representation, events.
2
3use clap::Args;
4
5use super::graph::GraphTransitionInfo;
6use super::theme::{C_ACTIVE, C_DIM, C_ERROR, C_SUCCESS, C_WARN};
7use super::theme::{GLYPH_ACTIVE, GLYPH_COMPLETE, GLYPH_ERROR, GLYPH_PENDING, GLYPH_WAITING};
8
9use crate::runstate::{self, StageRecord};
10use leviath_core::interaction;
11
12use ratatui::style::Color;
13
14/// Arguments for `lev dash`. It takes none; the dashboard is interactive.
15#[derive(Args)]
16pub struct DashboardArgs {}
17
18/// Whether the detail content pane shows Output or Logs.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub(super) enum StageContentMode {
21    Output,
22    Logs,
23    Context,
24}
25
26/// How the main run list is ordered. Whatever the mode, the order is a total
27/// one (unique tie-break by id), so a status change alone never reshuffles
28/// rows within a mode.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(super) enum SortMode {
31    /// Newest run first, and a run keeps its row for its whole life. The
32    /// default: predictable, nothing ever jumps.
33    StartedAt,
34    /// Most recently progressed run first: whatever just did something is on
35    /// top. Rows move only on real progress, never on a status flip alone.
36    RecentActivity,
37    /// The old grouping: active first, finished below, stable within a group.
38    StatusGrouped,
39}
40
41impl SortMode {
42    pub(super) fn next(self) -> Self {
43        match self {
44            Self::StartedAt => Self::RecentActivity,
45            Self::RecentActivity => Self::StatusGrouped,
46            Self::StatusGrouped => Self::StartedAt,
47        }
48    }
49
50    /// Short label for the table title.
51    pub(super) fn label(self) -> &'static str {
52        match self {
53            Self::StartedAt => "started",
54            Self::RecentActivity => "activity",
55            Self::StatusGrouped => "status",
56        }
57    }
58}
59
60/// Which pane of the main screen holds keyboard focus.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub(super) enum MainPane {
63    RunList,
64    LogPane,
65}
66
67/// A pane with its own wheel-scroll behavior, hit-tested against the rects
68/// each renderer registers per frame. Panes not listed here (detail content,
69/// review) share the keyboard's scroll target via `scroll_by`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub(super) enum PaneId {
72    RunTable,
73    LogPanel,
74}
75
76/// Which tab of the full-screen stage explorer is showing.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub(super) enum ExplorerTab {
79    Graph,
80    Timeline,
81}
82
83/// The full-screen stage explorer (`g` in the detail view of a graph agent):
84/// a real layered rendering of the stage DAG, and the visit timeline the old
85/// one-row strip could not show.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub(super) struct ExplorerState {
88    pub(super) tab: ExplorerTab,
89    /// Show stages never visited (dimmed); `u` toggles them off.
90    pub(super) show_unvisited: bool,
91    /// Vertical scroll of the graph canvas, in rows.
92    pub(super) scroll: usize,
93    /// Selected row on the timeline tab.
94    pub(super) timeline_selected: usize,
95}
96
97impl ExplorerState {
98    pub(super) fn new() -> Self {
99        Self {
100            tab: ExplorerTab::Graph,
101            show_unvisited: true,
102            scroll: 0,
103            timeline_selected: 0,
104        }
105    }
106}
107
108/// Cursor + expansion state of the structured Context view.
109///
110/// Regions default to expanded (header + one-line entry stubs); entries
111/// default to collapsed. The state survives ticks and history steps, and
112/// resets only when the selected run changes.
113#[derive(Debug, Clone, Default)]
114pub(super) struct ContextTreeState {
115    /// Regions whose entry list is folded away.
116    pub(super) collapsed_regions: std::collections::HashSet<String>,
117    /// `(region, entry_index)` pairs expanded to their full content.
118    pub(super) expanded_entries: std::collections::HashSet<(String, usize)>,
119    /// Cursor over the tree's interactive rows (headers + stubs).
120    pub(super) cursor: usize,
121    /// Set when a key moved the cursor, so the renderer scrolls to it once
122    /// rather than pinning the view to the cursor forever.
123    pub(super) follow_cursor: bool,
124}
125
126/// A destructive action waiting on its confirmation dialog.
127#[derive(Debug, Clone, PartialEq)]
128pub(super) enum ConfirmAction {
129    /// Cancel the run via the daemon (the row stays, marked cancelled).
130    Kill { run_id: String },
131    /// Cancel and permanently delete the run's on-disk state.
132    Delete { run_id: String },
133    /// Remove an MCP server from the config.
134    McpRemove { name: String },
135    /// Turn on unattended runs for the new-run screen.
136    EnableYolo,
137}
138
139/// Display status for agents in the dashboard.
140#[derive(Debug, Clone, PartialEq)]
141pub enum AgentDisplayStatus {
142    /// Working.
143    Active,
144    /// Blocked on a person answering.
145    Waiting,
146    /// Finished, with nothing further to accept.
147    Complete,
148    /// All required work done; still accepting optional follow-up input.
149    CompleteInteractive,
150    /// Stopped by a failure, carrying its message.
151    Error(String),
152    /// Loaded but not currently doing anything.
153    Idle,
154    /// Paused by the user; resumable with `r` (or `lev resume`). Distinct from
155    /// `Idle` because a paused run is deliberate unfinished business, not a run
156    /// that merely has not ticked yet.
157    Paused,
158    /// Stopped from outside, by `lev kill` or a shutting-down daemon.
159    Cancelled,
160    /// On disk the run claims to be live, but the daemon has no such run and its
161    /// metadata has not been touched in a long time - so nothing is driving it.
162    ///
163    /// Shown distinctly rather than as ACTIVE because the two are not the same
164    /// thing to the user: an ACTIVE row implies work is happening. Killable, like
165    /// every other non-finished state.
166    Stale,
167}
168
169impl std::fmt::Display for AgentDisplayStatus {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            Self::Active => write!(f, "{}ACTIVE", GLYPH_ACTIVE),
173            Self::Waiting => write!(f, "{}WAITING", GLYPH_WAITING),
174            Self::Complete => write!(f, "{}COMPLETE", GLYPH_COMPLETE),
175            Self::CompleteInteractive => write!(f, "{}COMPLETE", GLYPH_COMPLETE),
176            Self::Error(msg) => write!(f, "{}ERROR: {}", GLYPH_ERROR, msg),
177            Self::Idle => write!(f, "{}IDLE", GLYPH_PENDING),
178            Self::Paused => write!(f, "{}PAUSED", GLYPH_PENDING),
179            Self::Cancelled => write!(f, "⊘CANCEL"),
180            Self::Stale => write!(f, "{}STALE", GLYPH_ERROR),
181        }
182    }
183}
184
185impl AgentDisplayStatus {
186    /// Whether this run has finished, one way or another.
187    pub(super) fn is_terminal(&self) -> bool {
188        matches!(
189            self,
190            Self::Complete | Self::CompleteInteractive | Self::Error(_) | Self::Cancelled
191        )
192    }
193
194    /// Whether the run can be killed. Anything that has not finished can be -
195    /// `Idle` and `Stale` included: skipping those would leave a run the
196    /// dashboard shows as live with no way to get rid of it.
197    pub(super) fn is_killable(&self) -> bool {
198        !self.is_terminal()
199    }
200
201    pub(super) fn color(&self) -> Color {
202        match self {
203            Self::Active => C_ACTIVE,
204            Self::Waiting => C_WARN,
205            Self::Complete | Self::CompleteInteractive => C_SUCCESS,
206            Self::Error(_) => C_ERROR,
207            Self::Idle => C_DIM,
208            Self::Paused => C_WARN,
209            Self::Cancelled => C_DIM,
210            Self::Stale => C_WARN,
211        }
212    }
213}
214
215/// An agent displayed in the dashboard.
216#[derive(Debug, Clone)]
217pub struct DashboardAgent {
218    /// The run id, which is also what every action against this row quotes.
219    pub id: String,
220    /// The blueprint's name, as the manifest declares it.
221    pub blueprint_name: String,
222    /// The stage the run is in, by name.
223    pub stage: String,
224    /// That stage's position in the blueprint's list.
225    pub stage_index: usize,
226    /// How many stages the blueprint has, so the pair renders as "3 of 7".
227    pub num_stages: usize,
228    /// What the row shows, including states no other status enum has.
229    pub status: AgentDisplayStatus,
230    /// Cumulative prompt (input) tokens for background runs.
231    pub tokens_in: usize,
232    /// Cumulative completion (output) tokens for background runs.
233    pub tokens_out: usize,
234    /// Cumulative tokens read from provider cache.
235    pub cached_tokens: usize,
236    /// Inference turns taken in the current stage.
237    pub iteration: usize,
238    /// The question a waiting run is asking, in one line, for the list row.
239    pub waiting_prompt: Option<String>,
240    /// Full structured interaction request (populated for WaitingInput agents)
241    pub pending_request: Option<interaction::InteractionRequest>,
242    /// The request_id we most recently submitted a response for, used to suppress
243    /// re-showing the same prompt before the worker has consumed the response.
244    pub last_answered_request_id: Option<String>,
245    /// Live context window snapshot from context.json (background workers only)
246    /// Shared, not owned: the live snapshot comes out of the sync tick's
247    /// stat-gated cache, and cloning a full context window per tick was the
248    /// churn that cache exists to remove.
249    pub context_snapshot: Option<std::sync::Arc<runstate::ContextSnapshot>>,
250    /// Per-stage records from stages.json
251    pub stages: Vec<StageRecord>,
252    /// Working directory the agent ran in
253    pub workdir: String,
254    /// Original task prompt
255    pub task: String,
256    /// Auto-generated short title (None until the worker generates it).
257    pub title: Option<String>,
258    /// Original model override
259    pub model: Option<String>,
260    /// Parent agent ID (if this is a sub-agent)
261    pub parent_id: Option<String>,
262    /// Depth in the sub-agent tree (0 = root)
263    pub depth: usize,
264    /// Unix timestamp when the run started (for elapsed display)
265    pub started_at: i64,
266    /// Unix timestamp of the run's last recorded progress (`None` before the
267    /// first progress mark). Drives the recent-activity sort.
268    pub last_progress_at: Option<i64>,
269    /// Frozen wall-clock time (Unix seconds) when the agent entered a waiting state.
270    /// Used to prevent the elapsed timer from incrementing while waiting for input.
271    pub active_until: Option<i64>,
272    /// Total seconds spent waiting for user input across all completed waits.
273    /// Subtracted from elapsed to show only actual running time.
274    pub waiting_secs: u64,
275    /// Cached graph transition info (None = linear mode or not yet loaded)
276    pub(super) graph_info: Option<GraphTransitionInfo>,
277    /// Whether the current stage accepts mid-run user messages
278    pub accepts_messages: bool,
279    /// Per-region taint levels (region_name, taint_level_string).
280    /// Empty when taint tracking is disabled or not yet populated.
281    pub taint_summary: Vec<(String, String)>,
282}
283
284/// Log entry for the dashboard log panel.
285#[derive(Debug, Clone)]
286pub(super) struct LogEntry {
287    pub(super) timestamp: String,
288    pub(super) message: String,
289}
290
291/// Command sent from the dashboard's (sync) input handlers to the async
292/// daemon-control background task, which forwards it over the control socket.
293#[derive(Debug, PartialEq)]
294pub(super) enum DaemonCommand {
295    /// Cancel a run.
296    Cancel { run_id: String },
297    /// Pause a run.
298    Pause { run_id: String },
299    /// Resume a paused run.
300    Resume { run_id: String },
301    /// Answer a pending `ask_user` interaction.
302    Answer {
303        response: interaction::InteractionResponse,
304    },
305    /// Deliver a mid-run message to a running agent.
306    Message { agent_id: String, content: String },
307}
308
309/// The result of a [`DaemonCommand`], drained each tick.
310///
311/// Discarding these would make a cancel the daemon refused look identical to
312/// one that worked: the row flashes CANCEL, the log says "Killed", and the
313/// next disk sync puts it back to ACTIVE with no explanation.
314#[derive(Debug, PartialEq)]
315pub(super) struct DaemonOutcome {
316    /// The run the command targeted.
317    pub(super) run_id: String,
318    /// Human-readable result, shown as a toast when it failed.
319    pub(super) message: String,
320    /// Whether the daemon applied it.
321    pub(super) ok: bool,
322}
323
324/// A long-running MCP action dispatched from the (sync) MCP screen to the async
325/// background task, so browser login and connect-and-list never block the UI.
326#[derive(Debug, PartialEq)]
327pub(super) enum McpCommand {
328    /// Run the OAuth browser login for a server.
329    Login { name: String },
330    /// Connect to a server and count its tools.
331    Test { name: String },
332}
333
334/// The result of an [`McpCommand`], drained each tick and shown as a toast.
335#[derive(Debug, PartialEq)]
336pub(super) struct McpOutcome {
337    /// Human-readable result to toast.
338    pub(super) message: String,
339    /// Whether it succeeded (drives the toast colour).
340    pub(super) ok: bool,
341}
342
343/// One row of the MCP management screen.
344#[derive(Debug, Clone, PartialEq)]
345pub(super) struct McpRow {
346    pub(super) name: String,
347    pub(super) transport: String,
348    pub(super) endpoint: String,
349    pub(super) auth: String,
350}
351
352/// Paths + injected seams the MCP screen's file/OAuth operations use, so the
353/// whole screen is testable without the real home directory or a browser.
354#[derive(Clone)]
355pub(super) struct McpContext {
356    pub(super) config_path: std::path::PathBuf,
357    pub(super) store_path: std::path::PathBuf,
358    pub(super) opener: leviath_mcp::BrowserOpener,
359    pub(super) clock: fn() -> u64,
360}
361
362/// Which pane of the new-run screen holds keyboard focus (Tab toggles).
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub(super) enum NewRunPane {
365    Agents,
366    Task,
367}
368
369/// One runnable agent offered by the new-run screen.
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub(super) struct NewRunAgent {
372    pub(super) name: String,
373    /// Where it came from: `installed`, `configured`, `local`, or `bundled`.
374    pub(super) source: String,
375    pub(super) description: String,
376    /// What gets handed to `lev run`'s resolver: the manifest's directory for a
377    /// discovered agent, the bare name for a bundled one (which resolves only
378    /// once `lev setup` has installed it - and says so if it has not).
379    pub(super) path: String,
380}
381
382/// Where the new-run screen reads its agent catalog and its `@` file
383/// candidates from, so the whole screen is testable against a temp tree
384/// instead of the user's real home directory and working directory.
385#[derive(Clone)]
386pub(super) struct NewRunContext {
387    /// `~/.leviath/agents`, scanned for installed agents.
388    pub(super) agents_dir: std::path::PathBuf,
389    /// The config whose `agent_paths` add more places to look.
390    pub(super) config_path: std::path::PathBuf,
391    /// The directory the run's tools are confined to, and the root the `@`
392    /// completion offers files from.
393    pub(super) workdir: std::path::PathBuf,
394}
395
396/// A run the new-run screen asked for, dispatched to the async spawn lane.
397///
398/// Resolving a blueprint reads and parses files and the spawn itself is a
399/// socket round trip, so neither happens on the draw loop.
400#[derive(Debug, PartialEq, Eq)]
401pub(super) struct SpawnCommand {
402    /// The agent path or name to resolve.
403    pub(super) agent_path: String,
404    /// The task text as typed.
405    pub(super) task: String,
406    /// The working directory the run gets.
407    pub(super) workdir: String,
408    /// Whether the run approves its own tool calls.
409    pub(super) yolo: bool,
410}
411
412/// The result of a [`SpawnCommand`], drained each tick and shown as a toast.
413#[derive(Debug, PartialEq, Eq)]
414pub(super) struct SpawnOutcome {
415    /// Human-readable result to toast.
416    pub(super) message: String,
417    /// Whether the run actually started (drives the toast colour).
418    pub(super) ok: bool,
419    /// The id the daemon gave it, so the dashboard can open its page.
420    pub(super) run_id: Option<String>,
421}
422
423/// Toast notification shown as an overlay.
424#[derive(Debug, Clone)]
425pub(super) struct Toast {
426    pub(super) message: String,
427    pub(super) remaining_ticks: u32,
428    pub(super) level: ToastLevel,
429}
430
431#[derive(Debug, Clone, PartialEq)]
432pub(super) enum ToastLevel {
433    Info,
434    Warning,
435    Error,
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn agent_display_status_display() {
444        assert!(AgentDisplayStatus::Active.to_string().contains("ACTIVE"));
445        assert!(AgentDisplayStatus::Waiting.to_string().contains("WAITING"));
446        assert!(
447            AgentDisplayStatus::Complete
448                .to_string()
449                .contains("COMPLETE")
450        );
451        assert!(
452            AgentDisplayStatus::CompleteInteractive
453                .to_string()
454                .contains("COMPLETE")
455        );
456        assert!(
457            AgentDisplayStatus::Error("boom".to_string())
458                .to_string()
459                .contains("boom")
460        );
461        assert!(AgentDisplayStatus::Idle.to_string().contains("IDLE"));
462        assert!(AgentDisplayStatus::Paused.to_string().contains("PAUSED"));
463        assert!(AgentDisplayStatus::Cancelled.to_string().contains("CANCEL"));
464        assert!(AgentDisplayStatus::Stale.to_string().contains("STALE"));
465    }
466
467    #[test]
468    fn agent_display_status_colors_are_distinct() {
469        let active = AgentDisplayStatus::Active.color();
470        let error = AgentDisplayStatus::Error("x".to_string()).color();
471        let success = AgentDisplayStatus::Complete.color();
472        assert_ne!(active, error);
473        assert_ne!(error, success);
474    }
475
476    #[test]
477    fn agent_display_status_color_idle_and_cancelled() {
478        assert_eq!(AgentDisplayStatus::Idle.color(), C_DIM);
479        assert_eq!(AgentDisplayStatus::Cancelled.color(), C_DIM);
480        // Stale is a warning, not a finished state: it wants attention.
481        assert_eq!(AgentDisplayStatus::Stale.color(), C_WARN);
482        // Paused is deliberate unfinished business, not a dim afterthought.
483        assert_eq!(AgentDisplayStatus::Paused.color(), C_WARN);
484        assert!(!AgentDisplayStatus::Paused.is_terminal());
485        assert!(AgentDisplayStatus::Paused.is_killable());
486        assert_eq!(AgentDisplayStatus::Waiting.color(), C_WARN);
487        assert_eq!(AgentDisplayStatus::CompleteInteractive.color(), C_SUCCESS);
488    }
489
490    #[test]
491    fn stage_content_mode_equality() {
492        assert_eq!(StageContentMode::Output, StageContentMode::Output);
493        assert_ne!(StageContentMode::Output, StageContentMode::Logs);
494        assert_ne!(StageContentMode::Logs, StageContentMode::Context);
495    }
496
497    #[test]
498    fn toast_level_debug() {
499        let toast = Toast {
500            message: "hello".to_string(),
501            remaining_ticks: 25,
502            level: ToastLevel::Info,
503        };
504        let dbg = format!("{:?}", toast);
505        assert!(dbg.contains("hello"));
506        assert!(dbg.contains("25"));
507    }
508
509    #[test]
510    fn daemon_command_debug_and_eq() {
511        let cmd = DaemonCommand::Cancel {
512            run_id: "run-123".to_string(),
513        };
514        let dbg = format!("{:?}", cmd);
515        assert!(dbg.contains("run-123"));
516        assert_eq!(
517            cmd,
518            DaemonCommand::Cancel {
519                run_id: "run-123".to_string()
520            }
521        );
522        assert_ne!(
523            cmd,
524            DaemonCommand::Message {
525                agent_id: "a".to_string(),
526                content: "b".to_string()
527            }
528        );
529    }
530
531    #[test]
532    fn log_entry_clone() {
533        let entry = LogEntry {
534            timestamp: "12:00:00".to_string(),
535            message: "started".to_string(),
536        };
537        let cloned = entry.clone();
538        assert_eq!(cloned.timestamp, "12:00:00");
539        assert_eq!(cloned.message, "started");
540    }
541
542    #[test]
543    fn dashboard_agent_clone() {
544        let agent = DashboardAgent {
545            id: "run-1".to_string(),
546            blueprint_name: "coder".to_string(),
547            stage: "plan".to_string(),
548            stage_index: 0,
549            num_stages: 2,
550            status: AgentDisplayStatus::Active,
551            tokens_in: 100,
552            tokens_out: 50,
553            cached_tokens: 0,
554            iteration: 1,
555            waiting_prompt: None,
556            pending_request: None,
557            last_answered_request_id: None,
558            context_snapshot: None,
559            stages: vec![],
560            workdir: "/tmp".to_string(),
561            task: "do stuff".to_string(),
562            title: Some("My Task".to_string()),
563            model: None,
564            parent_id: None,
565            depth: 0,
566            started_at: 1000,
567            last_progress_at: None,
568            active_until: None,
569            waiting_secs: 0,
570            graph_info: None,
571            accepts_messages: true,
572            taint_summary: vec![],
573        };
574        let cloned = agent.clone();
575        assert_eq!(cloned.id, "run-1");
576        assert_eq!(cloned.blueprint_name, "coder");
577        assert_eq!(cloned.stage, "plan");
578        assert_eq!(cloned.tokens_in, 100);
579    }
580
581    #[test]
582    fn agent_display_status_complete_interactive_shows_complete() {
583        let status = AgentDisplayStatus::CompleteInteractive;
584        let display = status.to_string();
585        assert!(display.contains("COMPLETE"));
586        assert_eq!(status.color(), C_SUCCESS);
587    }
588}