1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum PanelId {
19 TaskList,
21 Output,
23 Gates,
25 Audit,
27 KeyHints,
29}
30
31impl PanelId {
32 pub const FOCUSABLE: &[PanelId] = &[
34 PanelId::TaskList,
35 PanelId::Output,
36 PanelId::Gates,
37 PanelId::Audit,
38 ];
39
40 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum PanelState {
66 Expanded,
68 Collapsed,
70 Hidden,
72}
73
74impl PanelState {
75 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 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
94pub struct PanelManager {
96 pub focused: PanelId,
98 pub panel_states: HashMap<PanelId, PanelState>,
100 pub scroll_offsets: HashMap<PanelId, u16>,
102 pub task_order: Vec<TaskId>,
104 pub tasks: HashMap<TaskId, TaskView>,
106 pub selected_task_idx: usize,
108 pub output_lines: Vec<String>,
110 task_output_buffers: HashMap<TaskId, VecDeque<String>>,
112 pub output_auto_scroll: bool,
114 pub explain_summary: Option<String>,
116 pub transient_status: Option<String>,
118 pub gate_results: Vec<(String, bool, Option<String>)>,
120 pub copy_mode: bool,
122 pub run_id: Option<Uuid>,
124 pub objective: Option<String>,
126 pub run_state: Option<crate::yarli_core::fsm::run::RunState>,
128 pub show_help: bool,
130 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 pub fn focus_next(&mut self) {
166 let focusable = PanelId::FOCUSABLE;
167 if let Some(idx) = focusable.iter().position(|p| *p == self.focused) {
168 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 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 pub fn focus_panel(&mut self, panel: PanelId) {
196 if self.panel_state(panel) != PanelState::Hidden {
197 self.focused = panel;
198 }
199 }
200
201 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 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 self.panel_state(self.focused) == PanelState::Hidden {
215 self.focus_next();
216 }
217 }
218
219 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 pub fn restore_all(&mut self) {
227 for state in self.panel_states.values_mut() {
228 *state = PanelState::Expanded;
229 }
230 }
231
232 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 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 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 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 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 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 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 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 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 pub fn append_output(&mut self, task_id: TaskId, line: String) {
332 const MAX_OUTPUT_LINES: usize = 1000;
333
334 if let Some(view) = self.tasks.get_mut(&task_id) {
336 view.last_output_line = Some(line.clone());
337 }
338
339 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 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 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 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#[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); }
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); }
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); mgr.focus_prev();
448 assert_eq!(mgr.focused, PanelId::Gates); }
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); }
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); }
473
474 #[test]
475 fn collapse_focused_moves_focus_when_hidden() {
476 let mut mgr = PanelManager::new();
477 mgr.collapse_focused(); assert_eq!(mgr.panel_state(PanelId::TaskList), PanelState::Collapsed);
479 assert_eq!(mgr.focused, PanelId::TaskList); mgr.collapse_focused(); assert_eq!(mgr.panel_state(PanelId::TaskList), PanelState::Hidden);
483 assert_eq!(mgr.focused, PanelId::Output); }
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(); 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); 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 mgr.append_output(id1, "line1".into());
569 mgr.append_output(id1, "line2".into());
570 assert_eq!(mgr.output_lines.len(), 2);
571
572 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 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 mgr.append_output(id2, "t2-lineA".into());
592 assert_eq!(mgr.output_lines.len(), 2); mgr.select_next_task();
596 assert_eq!(mgr.output_lines, vec!["t2-lineA"]);
597
598 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}