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 runs via the daemon (the rows stay, marked cancelled).
130 /// Carries one id for the selected run, several when runs are marked.
131 Kill { run_ids: Vec<String> },
132 /// Cancel and permanently delete the runs' on-disk state.
133 /// Carries one id for the selected run, several when runs are marked.
134 Delete { run_ids: Vec<String> },
135 /// Remove an MCP server from the config.
136 McpRemove { name: String },
137 /// Turn on unattended runs for the new-run screen.
138 EnableYolo,
139}
140
141/// Display status for agents in the dashboard.
142#[derive(Debug, Clone, PartialEq)]
143pub enum AgentDisplayStatus {
144 /// Working.
145 Active,
146 /// Blocked on a person answering.
147 Waiting,
148 /// Finished, with nothing further to accept.
149 Complete,
150 /// All required work done; still accepting optional follow-up input.
151 CompleteInteractive,
152 /// Stopped by a failure, carrying its message.
153 Error(String),
154 /// Loaded but not currently doing anything.
155 Idle,
156 /// Paused by the user; resumable with `r` (or `lev resume`). Distinct from
157 /// `Idle` because a paused run is deliberate unfinished business, not a run
158 /// that merely has not ticked yet.
159 Paused,
160 /// Stopped from outside, by `lev kill` or a shutting-down daemon.
161 Cancelled,
162 /// On disk the run claims to be live, but the daemon has no such run and its
163 /// metadata has not been touched in a long time - so nothing is driving it.
164 ///
165 /// Shown distinctly rather than as ACTIVE because the two are not the same
166 /// thing to the user: an ACTIVE row implies work is happening. Killable, like
167 /// every other non-finished state.
168 Stale,
169}
170
171impl std::fmt::Display for AgentDisplayStatus {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 match self {
174 Self::Active => write!(f, "{}ACTIVE", GLYPH_ACTIVE),
175 Self::Waiting => write!(f, "{}WAITING", GLYPH_WAITING),
176 Self::Complete => write!(f, "{}COMPLETE", GLYPH_COMPLETE),
177 Self::CompleteInteractive => write!(f, "{}COMPLETE", GLYPH_COMPLETE),
178 Self::Error(msg) => write!(f, "{}ERROR: {}", GLYPH_ERROR, msg),
179 Self::Idle => write!(f, "{}IDLE", GLYPH_PENDING),
180 Self::Paused => write!(f, "{}PAUSED", GLYPH_PENDING),
181 Self::Cancelled => write!(f, "⊘CANCEL"),
182 Self::Stale => write!(f, "{}STALE", GLYPH_ERROR),
183 }
184 }
185}
186
187impl AgentDisplayStatus {
188 /// Whether this run has finished, one way or another.
189 pub(super) fn is_terminal(&self) -> bool {
190 matches!(
191 self,
192 Self::Complete | Self::CompleteInteractive | Self::Error(_) | Self::Cancelled
193 )
194 }
195
196 /// Whether the run can be killed. Anything that has not finished can be -
197 /// `Idle` and `Stale` included: skipping those would leave a run the
198 /// dashboard shows as live with no way to get rid of it.
199 pub(super) fn is_killable(&self) -> bool {
200 !self.is_terminal()
201 }
202
203 pub(super) fn color(&self) -> Color {
204 match self {
205 Self::Active => C_ACTIVE,
206 Self::Waiting => C_WARN,
207 Self::Complete | Self::CompleteInteractive => C_SUCCESS,
208 Self::Error(_) => C_ERROR,
209 Self::Idle => C_DIM,
210 Self::Paused => C_WARN,
211 Self::Cancelled => C_DIM,
212 Self::Stale => C_WARN,
213 }
214 }
215}
216
217/// An agent displayed in the dashboard.
218#[derive(Debug, Clone)]
219pub struct DashboardAgent {
220 /// The run id, which is also what every action against this row quotes.
221 pub id: String,
222 /// The blueprint's name, as the manifest declares it.
223 pub blueprint_name: String,
224 /// The stage the run is in, by name.
225 pub stage: String,
226 /// That stage's position in the blueprint's list.
227 pub stage_index: usize,
228 /// How many stages the blueprint has, so the pair renders as "3 of 7".
229 pub num_stages: usize,
230 /// What the row shows, including states no other status enum has.
231 pub status: AgentDisplayStatus,
232 /// Cumulative prompt (input) tokens for background runs.
233 pub tokens_in: usize,
234 /// Cumulative completion (output) tokens for background runs.
235 pub tokens_out: usize,
236 /// Cumulative tokens read from provider cache.
237 pub cached_tokens: usize,
238 /// Inference turns taken in the current stage.
239 pub iteration: usize,
240 /// The question a waiting run is asking, in one line, for the list row.
241 pub waiting_prompt: Option<String>,
242 /// Why a waiting run is parked, when `meta.json` says.
243 ///
244 /// WAITING on its own reads as "go and answer it", which is wrong for a
245 /// parent whose fan-out workers are still churning. `None` on a run that
246 /// is not parked, and on one written by a build from before the field
247 /// existed, which is why the row falls back to the bare status rather
248 /// than assuming.
249 pub wait_reason: Option<leviath_core::run_meta::WaitReason>,
250 /// Full structured interaction request (populated for WaitingInput agents)
251 pub pending_request: Option<interaction::InteractionRequest>,
252 /// The request_id we most recently submitted a response for, used to suppress
253 /// re-showing the same prompt before the worker has consumed the response.
254 pub last_answered_request_id: Option<String>,
255 /// Live context window snapshot from context.json (background workers only)
256 /// Shared, not owned: the live snapshot comes out of the sync tick's
257 /// stat-gated cache, and cloning a full context window per tick was the
258 /// churn that cache exists to remove.
259 pub context_snapshot: Option<std::sync::Arc<runstate::ContextSnapshot>>,
260 /// Per-stage records from stages.json
261 pub stages: Vec<StageRecord>,
262 /// Working directory the agent ran in
263 pub workdir: String,
264 /// Original task prompt
265 pub task: String,
266 /// Auto-generated short title (None until the worker generates it).
267 pub title: Option<String>,
268 /// Original model override
269 pub model: Option<String>,
270 /// Parent agent ID (if this is a sub-agent)
271 pub parent_id: Option<String>,
272 /// Depth in the sub-agent tree (0 = root)
273 pub depth: usize,
274 /// Unix timestamp when the run started (for elapsed display)
275 pub started_at: i64,
276 /// Unix timestamp of the run's last recorded progress (`None` before the
277 /// first progress mark). Drives the recent-activity sort.
278 pub last_progress_at: Option<i64>,
279 /// Frozen wall-clock time (Unix seconds) when the agent entered a waiting state.
280 /// Used to prevent the elapsed timer from incrementing while waiting for input.
281 pub active_until: Option<i64>,
282 /// Total seconds spent waiting for user input across all completed waits.
283 /// Subtracted from elapsed to show only actual running time.
284 pub waiting_secs: u64,
285 /// Cached graph transition info (None = linear mode or not yet loaded)
286 pub(super) graph_info: Option<GraphTransitionInfo>,
287 /// Whether the current stage accepts mid-run user messages
288 pub accepts_messages: bool,
289 /// Per-region taint levels (region_name, taint_level_string).
290 /// Empty when taint tracking is disabled or not yet populated.
291 pub taint_summary: Vec<(String, String)>,
292}
293
294/// Log entry for the dashboard log panel.
295#[derive(Debug, Clone)]
296pub(super) struct LogEntry {
297 pub(super) timestamp: String,
298 pub(super) message: String,
299}
300
301/// Command sent from the dashboard's (sync) input handlers to the async
302/// daemon-control background task, which forwards it over the control socket.
303#[derive(Debug, PartialEq)]
304pub(super) enum DaemonCommand {
305 /// Cancel a run.
306 Cancel { run_id: String },
307 /// Pause a run.
308 Pause { run_id: String },
309 /// Resume a paused run.
310 Resume { run_id: String },
311 /// Answer a pending `ask_user` interaction.
312 Answer {
313 response: interaction::InteractionResponse,
314 },
315 /// Deliver a mid-run message to a running agent.
316 Message { agent_id: String, content: String },
317}
318
319/// The result of a [`DaemonCommand`], drained each tick.
320///
321/// Discarding these would make a cancel the daemon refused look identical to
322/// one that worked: the row flashes CANCEL, the log says "Killed", and the
323/// next disk sync puts it back to ACTIVE with no explanation.
324#[derive(Debug, PartialEq)]
325pub(super) struct DaemonOutcome {
326 /// The run the command targeted.
327 pub(super) run_id: String,
328 /// Human-readable result, shown as a toast when it failed.
329 pub(super) message: String,
330 /// Whether the daemon applied it.
331 pub(super) ok: bool,
332}
333
334/// A long-running MCP action dispatched from the (sync) MCP screen to the async
335/// background task, so browser login and connect-and-list never block the UI.
336#[derive(Debug, PartialEq)]
337pub(super) enum McpCommand {
338 /// Run the OAuth browser login for a server.
339 Login { name: String },
340 /// Connect to a server and count its tools.
341 Test { name: String },
342}
343
344/// The result of an [`McpCommand`], drained each tick and shown as a toast.
345#[derive(Debug, PartialEq)]
346pub(super) struct McpOutcome {
347 /// Human-readable result to toast.
348 pub(super) message: String,
349 /// Whether it succeeded (drives the toast colour).
350 pub(super) ok: bool,
351}
352
353/// One row of the MCP management screen.
354#[derive(Debug, Clone, PartialEq)]
355pub(super) struct McpRow {
356 pub(super) name: String,
357 pub(super) transport: String,
358 pub(super) endpoint: String,
359 pub(super) auth: String,
360}
361
362/// Paths + injected seams the MCP screen's file/OAuth operations use, so the
363/// whole screen is testable without the real home directory or a browser.
364#[derive(Clone)]
365pub(super) struct McpContext {
366 pub(super) config_path: std::path::PathBuf,
367 pub(super) store_path: std::path::PathBuf,
368 pub(super) opener: leviath_mcp::BrowserOpener,
369 pub(super) clock: fn() -> u64,
370}
371
372/// Which pane of the new-run screen holds keyboard focus (Tab toggles).
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub(super) enum NewRunPane {
375 Agents,
376 Task,
377}
378
379/// One runnable agent offered by the new-run screen.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub(super) struct NewRunAgent {
382 pub(super) name: String,
383 /// Where it came from: `installed`, `configured`, `local`, or `bundled`.
384 pub(super) source: String,
385 pub(super) description: String,
386 /// What gets handed to `lev run`'s resolver: the manifest's directory for a
387 /// discovered agent, the bare name for a bundled one (which resolves only
388 /// once `lev setup` has installed it - and says so if it has not).
389 pub(super) path: String,
390}
391
392/// Where the new-run screen reads its agent catalog and its `@` file
393/// candidates from, so the whole screen is testable against a temp tree
394/// instead of the user's real home directory and working directory.
395#[derive(Clone)]
396pub(super) struct NewRunContext {
397 /// `~/.leviath/agents`, scanned for installed agents.
398 pub(super) agents_dir: std::path::PathBuf,
399 /// The config whose `agent_paths` add more places to look.
400 pub(super) config_path: std::path::PathBuf,
401 /// The directory the run's tools are confined to, and the root the `@`
402 /// completion offers files from.
403 pub(super) workdir: std::path::PathBuf,
404}
405
406/// A run the new-run screen asked for, dispatched to the async spawn lane.
407///
408/// Resolving a blueprint reads and parses files and the spawn itself is a
409/// socket round trip, so neither happens on the draw loop.
410#[derive(Debug, PartialEq, Eq)]
411pub(super) struct SpawnCommand {
412 /// The agent path or name to resolve.
413 pub(super) agent_path: String,
414 /// The task text as typed.
415 pub(super) task: String,
416 /// The working directory the run gets.
417 pub(super) workdir: String,
418 /// Whether the run approves its own tool calls.
419 pub(super) yolo: bool,
420}
421
422/// The result of a [`SpawnCommand`], drained each tick and shown as a toast.
423#[derive(Debug, PartialEq, Eq)]
424pub(super) struct SpawnOutcome {
425 /// Human-readable result to toast.
426 pub(super) message: String,
427 /// Whether the run actually started (drives the toast colour).
428 pub(super) ok: bool,
429 /// The id the daemon gave it, so the dashboard can open its page.
430 pub(super) run_id: Option<String>,
431}
432
433/// Toast notification shown as an overlay.
434#[derive(Debug, Clone)]
435pub(super) struct Toast {
436 pub(super) message: String,
437 pub(super) remaining_ticks: u32,
438 pub(super) level: ToastLevel,
439}
440
441#[derive(Debug, Clone, PartialEq)]
442pub(super) enum ToastLevel {
443 Info,
444 Warning,
445 Error,
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn agent_display_status_display() {
454 assert!(AgentDisplayStatus::Active.to_string().contains("ACTIVE"));
455 assert!(AgentDisplayStatus::Waiting.to_string().contains("WAITING"));
456 assert!(
457 AgentDisplayStatus::Complete
458 .to_string()
459 .contains("COMPLETE")
460 );
461 assert!(
462 AgentDisplayStatus::CompleteInteractive
463 .to_string()
464 .contains("COMPLETE")
465 );
466 assert!(
467 AgentDisplayStatus::Error("boom".to_string())
468 .to_string()
469 .contains("boom")
470 );
471 assert!(AgentDisplayStatus::Idle.to_string().contains("IDLE"));
472 assert!(AgentDisplayStatus::Paused.to_string().contains("PAUSED"));
473 assert!(AgentDisplayStatus::Cancelled.to_string().contains("CANCEL"));
474 assert!(AgentDisplayStatus::Stale.to_string().contains("STALE"));
475 }
476
477 #[test]
478 fn agent_display_status_colors_are_distinct() {
479 let active = AgentDisplayStatus::Active.color();
480 let error = AgentDisplayStatus::Error("x".to_string()).color();
481 let success = AgentDisplayStatus::Complete.color();
482 assert_ne!(active, error);
483 assert_ne!(error, success);
484 }
485
486 #[test]
487 fn agent_display_status_color_idle_and_cancelled() {
488 assert_eq!(AgentDisplayStatus::Idle.color(), C_DIM);
489 assert_eq!(AgentDisplayStatus::Cancelled.color(), C_DIM);
490 // Stale is a warning, not a finished state: it wants attention.
491 assert_eq!(AgentDisplayStatus::Stale.color(), C_WARN);
492 // Paused is deliberate unfinished business, not a dim afterthought.
493 assert_eq!(AgentDisplayStatus::Paused.color(), C_WARN);
494 assert!(!AgentDisplayStatus::Paused.is_terminal());
495 assert!(AgentDisplayStatus::Paused.is_killable());
496 assert_eq!(AgentDisplayStatus::Waiting.color(), C_WARN);
497 assert_eq!(AgentDisplayStatus::CompleteInteractive.color(), C_SUCCESS);
498 }
499
500 #[test]
501 fn stage_content_mode_equality() {
502 assert_eq!(StageContentMode::Output, StageContentMode::Output);
503 assert_ne!(StageContentMode::Output, StageContentMode::Logs);
504 assert_ne!(StageContentMode::Logs, StageContentMode::Context);
505 }
506
507 #[test]
508 fn toast_level_debug() {
509 let toast = Toast {
510 message: "hello".to_string(),
511 remaining_ticks: 25,
512 level: ToastLevel::Info,
513 };
514 let dbg = format!("{:?}", toast);
515 assert!(dbg.contains("hello"));
516 assert!(dbg.contains("25"));
517 }
518
519 #[test]
520 fn daemon_command_debug_and_eq() {
521 let cmd = DaemonCommand::Cancel {
522 run_id: "run-123".to_string(),
523 };
524 let dbg = format!("{:?}", cmd);
525 assert!(dbg.contains("run-123"));
526 assert_eq!(
527 cmd,
528 DaemonCommand::Cancel {
529 run_id: "run-123".to_string()
530 }
531 );
532 assert_ne!(
533 cmd,
534 DaemonCommand::Message {
535 agent_id: "a".to_string(),
536 content: "b".to_string()
537 }
538 );
539 }
540
541 #[test]
542 fn log_entry_clone() {
543 let entry = LogEntry {
544 timestamp: "12:00:00".to_string(),
545 message: "started".to_string(),
546 };
547 let cloned = entry.clone();
548 assert_eq!(cloned.timestamp, "12:00:00");
549 assert_eq!(cloned.message, "started");
550 }
551
552 #[test]
553 fn dashboard_agent_clone() {
554 let agent = DashboardAgent {
555 id: "run-1".to_string(),
556 blueprint_name: "coder".to_string(),
557 stage: "plan".to_string(),
558 stage_index: 0,
559 num_stages: 2,
560 status: AgentDisplayStatus::Active,
561 tokens_in: 100,
562 tokens_out: 50,
563 cached_tokens: 0,
564 iteration: 1,
565 waiting_prompt: None,
566 wait_reason: None,
567 pending_request: None,
568 last_answered_request_id: None,
569 context_snapshot: None,
570 stages: vec![],
571 workdir: "/tmp".to_string(),
572 task: "do stuff".to_string(),
573 title: Some("My Task".to_string()),
574 model: None,
575 parent_id: None,
576 depth: 0,
577 started_at: 1000,
578 last_progress_at: None,
579 active_until: None,
580 waiting_secs: 0,
581 graph_info: None,
582 accepts_messages: true,
583 taint_summary: vec![],
584 };
585 let cloned = agent.clone();
586 assert_eq!(cloned.id, "run-1");
587 assert_eq!(cloned.blueprint_name, "coder");
588 assert_eq!(cloned.stage, "plan");
589 assert_eq!(cloned.tokens_in, 100);
590 }
591
592 #[test]
593 fn agent_display_status_complete_interactive_shows_complete() {
594 let status = AgentDisplayStatus::CompleteInteractive;
595 let display = status.to_string();
596 assert!(display.contains("COMPLETE"));
597 assert_eq!(status.color(), C_SUCCESS);
598 }
599}