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/// Display status for agents in the dashboard.
26#[derive(Debug, Clone, PartialEq)]
27pub enum AgentDisplayStatus {
28    Active,
29    Waiting,
30    Complete,
31    /// All required work done; still accepting optional follow-up input.
32    CompleteInteractive,
33    Error(String),
34    Idle,
35    /// Paused by the user; resumable with `r` (or `lev resume`). Distinct from
36    /// `Idle` because a paused run is deliberate unfinished business, not a run
37    /// that merely has not ticked yet.
38    Paused,
39    Cancelled,
40    /// On disk the run claims to be live, but the daemon has no such run and its
41    /// metadata has not been touched in a long time - so nothing is driving it.
42    ///
43    /// Shown distinctly rather than as ACTIVE because the two are not the same
44    /// thing to the user: an ACTIVE row implies work is happening. Killable, like
45    /// every other non-finished state.
46    Stale,
47}
48
49impl std::fmt::Display for AgentDisplayStatus {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Active => write!(f, "{}ACTIVE", GLYPH_ACTIVE),
53            Self::Waiting => write!(f, "{}WAITING", GLYPH_WAITING),
54            Self::Complete => write!(f, "{}COMPLETE", GLYPH_COMPLETE),
55            Self::CompleteInteractive => write!(f, "{}COMPLETE", GLYPH_COMPLETE),
56            Self::Error(msg) => write!(f, "{}ERROR: {}", GLYPH_ERROR, msg),
57            Self::Idle => write!(f, "{}IDLE", GLYPH_PENDING),
58            Self::Paused => write!(f, "{}PAUSED", GLYPH_PENDING),
59            Self::Cancelled => write!(f, "⊘CANCEL"),
60            Self::Stale => write!(f, "{}STALE", GLYPH_ERROR),
61        }
62    }
63}
64
65impl AgentDisplayStatus {
66    /// Whether this run has finished, one way or another.
67    pub(super) fn is_terminal(&self) -> bool {
68        matches!(
69            self,
70            Self::Complete | Self::CompleteInteractive | Self::Error(_) | Self::Cancelled
71        )
72    }
73
74    /// Whether the run can be killed. Anything that has not finished can be -
75    /// `Idle` and `Stale` included: skipping those would leave a run the
76    /// dashboard shows as live with no way to get rid of it.
77    pub(super) fn is_killable(&self) -> bool {
78        !self.is_terminal()
79    }
80
81    pub(super) fn color(&self) -> Color {
82        match self {
83            Self::Active => C_ACTIVE,
84            Self::Waiting => C_WARN,
85            Self::Complete | Self::CompleteInteractive => C_SUCCESS,
86            Self::Error(_) => C_ERROR,
87            Self::Idle => C_DIM,
88            Self::Paused => C_WARN,
89            Self::Cancelled => C_DIM,
90            Self::Stale => C_WARN,
91        }
92    }
93}
94
95/// An agent displayed in the dashboard.
96#[derive(Debug, Clone)]
97pub struct DashboardAgent {
98    pub id: String,
99    pub blueprint_name: String,
100    pub stage: String,
101    pub stage_index: usize,
102    pub num_stages: usize,
103    pub status: AgentDisplayStatus,
104    /// Cumulative prompt (input) tokens for background runs.
105    pub tokens_in: usize,
106    /// Cumulative completion (output) tokens for background runs.
107    pub tokens_out: usize,
108    /// Cumulative tokens read from provider cache.
109    pub cached_tokens: usize,
110    pub iteration: usize,
111    pub waiting_prompt: Option<String>,
112    /// Full structured interaction request (populated for WaitingInput agents)
113    pub pending_request: Option<interaction::InteractionRequest>,
114    /// The request_id we most recently submitted a response for, used to suppress
115    /// re-showing the same prompt before the worker has consumed the response.
116    pub last_answered_request_id: Option<String>,
117    /// Live context window snapshot from context.json (background workers only)
118    pub context_snapshot: Option<runstate::ContextSnapshot>,
119    /// Per-stage records from stages.json
120    pub stages: Vec<StageRecord>,
121    /// Working directory the agent ran in
122    pub workdir: String,
123    /// Original task prompt
124    pub task: String,
125    /// Auto-generated short title (None until the worker generates it).
126    pub title: Option<String>,
127    /// Original model override
128    pub model: Option<String>,
129    /// Parent agent ID (if this is a sub-agent)
130    pub parent_id: Option<String>,
131    /// Depth in the sub-agent tree (0 = root)
132    pub depth: usize,
133    /// Unix timestamp when the run started (for elapsed display)
134    pub started_at: i64,
135    /// Frozen wall-clock time (Unix seconds) when the agent entered a waiting state.
136    /// Used to prevent the elapsed timer from incrementing while waiting for input.
137    pub active_until: Option<i64>,
138    /// Total seconds spent waiting for user input across all completed waits.
139    /// Subtracted from elapsed to show only actual running time.
140    pub waiting_secs: u64,
141    /// Cached graph transition info (None = linear mode or not yet loaded)
142    pub(super) graph_info: Option<GraphTransitionInfo>,
143    /// Whether the current stage accepts mid-run user messages
144    pub accepts_messages: bool,
145    /// Per-region taint levels (region_name, taint_level_string).
146    /// Empty when taint tracking is disabled or not yet populated.
147    pub taint_summary: Vec<(String, String)>,
148}
149
150/// Log entry for the dashboard log panel.
151#[derive(Debug, Clone)]
152pub(super) struct LogEntry {
153    pub(super) timestamp: String,
154    pub(super) message: String,
155}
156
157/// Command sent from the dashboard's (sync) input handlers to the async
158/// daemon-control background task, which forwards it over the control socket.
159#[derive(Debug, PartialEq)]
160pub(super) enum DaemonCommand {
161    /// Cancel a run.
162    Cancel { run_id: String },
163    /// Pause a run.
164    Pause { run_id: String },
165    /// Resume a paused run.
166    Resume { run_id: String },
167    /// Answer a pending `ask_user` interaction.
168    Answer {
169        response: interaction::InteractionResponse,
170    },
171    /// Deliver a mid-run message to a running agent.
172    Message { agent_id: String, content: String },
173}
174
175/// The result of a [`DaemonCommand`], drained each tick.
176///
177/// Discarding these would make a cancel the daemon refused look identical to
178/// one that worked: the row flashes CANCEL, the log says "Killed", and the
179/// next disk sync puts it back to ACTIVE with no explanation.
180#[derive(Debug, PartialEq)]
181pub(super) struct DaemonOutcome {
182    /// The run the command targeted.
183    pub(super) run_id: String,
184    /// Human-readable result, shown as a toast when it failed.
185    pub(super) message: String,
186    /// Whether the daemon applied it.
187    pub(super) ok: bool,
188}
189
190/// A long-running MCP action dispatched from the (sync) MCP screen to the async
191/// background task, so browser login and connect-and-list never block the UI.
192#[derive(Debug, PartialEq)]
193pub(super) enum McpCommand {
194    /// Run the OAuth browser login for a server.
195    Login { name: String },
196    /// Connect to a server and count its tools.
197    Test { name: String },
198}
199
200/// The result of an [`McpCommand`], drained each tick and shown as a toast.
201#[derive(Debug, PartialEq)]
202pub(super) struct McpOutcome {
203    /// Human-readable result to toast.
204    pub(super) message: String,
205    /// Whether it succeeded (drives the toast colour).
206    pub(super) ok: bool,
207}
208
209/// One row of the MCP management screen.
210#[derive(Debug, Clone, PartialEq)]
211pub(super) struct McpRow {
212    pub(super) name: String,
213    pub(super) transport: String,
214    pub(super) endpoint: String,
215    pub(super) auth: String,
216}
217
218/// Paths + injected seams the MCP screen's file/OAuth operations use, so the
219/// whole screen is testable without the real home directory or a browser.
220#[derive(Clone)]
221pub(super) struct McpContext {
222    pub(super) config_path: std::path::PathBuf,
223    pub(super) store_path: std::path::PathBuf,
224    pub(super) opener: leviath_mcp::BrowserOpener,
225    pub(super) clock: fn() -> u64,
226}
227
228/// Toast notification shown as an overlay.
229#[derive(Debug, Clone)]
230pub(super) struct Toast {
231    pub(super) message: String,
232    pub(super) remaining_ticks: u32,
233    pub(super) level: ToastLevel,
234}
235
236#[derive(Debug, Clone, PartialEq)]
237pub(super) enum ToastLevel {
238    Info,
239    Warning,
240    Error,
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn agent_display_status_display() {
249        assert!(AgentDisplayStatus::Active.to_string().contains("ACTIVE"));
250        assert!(AgentDisplayStatus::Waiting.to_string().contains("WAITING"));
251        assert!(
252            AgentDisplayStatus::Complete
253                .to_string()
254                .contains("COMPLETE")
255        );
256        assert!(
257            AgentDisplayStatus::CompleteInteractive
258                .to_string()
259                .contains("COMPLETE")
260        );
261        assert!(
262            AgentDisplayStatus::Error("boom".to_string())
263                .to_string()
264                .contains("boom")
265        );
266        assert!(AgentDisplayStatus::Idle.to_string().contains("IDLE"));
267        assert!(AgentDisplayStatus::Paused.to_string().contains("PAUSED"));
268        assert!(AgentDisplayStatus::Cancelled.to_string().contains("CANCEL"));
269        assert!(AgentDisplayStatus::Stale.to_string().contains("STALE"));
270    }
271
272    #[test]
273    fn agent_display_status_colors_are_distinct() {
274        let active = AgentDisplayStatus::Active.color();
275        let error = AgentDisplayStatus::Error("x".to_string()).color();
276        let success = AgentDisplayStatus::Complete.color();
277        assert_ne!(active, error);
278        assert_ne!(error, success);
279    }
280
281    #[test]
282    fn agent_display_status_color_idle_and_cancelled() {
283        assert_eq!(AgentDisplayStatus::Idle.color(), C_DIM);
284        assert_eq!(AgentDisplayStatus::Cancelled.color(), C_DIM);
285        // Stale is a warning, not a finished state: it wants attention.
286        assert_eq!(AgentDisplayStatus::Stale.color(), C_WARN);
287        // Paused is deliberate unfinished business, not a dim afterthought.
288        assert_eq!(AgentDisplayStatus::Paused.color(), C_WARN);
289        assert!(!AgentDisplayStatus::Paused.is_terminal());
290        assert!(AgentDisplayStatus::Paused.is_killable());
291        assert_eq!(AgentDisplayStatus::Waiting.color(), C_WARN);
292        assert_eq!(AgentDisplayStatus::CompleteInteractive.color(), C_SUCCESS);
293    }
294
295    #[test]
296    fn stage_content_mode_equality() {
297        assert_eq!(StageContentMode::Output, StageContentMode::Output);
298        assert_ne!(StageContentMode::Output, StageContentMode::Logs);
299        assert_ne!(StageContentMode::Logs, StageContentMode::Context);
300    }
301
302    #[test]
303    fn toast_level_debug() {
304        let toast = Toast {
305            message: "hello".to_string(),
306            remaining_ticks: 25,
307            level: ToastLevel::Info,
308        };
309        let dbg = format!("{:?}", toast);
310        assert!(dbg.contains("hello"));
311        assert!(dbg.contains("25"));
312    }
313
314    #[test]
315    fn daemon_command_debug_and_eq() {
316        let cmd = DaemonCommand::Cancel {
317            run_id: "run-123".to_string(),
318        };
319        let dbg = format!("{:?}", cmd);
320        assert!(dbg.contains("run-123"));
321        assert_eq!(
322            cmd,
323            DaemonCommand::Cancel {
324                run_id: "run-123".to_string()
325            }
326        );
327        assert_ne!(
328            cmd,
329            DaemonCommand::Message {
330                agent_id: "a".to_string(),
331                content: "b".to_string()
332            }
333        );
334    }
335
336    #[test]
337    fn log_entry_clone() {
338        let entry = LogEntry {
339            timestamp: "12:00:00".to_string(),
340            message: "started".to_string(),
341        };
342        let cloned = entry.clone();
343        assert_eq!(cloned.timestamp, "12:00:00");
344        assert_eq!(cloned.message, "started");
345    }
346
347    #[test]
348    fn dashboard_agent_clone() {
349        let agent = DashboardAgent {
350            id: "run-1".to_string(),
351            blueprint_name: "coder".to_string(),
352            stage: "plan".to_string(),
353            stage_index: 0,
354            num_stages: 2,
355            status: AgentDisplayStatus::Active,
356            tokens_in: 100,
357            tokens_out: 50,
358            cached_tokens: 0,
359            iteration: 1,
360            waiting_prompt: None,
361            pending_request: None,
362            last_answered_request_id: None,
363            context_snapshot: None,
364            stages: vec![],
365            workdir: "/tmp".to_string(),
366            task: "do stuff".to_string(),
367            title: Some("My Task".to_string()),
368            model: None,
369            parent_id: None,
370            depth: 0,
371            started_at: 1000,
372            active_until: None,
373            waiting_secs: 0,
374            graph_info: None,
375            accepts_messages: true,
376            taint_summary: vec![],
377        };
378        let cloned = agent.clone();
379        assert_eq!(cloned.id, "run-1");
380        assert_eq!(cloned.blueprint_name, "coder");
381        assert_eq!(cloned.stage, "plan");
382        assert_eq!(cloned.tokens_in, 100);
383    }
384
385    #[test]
386    fn agent_display_status_complete_interactive_shows_complete() {
387        let status = AgentDisplayStatus::CompleteInteractive;
388        let display = status.to_string();
389        assert!(display.contains("COMPLETE"));
390        assert_eq!(status.color(), C_SUCCESS);
391    }
392}