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