Skip to main content

yarli_cli/dashboard/
state.rs

1//! Panel state management for the dashboard TUI.
2//!
3//! `PanelManager` maintains retained state for focus, collapse/expand,
4//! scroll positions, and selected task across all panels (~300 LOC, Section 32).
5
6use std::collections::{HashMap, VecDeque};
7use std::time::Duration;
8
9use uuid::Uuid;
10
11use crate::yarli_core::domain::TaskId;
12use crate::yarli_core::fsm::task::TaskState;
13
14use crate::stream::TaskView;
15
16/// Identifies a panel in the dashboard layout.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum PanelId {
19    /// Left panel: task list with state glyphs and timing.
20    TaskList,
21    /// Right panel: selected task's live output.
22    Output,
23    /// Gate/merge status panel.
24    Gates,
25    /// Audit event panel.
26    Audit,
27    /// Key hints bar (not focusable).
28    KeyHints,
29}
30
31impl PanelId {
32    /// Panels in focus-cycling order (Tab/Shift+Tab).
33    pub const FOCUSABLE: &[PanelId] = &[
34        PanelId::TaskList,
35        PanelId::Output,
36        PanelId::Gates,
37        PanelId::Audit,
38    ];
39
40    /// Panel title for display.
41    pub fn title(self) -> &'static str {
42        match self {
43            PanelId::TaskList => "Tasks",
44            PanelId::Output => "Output",
45            PanelId::Gates => "Gates",
46            PanelId::Audit => "Audit",
47            PanelId::KeyHints => "Keys",
48        }
49    }
50
51    /// Keyboard shortcut number for direct panel jump.
52    pub fn shortcut(self) -> Option<char> {
53        match self {
54            PanelId::TaskList => Some('1'),
55            PanelId::Output => Some('2'),
56            PanelId::Gates => Some('3'),
57            PanelId::Audit => Some('4'),
58            PanelId::KeyHints => None,
59        }
60    }
61}
62
63/// Visual state of a panel.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum PanelState {
66    /// Fully visible (default).
67    Expanded,
68    /// Header-only (collapsed).
69    Collapsed,
70    /// Completely hidden.
71    Hidden,
72}
73
74impl PanelState {
75    /// Cycle: Expanded -> Collapsed -> Hidden -> Expanded.
76    pub fn collapse(self) -> PanelState {
77        match self {
78            PanelState::Expanded => PanelState::Collapsed,
79            PanelState::Collapsed => PanelState::Hidden,
80            PanelState::Hidden => PanelState::Expanded,
81        }
82    }
83
84    /// Reverse cycle.
85    pub fn expand(self) -> PanelState {
86        match self {
87            PanelState::Hidden => PanelState::Collapsed,
88            PanelState::Collapsed => PanelState::Expanded,
89            PanelState::Expanded => PanelState::Expanded,
90        }
91    }
92}
93
94/// Retained state for the dashboard panels.
95pub struct PanelManager {
96    /// Which panel has focus.
97    pub focused: PanelId,
98    /// Per-panel visual state.
99    pub panel_states: HashMap<PanelId, PanelState>,
100    /// Per-panel scroll offset (lines from top).
101    pub scroll_offsets: HashMap<PanelId, u16>,
102    /// Task list: ordered task IDs.
103    pub task_order: Vec<TaskId>,
104    /// Task list: task views keyed by ID.
105    pub tasks: HashMap<TaskId, TaskView>,
106    /// Selected task index in task_order (for cursor).
107    pub selected_task_idx: usize,
108    /// Output lines for the selected task (view into per-task buffer).
109    pub output_lines: Vec<String>,
110    /// Per-task output ring buffers (capped at 1000 lines each).
111    task_output_buffers: HashMap<TaskId, VecDeque<String>>,
112    /// Whether output auto-scrolls (true until user scrolls up).
113    pub output_auto_scroll: bool,
114    /// Current "Why Not Done?" summary.
115    pub explain_summary: Option<String>,
116    /// Latest transient status (heartbeat/progress) from scheduler.
117    pub transient_status: Option<String>,
118    /// Gate results (gate_name -> passed).
119    pub gate_results: Vec<(String, bool, Option<String>)>,
120    /// Whether copy mode is active (strips borders, disables mouse capture).
121    pub copy_mode: bool,
122    /// Current run ID being displayed.
123    pub run_id: Option<Uuid>,
124    /// Current run objective.
125    pub objective: Option<String>,
126    /// Current run state.
127    pub run_state: Option<crate::yarli_core::fsm::run::RunState>,
128    /// Help overlay visible.
129    pub show_help: bool,
130    /// Continuation payload from run exit.
131    pub continuation_payload: Option<crate::yarli_core::entities::ContinuationPayload>,
132}
133
134impl PanelManager {
135    pub fn new() -> Self {
136        let mut panel_states = HashMap::new();
137        panel_states.insert(PanelId::TaskList, PanelState::Expanded);
138        panel_states.insert(PanelId::Output, PanelState::Expanded);
139        panel_states.insert(PanelId::Gates, PanelState::Expanded);
140        panel_states.insert(PanelId::Audit, PanelState::Collapsed);
141
142        Self {
143            focused: PanelId::TaskList,
144            panel_states,
145            scroll_offsets: HashMap::new(),
146            task_order: Vec::new(),
147            tasks: HashMap::new(),
148            selected_task_idx: 0,
149            output_lines: Vec::new(),
150            task_output_buffers: HashMap::new(),
151            output_auto_scroll: true,
152            explain_summary: None,
153            transient_status: None,
154            gate_results: Vec::new(),
155            copy_mode: false,
156            run_id: None,
157            objective: None,
158            run_state: None,
159            show_help: false,
160            continuation_payload: None,
161        }
162    }
163
164    /// Cycle focus to the next panel (Tab).
165    pub fn focus_next(&mut self) {
166        let focusable = PanelId::FOCUSABLE;
167        if let Some(idx) = focusable.iter().position(|p| *p == self.focused) {
168            // Skip hidden panels.
169            for offset in 1..=focusable.len() {
170                let next = focusable[(idx + offset) % focusable.len()];
171                if self.panel_state(next) != PanelState::Hidden {
172                    self.focused = next;
173                    return;
174                }
175            }
176        }
177    }
178
179    /// Cycle focus to the previous panel (Shift+Tab).
180    pub fn focus_prev(&mut self) {
181        let focusable = PanelId::FOCUSABLE;
182        if let Some(idx) = focusable.iter().position(|p| *p == self.focused) {
183            for offset in 1..=focusable.len() {
184                let prev_idx = (idx + focusable.len() - offset) % focusable.len();
185                let prev = focusable[prev_idx];
186                if self.panel_state(prev) != PanelState::Hidden {
187                    self.focused = prev;
188                    return;
189                }
190            }
191        }
192    }
193
194    /// Jump to panel by shortcut number.
195    pub fn focus_panel(&mut self, panel: PanelId) {
196        if self.panel_state(panel) != PanelState::Hidden {
197            self.focused = panel;
198        }
199    }
200
201    /// Get panel state, defaulting to Expanded.
202    pub fn panel_state(&self, panel: PanelId) -> PanelState {
203        self.panel_states
204            .get(&panel)
205            .copied()
206            .unwrap_or(PanelState::Expanded)
207    }
208
209    /// Collapse the focused panel.
210    pub fn collapse_focused(&mut self) {
211        let state = self.panel_state(self.focused);
212        self.panel_states.insert(self.focused, state.collapse());
213        // If panel becomes hidden, move focus.
214        if self.panel_state(self.focused) == PanelState::Hidden {
215            self.focus_next();
216        }
217    }
218
219    /// Expand the focused panel.
220    pub fn expand_focused(&mut self) {
221        let state = self.panel_state(self.focused);
222        self.panel_states.insert(self.focused, state.expand());
223    }
224
225    /// Restore all panels to expanded.
226    pub fn restore_all(&mut self) {
227        for state in self.panel_states.values_mut() {
228            *state = PanelState::Expanded;
229        }
230    }
231
232    /// Move task list cursor down.
233    pub fn select_next_task(&mut self) {
234        if !self.task_order.is_empty() {
235            self.selected_task_idx = (self.selected_task_idx + 1).min(self.task_order.len() - 1);
236            self.update_output_for_selected_task();
237        }
238    }
239
240    /// Move task list cursor up.
241    pub fn select_prev_task(&mut self) {
242        if self.selected_task_idx > 0 {
243            self.selected_task_idx -= 1;
244            self.update_output_for_selected_task();
245        }
246    }
247
248    /// Get the currently selected task.
249    pub fn selected_task(&self) -> Option<&TaskView> {
250        self.task_order
251            .get(self.selected_task_idx)
252            .and_then(|id| self.tasks.get(id))
253    }
254
255    /// Scroll the focused panel up by N lines.
256    pub fn scroll_up(&mut self, lines: u16) {
257        let offset = self.scroll_offsets.entry(self.focused).or_insert(0);
258        *offset = offset.saturating_sub(lines);
259        if self.focused == PanelId::Output {
260            self.output_auto_scroll = false;
261        }
262    }
263
264    /// Scroll the focused panel down by N lines.
265    pub fn scroll_down(&mut self, lines: u16) {
266        let offset = self.scroll_offsets.entry(self.focused).or_insert(0);
267        *offset = offset.saturating_add(lines);
268        // Re-engage auto-scroll if we scroll to the bottom.
269        if self.focused == PanelId::Output {
270            let max_scroll = self.output_lines.len().saturating_sub(1) as u16;
271            if *offset >= max_scroll {
272                self.output_auto_scroll = true;
273            }
274        }
275    }
276
277    /// Scroll to top of focused panel.
278    pub fn scroll_to_top(&mut self) {
279        self.scroll_offsets.insert(self.focused, 0);
280        if self.focused == PanelId::Output {
281            self.output_auto_scroll = false;
282        }
283    }
284
285    /// Scroll to bottom of focused panel (re-engages auto-scroll for output).
286    pub fn scroll_to_bottom(&mut self) {
287        if self.focused == PanelId::Output {
288            let max = self.output_lines.len().saturating_sub(1) as u16;
289            self.scroll_offsets.insert(PanelId::Output, max);
290            self.output_auto_scroll = true;
291        }
292    }
293
294    /// Update a task's state from a stream event.
295    pub fn update_task(
296        &mut self,
297        task_id: TaskId,
298        name: &str,
299        state: TaskState,
300        elapsed: Option<Duration>,
301        blocked_by: Option<Vec<String>>,
302    ) {
303        let blocked_by = blocked_by.and_then(|depends_on| {
304            if depends_on.is_empty() {
305                None
306            } else {
307                Some(depends_on.join(", "))
308            }
309        });
310        if !self.task_order.contains(&task_id) {
311            self.task_order.push(task_id);
312        }
313
314        let view = self.tasks.entry(task_id).or_insert_with(|| TaskView {
315            task_id,
316            name: name.to_string(),
317            state,
318            elapsed,
319            last_output_line: None,
320            blocked_by: blocked_by.clone(),
321            worker_id: None,
322        });
323        view.state = state;
324        view.elapsed = elapsed;
325        if blocked_by.is_some() {
326            view.blocked_by = blocked_by;
327        }
328    }
329
330    /// Append an output line for a task.
331    pub fn append_output(&mut self, task_id: TaskId, line: String) {
332        const MAX_OUTPUT_LINES: usize = 1000;
333
334        // Update last_output_line on task.
335        if let Some(view) = self.tasks.get_mut(&task_id) {
336            view.last_output_line = Some(line.clone());
337        }
338
339        // Always store in per-task ring buffer.
340        let buf = self.task_output_buffers.entry(task_id).or_default();
341        buf.push_back(line.clone());
342        if buf.len() > MAX_OUTPUT_LINES {
343            buf.pop_front();
344        }
345
346        // Append to visible output if this is the selected task.
347        if self.task_order.get(self.selected_task_idx) == Some(&task_id) {
348            self.output_lines.push(line);
349            if self.output_lines.len() > MAX_OUTPUT_LINES {
350                self.output_lines.remove(0);
351            }
352            if self.output_auto_scroll {
353                let max = self.output_lines.len().saturating_sub(1) as u16;
354                self.scroll_offsets.insert(PanelId::Output, max);
355            }
356        }
357    }
358
359    /// Update output lines when selected task changes.
360    fn update_output_for_selected_task(&mut self) {
361        self.output_lines.clear();
362        if let Some(&task_id) = self.task_order.get(self.selected_task_idx) {
363            if let Some(buf) = self.task_output_buffers.get(&task_id) {
364                self.output_lines.extend(buf.iter().cloned());
365            }
366        }
367        self.output_auto_scroll = true;
368        if self.output_auto_scroll {
369            let max = self.output_lines.len().saturating_sub(1) as u16;
370            self.scroll_offsets.insert(PanelId::Output, max);
371        }
372    }
373
374    /// Get total number of tasks in each state for the status bar.
375    pub fn task_summary(&self) -> TaskSummary {
376        let mut summary = TaskSummary::default();
377        for view in self.tasks.values() {
378            match view.state {
379                TaskState::TaskComplete => summary.complete += 1,
380                TaskState::TaskFailed => summary.failed += 1,
381                TaskState::TaskExecuting | TaskState::TaskWaiting => summary.active += 1,
382                TaskState::TaskBlocked => summary.blocked += 1,
383                _ => summary.pending += 1,
384            }
385        }
386        summary.total = self.tasks.len();
387        summary
388    }
389}
390
391impl Default for PanelManager {
392    fn default() -> Self {
393        Self::new()
394    }
395}
396
397/// Summary of task states for the status bar.
398#[derive(Debug, Default)]
399pub struct TaskSummary {
400    pub total: usize,
401    pub complete: usize,
402    pub failed: usize,
403    pub active: usize,
404    pub blocked: usize,
405    pub pending: usize,
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn panel_state_collapse_cycle() {
414        let s = PanelState::Expanded;
415        assert_eq!(s.collapse(), PanelState::Collapsed);
416        assert_eq!(s.collapse().collapse(), PanelState::Hidden);
417        assert_eq!(s.collapse().collapse().collapse(), PanelState::Expanded);
418    }
419
420    #[test]
421    fn panel_state_expand_cycle() {
422        let s = PanelState::Hidden;
423        assert_eq!(s.expand(), PanelState::Collapsed);
424        assert_eq!(s.expand().expand(), PanelState::Expanded);
425        assert_eq!(s.expand().expand().expand(), PanelState::Expanded); // stays
426    }
427
428    #[test]
429    fn focus_cycling() {
430        let mut mgr = PanelManager::new();
431        assert_eq!(mgr.focused, PanelId::TaskList);
432        mgr.focus_next();
433        assert_eq!(mgr.focused, PanelId::Output);
434        mgr.focus_next();
435        assert_eq!(mgr.focused, PanelId::Gates);
436        mgr.focus_next();
437        assert_eq!(mgr.focused, PanelId::Audit);
438        mgr.focus_next();
439        assert_eq!(mgr.focused, PanelId::TaskList); // wraps
440    }
441
442    #[test]
443    fn focus_prev_cycling() {
444        let mut mgr = PanelManager::new();
445        mgr.focus_prev();
446        assert_eq!(mgr.focused, PanelId::Audit); // wraps backward
447        mgr.focus_prev();
448        assert_eq!(mgr.focused, PanelId::Gates); // Audit → Gates (backward)
449    }
450
451    #[test]
452    fn focus_skips_hidden_panels() {
453        let mut mgr = PanelManager::new();
454        mgr.panel_states.insert(PanelId::Output, PanelState::Hidden);
455        mgr.focus_next();
456        assert_eq!(mgr.focused, PanelId::Gates); // skipped Output
457    }
458
459    #[test]
460    fn focus_panel_by_shortcut() {
461        let mut mgr = PanelManager::new();
462        mgr.focus_panel(PanelId::Gates);
463        assert_eq!(mgr.focused, PanelId::Gates);
464    }
465
466    #[test]
467    fn focus_panel_ignores_hidden() {
468        let mut mgr = PanelManager::new();
469        mgr.panel_states.insert(PanelId::Gates, PanelState::Hidden);
470        mgr.focus_panel(PanelId::Gates);
471        assert_eq!(mgr.focused, PanelId::TaskList); // unchanged
472    }
473
474    #[test]
475    fn collapse_focused_moves_focus_when_hidden() {
476        let mut mgr = PanelManager::new();
477        mgr.collapse_focused(); // Expanded -> Collapsed
478        assert_eq!(mgr.panel_state(PanelId::TaskList), PanelState::Collapsed);
479        assert_eq!(mgr.focused, PanelId::TaskList); // still focused
480
481        mgr.collapse_focused(); // Collapsed -> Hidden
482        assert_eq!(mgr.panel_state(PanelId::TaskList), PanelState::Hidden);
483        assert_eq!(mgr.focused, PanelId::Output); // focus moved
484    }
485
486    #[test]
487    fn restore_all_resets_panels() {
488        let mut mgr = PanelManager::new();
489        mgr.panel_states
490            .insert(PanelId::TaskList, PanelState::Hidden);
491        mgr.panel_states
492            .insert(PanelId::Output, PanelState::Collapsed);
493        mgr.restore_all();
494        assert_eq!(mgr.panel_state(PanelId::TaskList), PanelState::Expanded);
495        assert_eq!(mgr.panel_state(PanelId::Output), PanelState::Expanded);
496    }
497
498    #[test]
499    fn task_selection() {
500        let mut mgr = PanelManager::new();
501        let id1 = Uuid::new_v4();
502        let id2 = Uuid::new_v4();
503
504        mgr.update_task(id1, "task-1", TaskState::TaskExecuting, None, None);
505        mgr.update_task(id2, "task-2", TaskState::TaskReady, None, None);
506
507        assert_eq!(mgr.selected_task_idx, 0);
508        assert_eq!(mgr.selected_task().unwrap().name, "task-1");
509
510        mgr.select_next_task();
511        assert_eq!(mgr.selected_task_idx, 1);
512        assert_eq!(mgr.selected_task().unwrap().name, "task-2");
513
514        mgr.select_next_task(); // at end, stays
515        assert_eq!(mgr.selected_task_idx, 1);
516
517        mgr.select_prev_task();
518        assert_eq!(mgr.selected_task_idx, 0);
519    }
520
521    #[test]
522    fn scroll_up_down() {
523        let mut mgr = PanelManager::new();
524        mgr.scroll_down(5);
525        assert_eq!(mgr.scroll_offsets[&PanelId::TaskList], 5);
526        mgr.scroll_up(3);
527        assert_eq!(mgr.scroll_offsets[&PanelId::TaskList], 2);
528        mgr.scroll_up(10); // doesn't go below 0
529        assert_eq!(mgr.scroll_offsets[&PanelId::TaskList], 0);
530    }
531
532    #[test]
533    fn output_auto_scroll_disengages_on_scroll_up() {
534        let mut mgr = PanelManager::new();
535        mgr.focused = PanelId::Output;
536        assert!(mgr.output_auto_scroll);
537        mgr.scroll_up(1);
538        assert!(!mgr.output_auto_scroll);
539    }
540
541    #[test]
542    fn task_summary_counts() {
543        let mut mgr = PanelManager::new();
544        mgr.update_task(Uuid::new_v4(), "t1", TaskState::TaskComplete, None, None);
545        mgr.update_task(Uuid::new_v4(), "t2", TaskState::TaskFailed, None, None);
546        mgr.update_task(Uuid::new_v4(), "t3", TaskState::TaskExecuting, None, None);
547        mgr.update_task(Uuid::new_v4(), "t4", TaskState::TaskBlocked, None, None);
548        mgr.update_task(Uuid::new_v4(), "t5", TaskState::TaskOpen, None, None);
549
550        let s = mgr.task_summary();
551        assert_eq!(s.total, 5);
552        assert_eq!(s.complete, 1);
553        assert_eq!(s.failed, 1);
554        assert_eq!(s.active, 1);
555        assert_eq!(s.blocked, 1);
556        assert_eq!(s.pending, 1);
557    }
558
559    #[test]
560    fn append_output_for_selected_task() {
561        let mut mgr = PanelManager::new();
562        let id1 = Uuid::new_v4();
563        let id2 = Uuid::new_v4();
564        mgr.update_task(id1, "t1", TaskState::TaskExecuting, None, None);
565        mgr.update_task(id2, "t2", TaskState::TaskExecuting, None, None);
566
567        // Selected task is t1 (idx 0).
568        mgr.append_output(id1, "line1".into());
569        mgr.append_output(id1, "line2".into());
570        assert_eq!(mgr.output_lines.len(), 2);
571
572        // Output for non-selected task is not appended to panel.
573        mgr.append_output(id2, "other".into());
574        assert_eq!(mgr.output_lines.len(), 2);
575    }
576
577    #[test]
578    fn output_persists_across_task_switch() {
579        let mut mgr = PanelManager::new();
580        let id1 = Uuid::new_v4();
581        let id2 = Uuid::new_v4();
582        mgr.update_task(id1, "t1", TaskState::TaskExecuting, None, None);
583        mgr.update_task(id2, "t2", TaskState::TaskExecuting, None, None);
584
585        // Append output to t1 (selected).
586        mgr.append_output(id1, "t1-line1".into());
587        mgr.append_output(id1, "t1-line2".into());
588        assert_eq!(mgr.output_lines.len(), 2);
589
590        // Append output to t2 (not selected — goes to buffer only).
591        mgr.append_output(id2, "t2-lineA".into());
592        assert_eq!(mgr.output_lines.len(), 2); // still t1's output
593
594        // Switch to t2 — should restore t2's buffer.
595        mgr.select_next_task();
596        assert_eq!(mgr.output_lines, vec!["t2-lineA"]);
597
598        // Switch back to t1 — should restore t1's buffer.
599        mgr.select_prev_task();
600        assert_eq!(mgr.output_lines, vec!["t1-line1", "t1-line2"]);
601    }
602
603    #[test]
604    fn panel_id_titles_and_shortcuts() {
605        assert_eq!(PanelId::TaskList.title(), "Tasks");
606        assert_eq!(PanelId::Output.title(), "Output");
607        assert_eq!(PanelId::Audit.title(), "Audit");
608        assert_eq!(PanelId::TaskList.shortcut(), Some('1'));
609        assert_eq!(PanelId::KeyHints.shortcut(), None);
610    }
611}