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