Skip to main content

cu_consolemon/
lib.rs

1#[cfg(feature = "debug_pane")]
2mod debug_pane;
3pub mod sysinfo;
4mod tui_nodes;
5
6use crate::tui_nodes::{Connection, NodeGraph, NodeLayout};
7use ansi_to_tui::IntoText;
8use color_eyre::config::HookBuilder;
9use compact_str::{CompactString, ToCompactString};
10use cu29::clock::{CuDuration, RobotClock};
11use cu29::config::CuConfig;
12use cu29::config::Flavor;
13use cu29::curuntime::{CuExecutionUnit, compute_runtime_plan};
14use cu29::cutask::CuMsgMetadata;
15use cu29::monitoring::{
16    ComponentKind, CopperListInfo, CopperListIoStats, CuDurationStatistics, CuMonitor, CuTaskState,
17    Decision, MonitorTopology,
18};
19use cu29::prelude::{CuCompactString, CuTime, pool};
20use cu29::{CuError, CuResult};
21use cu29_log::CuLogLevel;
22#[cfg(debug_assertions)]
23use cu29_log_runtime::{
24    format_message_only, register_live_log_listener, unregister_live_log_listener,
25};
26#[cfg(feature = "debug_pane")]
27use debug_pane::{StyledLine, StyledRun, UIExt};
28use ratatui::backend::CrosstermBackend;
29use ratatui::buffer::Buffer;
30use ratatui::crossterm::event::{
31    DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseButton, MouseEventKind,
32};
33use ratatui::crossterm::terminal::{
34    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
35};
36use ratatui::crossterm::tty::IsTty;
37use ratatui::crossterm::{event, execute};
38use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position, Size};
39use ratatui::prelude::Stylize;
40use ratatui::prelude::{Backend, Rect};
41use ratatui::style::{Color, Modifier, Style};
42use ratatui::text::{Line, Span, Text};
43use ratatui::widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, StatefulWidget, Table};
44use ratatui::{Frame, Terminal};
45use std::backtrace::Backtrace;
46use std::fmt::{Display, Formatter};
47use std::io::{Write, stdin, stdout};
48use std::marker::PhantomData;
49use std::process;
50use std::sync::atomic::{AtomicBool, Ordering};
51use std::sync::{Arc, Mutex, OnceLock};
52use std::thread::JoinHandle;
53use std::time::{Duration, Instant};
54use std::{collections::HashMap, io, thread};
55use tui_widgets::scrollview::{ScrollView, ScrollViewState};
56
57#[cfg(feature = "debug_pane")]
58use arboard::Clipboard;
59
60#[derive(Clone, Copy)]
61struct TabDef {
62    screen: Screen,
63    label: &'static str,
64    key: &'static str,
65}
66
67#[derive(Clone, Copy)]
68struct TabHitbox {
69    screen: Screen,
70    x: u16,
71    y: u16,
72    width: u16,
73    height: u16,
74}
75
76#[derive(Clone, Copy)]
77enum HelpAction {
78    ResetLatency,
79    Quit,
80}
81
82#[derive(Clone, Copy)]
83struct HelpHitbox {
84    action: HelpAction,
85    x: u16,
86    y: u16,
87    width: u16,
88    height: u16,
89}
90
91const TAB_DEFS: &[TabDef] = &[
92    TabDef {
93        screen: Screen::Neofetch,
94        label: "SYS",
95        key: "1",
96    },
97    TabDef {
98        screen: Screen::Dag,
99        label: "DAG",
100        key: "2",
101    },
102    TabDef {
103        screen: Screen::Latency,
104        label: "LAT",
105        key: "3",
106    },
107    TabDef {
108        screen: Screen::CopperList,
109        label: "BW",
110        key: "4",
111    },
112    TabDef {
113        screen: Screen::MemoryPools,
114        label: "MEM",
115        key: "5",
116    },
117    #[cfg(feature = "debug_pane")]
118    TabDef {
119        screen: Screen::DebugOutput,
120        label: "LOG",
121        key: "6",
122    },
123];
124
125const COPPERLIST_RATE_WINDOW: Duration = Duration::from_secs(1);
126
127#[derive(Clone, Copy, PartialEq)]
128enum Screen {
129    Neofetch,
130    Dag,
131    Latency,
132    MemoryPools,
133    CopperList,
134    #[cfg(feature = "debug_pane")]
135    DebugOutput,
136}
137
138#[cfg(feature = "debug_pane")]
139#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
140struct SelectionPoint {
141    row: usize,
142    col: usize,
143}
144
145#[cfg(feature = "debug_pane")]
146#[derive(Clone, Copy, Debug, Default)]
147struct DebugSelection {
148    anchor: Option<SelectionPoint>,
149    cursor: Option<SelectionPoint>,
150}
151
152#[cfg(feature = "debug_pane")]
153impl DebugSelection {
154    fn clear(&mut self) {
155        self.anchor = None;
156        self.cursor = None;
157    }
158
159    fn start(&mut self, point: SelectionPoint) {
160        self.anchor = Some(point);
161        self.cursor = Some(point);
162    }
163
164    fn update(&mut self, point: SelectionPoint) {
165        if self.anchor.is_some() {
166            self.cursor = Some(point);
167        }
168    }
169
170    fn range(&self) -> Option<(SelectionPoint, SelectionPoint)> {
171        let anchor = self.anchor?;
172        let cursor = self.cursor?;
173        if (anchor.row, anchor.col) <= (cursor.row, cursor.col) {
174            Some((anchor, cursor))
175        } else {
176            Some((cursor, anchor))
177        }
178    }
179}
180
181fn segment_width(key: &str, label: &str) -> u16 {
182    (6 + key.chars().count() + label.chars().count()) as u16
183}
184
185fn help_action(key: &str) -> Option<HelpAction> {
186    match key {
187        "r" => Some(HelpAction::ResetLatency),
188        "q" => Some(HelpAction::Quit),
189        _ => None,
190    }
191}
192
193fn mouse_inside(mouse: &event::MouseEvent, x: u16, y: u16, width: u16, height: u16) -> bool {
194    mouse.column >= x && mouse.column < x + width && mouse.row >= y && mouse.row < y + height
195}
196
197#[cfg(feature = "debug_pane")]
198fn char_to_byte_index(text: &str, char_idx: usize) -> usize {
199    text.char_indices()
200        .nth(char_idx)
201        .map(|(idx, _)| idx)
202        .unwrap_or(text.len())
203}
204
205#[cfg(feature = "debug_pane")]
206fn slice_char_range(text: &str, start: usize, end: usize) -> (&str, &str, &str) {
207    let start_idx = char_to_byte_index(text, start);
208    let end_idx = char_to_byte_index(text, end);
209    let start_idx = start_idx.min(text.len());
210    let end_idx = end_idx.min(text.len());
211    let (start_idx, end_idx) = if start_idx <= end_idx {
212        (start_idx, end_idx)
213    } else {
214        (end_idx, start_idx)
215    };
216    (
217        &text[..start_idx],
218        &text[start_idx..end_idx],
219        &text[end_idx..],
220    )
221}
222
223#[cfg(feature = "debug_pane")]
224fn line_selection_bounds(
225    line_index: usize,
226    line_len: usize,
227    start: SelectionPoint,
228    end: SelectionPoint,
229) -> Option<(usize, usize)> {
230    if line_index < start.row || line_index > end.row {
231        return None;
232    }
233
234    let start_col = if line_index == start.row {
235        start.col
236    } else {
237        0
238    };
239    let mut end_col = if line_index == end.row {
240        end.col
241    } else {
242        line_len
243    };
244    if line_index == end.row {
245        end_col = end_col.saturating_add(1).min(line_len);
246    }
247    let start_col = start_col.min(line_len);
248    let end_col = end_col.min(line_len);
249    if start_col >= end_col {
250        return None;
251    }
252
253    Some((start_col, end_col))
254}
255
256#[cfg(feature = "debug_pane")]
257fn slice_chars_owned(text: &str, start: usize, end: usize) -> String {
258    let start_idx = char_to_byte_index(text, start);
259    let end_idx = char_to_byte_index(text, end);
260    text[start_idx.min(text.len())..end_idx.min(text.len())].to_string()
261}
262
263#[cfg(feature = "debug_pane")]
264fn spans_from_runs(line: &StyledLine) -> Vec<Span<'static>> {
265    if line.runs.is_empty() {
266        return vec![Span::raw(line.text.clone())];
267    }
268
269    let mut spans = Vec::new();
270    let mut cursor = 0usize;
271    let total_chars = line.text.chars().count();
272    let mut runs = line.runs.clone();
273    runs.sort_by_key(|r| r.start);
274
275    for run in runs {
276        let start = run.start.min(total_chars);
277        let end = run.end.min(total_chars);
278        if start > cursor {
279            let before = slice_chars_owned(&line.text, cursor, start);
280            if !before.is_empty() {
281                spans.push(Span::raw(before));
282            }
283        }
284        if end > start {
285            let segment = slice_chars_owned(&line.text, start, end);
286            spans.push(Span::styled(segment, run.style));
287        }
288        cursor = cursor.max(end);
289    }
290
291    if cursor < total_chars {
292        let tail = slice_chars_owned(&line.text, cursor, total_chars);
293        if !tail.is_empty() {
294            spans.push(Span::raw(tail));
295        }
296    }
297
298    spans
299}
300
301#[cfg(feature = "debug_pane")]
302fn color_for_level(level: CuLogLevel) -> Color {
303    match level {
304        CuLogLevel::Debug => Color::Green,
305        CuLogLevel::Info => Color::Gray,
306        CuLogLevel::Warning => Color::Yellow,
307        CuLogLevel::Error => Color::Red,
308        CuLogLevel::Critical => Color::Red,
309    }
310}
311
312#[cfg(feature = "debug_pane")]
313fn format_ts(time: CuTime) -> String {
314    let nanos = time.as_nanos();
315    let total_ms = nanos / 1_000_000;
316    let millis = total_ms % 1000;
317    let total_s = total_ms / 1000;
318    let secs = total_s % 60;
319    let mins = (total_s / 60) % 60;
320    let hours = (total_s / 3600) % 24;
321    format!("{hours:02}:{mins:02}:{secs:02}.{millis:03}")
322}
323
324#[cfg(feature = "debug_pane")]
325fn build_message_with_runs(
326    format_str: &str,
327    params: &[String],
328    named_params: &HashMap<String, String>,
329) -> (String, Vec<(usize, usize)>) {
330    let mut out = String::new();
331    let mut param_spans = Vec::new();
332    let mut anon_iter = params.iter();
333    let mut iter = format_str.char_indices().peekable();
334    while let Some((idx, ch)) = iter.next() {
335        if ch == '{' {
336            let start_idx = idx + ch.len_utf8();
337            if let Some(end) = format_str[start_idx..].find('}') {
338                let end_idx = start_idx + end;
339                let placeholder = &format_str[start_idx..end_idx];
340                let replacement_opt = if placeholder.is_empty() {
341                    anon_iter.next()
342                } else {
343                    named_params.get(placeholder)
344                };
345                if let Some(repl) = replacement_opt {
346                    let span_start = out.chars().count();
347                    out.push_str(repl);
348                    let span_end = out.chars().count();
349                    param_spans.push((span_start, span_end));
350                    let skip_to = end_idx + '}'.len_utf8();
351                    while let Some((next_idx, _)) = iter.peek().copied() {
352                        if next_idx < skip_to {
353                            iter.next();
354                        } else {
355                            break;
356                        }
357                    }
358                    continue;
359                }
360            }
361        }
362        out.push(ch);
363    }
364    (out, param_spans)
365}
366
367#[cfg(feature = "debug_pane")]
368fn styled_line_from_structured(
369    time: CuTime,
370    level: CuLogLevel,
371    format_str: &str,
372    params: &[String],
373    named_params: &HashMap<String, String>,
374) -> StyledLine {
375    let ts = format_ts(time);
376    let level_txt = format!("[{:?}]", level);
377
378    let (msg_text, param_spans) = build_message_with_runs(format_str, params, named_params);
379    let mut msg_runs = Vec::new();
380    let mut cursor = 0usize;
381    let param_spans_sorted = {
382        let mut v = param_spans;
383        v.sort_by_key(|p| p.0);
384        v
385    };
386    for (start, end) in param_spans_sorted {
387        if start > cursor {
388            msg_runs.push(StyledRun {
389                start: cursor,
390                end: start,
391                style: Style::default().fg(Color::Gray),
392            });
393        }
394        msg_runs.push(StyledRun {
395            start,
396            end,
397            style: Style::default().fg(Color::Magenta),
398        });
399        cursor = end;
400    }
401    if cursor < msg_text.chars().count() {
402        msg_runs.push(StyledRun {
403            start: cursor,
404            end: msg_text.chars().count(),
405            style: Style::default().fg(Color::Gray),
406        });
407    }
408
409    let prefix = format!("{ts} {level_txt} ");
410    let prefix_len = prefix.chars().count();
411    let line_text = format!("{prefix}{msg_text}");
412
413    let mut runs = Vec::new();
414    let ts_len = ts.chars().count();
415    let level_start = ts_len + 1;
416    let level_end = level_start + level_txt.chars().count();
417
418    runs.push(StyledRun {
419        start: 0,
420        end: ts_len,
421        style: Style::default().fg(Color::Blue),
422    });
423    runs.push(StyledRun {
424        start: level_start,
425        end: level_end,
426        style: Style::default().fg(color_for_level(level)).bold(),
427    });
428    for run in msg_runs {
429        runs.push(StyledRun {
430            start: prefix_len + run.start,
431            end: prefix_len + run.end,
432            style: run.style,
433        });
434    }
435
436    StyledLine {
437        text: line_text,
438        runs,
439    }
440}
441
442struct TaskStats {
443    stats: Vec<CuDurationStatistics>,
444    end2end: CuDurationStatistics,
445}
446
447impl TaskStats {
448    fn new(num_tasks: usize, max_duration: CuDuration) -> Self {
449        let stats = vec![CuDurationStatistics::new(max_duration); num_tasks];
450        TaskStats {
451            stats,
452            end2end: CuDurationStatistics::new(max_duration),
453        }
454    }
455
456    fn update(&mut self, msgs: &[&CuMsgMetadata]) {
457        for (i, &msg) in msgs.iter().enumerate() {
458            let (before, after) = (
459                msg.process_time.start.unwrap(),
460                msg.process_time.end.unwrap(),
461            );
462            self.stats[i].record(after - before);
463        }
464        self.end2end.record(compute_end_to_end_latency(msgs));
465    }
466
467    fn reset(&mut self) {
468        for s in &mut self.stats {
469            s.reset();
470        }
471        self.end2end.reset();
472    }
473}
474struct PoolStats {
475    id: CompactString,
476    space_left: usize,
477    total_size: usize,
478    buffer_size: usize,
479    handles_in_use: usize,
480    handles_per_second: usize,
481    last_update: Instant,
482}
483
484impl PoolStats {
485    fn new(
486        id: impl ToCompactString,
487        space_left: usize,
488        total_size: usize,
489        buffer_size: usize,
490    ) -> Self {
491        Self {
492            id: id.to_compact_string(),
493            space_left,
494            total_size,
495            buffer_size,
496            handles_in_use: total_size - space_left,
497            handles_per_second: 0,
498            last_update: Instant::now(),
499        }
500    }
501
502    fn update(&mut self, space_left: usize, total_size: usize) {
503        let now = Instant::now();
504        let handles_in_use = total_size - space_left;
505        let elapsed = now.duration_since(self.last_update).as_secs_f32();
506
507        if elapsed >= 1.0 {
508            self.handles_per_second =
509                ((handles_in_use.abs_diff(self.handles_in_use)) as f32 / elapsed) as usize;
510            self.last_update = now;
511        }
512
513        self.handles_in_use = handles_in_use;
514        self.space_left = space_left;
515        self.total_size = total_size;
516    }
517}
518
519struct CopperListStats {
520    size_bytes: usize,
521    raw_culist_bytes: u64,
522    handle_bytes: u64,
523    encoded_bytes: u64,
524    keyframe_bytes: u64,
525    structured_total_bytes: u64,
526    structured_bytes_per_cl: u64,
527    total_copperlists: u64,
528    window_copperlists: u64,
529    last_rate_at: Instant,
530    rate_hz: f64,
531}
532
533impl CopperListStats {
534    fn new() -> Self {
535        Self {
536            size_bytes: 0,
537            raw_culist_bytes: 0,
538            handle_bytes: 0,
539            encoded_bytes: 0,
540            keyframe_bytes: 0,
541            structured_total_bytes: 0,
542            structured_bytes_per_cl: 0,
543            total_copperlists: 0,
544            window_copperlists: 0,
545            last_rate_at: Instant::now(),
546            rate_hz: 0.0,
547        }
548    }
549
550    fn set_info(&mut self, info: CopperListInfo) {
551        self.size_bytes = info.size_bytes;
552    }
553
554    fn update_io(&mut self, stats: cu29::monitoring::CopperListIoStats) {
555        self.raw_culist_bytes = stats.raw_culist_bytes;
556        self.handle_bytes = stats.handle_bytes;
557        self.encoded_bytes = stats.encoded_culist_bytes;
558        self.keyframe_bytes = stats.keyframe_bytes;
559        let total = stats.structured_log_bytes_total;
560        self.structured_bytes_per_cl = total.saturating_sub(self.structured_total_bytes);
561        self.structured_total_bytes = total;
562    }
563
564    fn update_rate(&mut self) {
565        self.total_copperlists = self.total_copperlists.saturating_add(1);
566        self.window_copperlists = self.window_copperlists.saturating_add(1);
567
568        let now = Instant::now();
569        let elapsed = now.duration_since(self.last_rate_at);
570        if elapsed >= COPPERLIST_RATE_WINDOW {
571            let elapsed_secs = elapsed.as_secs_f64();
572            self.rate_hz = if elapsed_secs > 0.0 {
573                self.window_copperlists as f64 / elapsed_secs
574            } else {
575                0.0
576            };
577            self.window_copperlists = 0;
578            self.last_rate_at = now;
579        }
580    }
581}
582
583fn compute_end_to_end_latency(msgs: &[&CuMsgMetadata]) -> CuDuration {
584    let start = msgs.first().map(|m| m.process_time.start);
585    let end = msgs.last().map(|m| m.process_time.end);
586
587    if let (Some(s), Some(e)) = (start, end)
588        && let (Some(s), Some(e)) = (Option::<CuTime>::from(s), Option::<CuTime>::from(e))
589        && e >= s
590    {
591        e - s
592    } else {
593        CuDuration::MIN
594    }
595}
596
597fn format_bytes(bytes: f64) -> String {
598    const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
599    let mut value = bytes;
600    let mut unit_idx = 0;
601    while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
602        value /= 1024.0;
603        unit_idx += 1;
604    }
605    if unit_idx == 0 {
606        format!("{:.0} {}", value, UNITS[unit_idx])
607    } else {
608        format!("{:.2} {}", value, UNITS[unit_idx])
609    }
610}
611
612fn format_bytes_or(bytes: u64, fallback: &str) -> String {
613    if bytes > 0 {
614        format_bytes(bytes as f64)
615    } else {
616        fallback.to_string()
617    }
618}
619
620fn format_rate_bytes_or_na(bytes: u64, rate_hz: f64) -> String {
621    if bytes > 0 {
622        format!("{}/s", format_bytes((bytes as f64) * rate_hz))
623    } else {
624        "n/a".to_string()
625    }
626}
627
628// This is kind of terrible.
629#[derive(Copy, Clone)]
630enum NodeType {
631    Unknown,
632    Source,
633    Sink,
634    Task,
635    Bridge,
636}
637
638impl Display for NodeType {
639    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
640        match self {
641            Self::Unknown => write!(f, "?"),
642            Self::Source => write!(f, "◈"),
643            Self::Task => write!(f, "⚙"),
644            Self::Sink => write!(f, "⭳"),
645            Self::Bridge => write!(f, "⇆"),
646        }
647    }
648}
649
650impl NodeType {
651    fn add_incoming(self) -> NodeType {
652        match self {
653            Self::Unknown => Self::Sink,
654            Self::Source => Self::Task,
655            Self::Sink => Self::Sink,
656            Self::Task => Self::Task,
657            Self::Bridge => Self::Bridge,
658        }
659    }
660
661    fn add_outgoing(self) -> NodeType {
662        match self {
663            Self::Unknown => Self::Source,
664            Self::Source => Self::Source,
665            Self::Sink => Self::Task,
666            Self::Task => Self::Task,
667            Self::Bridge => Self::Bridge,
668        }
669    }
670
671    fn color(self) -> Color {
672        match self {
673            Self::Unknown => Color::Gray,
674            Self::Source => Color::Rgb(255, 191, 0),
675            Self::Sink => Color::Rgb(255, 102, 204),
676            Self::Task => Color::White,
677            Self::Bridge => Color::Rgb(204, 153, 255),
678        }
679    }
680}
681
682#[derive(Default, Clone)]
683struct TaskStatus {
684    is_error: bool,
685    status_txt: CompactString,
686    error: CompactString,
687}
688
689#[derive(Clone)]
690struct DisplayNode {
691    id: String,
692    type_label: String,
693    node_type: NodeType,
694    inputs: Vec<String>,
695    outputs: Vec<String>,
696}
697
698#[derive(Clone, Copy, PartialEq, Eq)]
699struct GraphCacheKey {
700    area: Size,
701    node_count: usize,
702    connection_count: usize,
703}
704
705struct GraphCache {
706    graph: Option<NodeGraph<'static>>,
707    content_size: Size,
708    key: Option<GraphCacheKey>,
709    dirty: bool,
710}
711
712impl GraphCache {
713    fn new() -> Self {
714        Self {
715            graph: None,
716            content_size: Size::ZERO,
717            key: None,
718            dirty: true,
719        }
720    }
721}
722
723impl GraphCache {
724    fn needs_rebuild(&self, key: GraphCacheKey) -> bool {
725        self.dirty || self.graph.is_none() || self.key != Some(key)
726    }
727}
728
729struct NodesScrollableWidgetState {
730    display_nodes: Vec<DisplayNode>,
731    connections: Vec<Connection>,
732    statuses: Arc<Mutex<Vec<TaskStatus>>>,
733    status_index_map: Vec<Option<usize>>,
734    task_count: usize,
735    nodes_scrollable_state: ScrollViewState,
736    graph_cache: GraphCache,
737}
738
739impl NodesScrollableWidgetState {
740    fn new(
741        config: &CuConfig,
742        errors: Arc<Mutex<Vec<TaskStatus>>>,
743        mission: Option<&str>,
744        task_ids: &'static [&'static str],
745        topology: Option<MonitorTopology>,
746    ) -> Self {
747        let topology = topology
748            .or_else(|| cu29::monitoring::build_monitor_topology(config, mission).ok())
749            .unwrap_or_default();
750
751        let mut display_nodes: Vec<DisplayNode> = Vec::new();
752        let mut status_index_map = Vec::new();
753        let mut node_lookup = HashMap::new();
754
755        for node in topology.nodes.iter() {
756            let node_type = match node.kind {
757                ComponentKind::Bridge => NodeType::Bridge,
758                ComponentKind::Task => {
759                    let mut role = NodeType::Unknown;
760                    if !node.inputs.is_empty() {
761                        role = role.add_incoming();
762                    }
763                    if !node.outputs.is_empty() {
764                        role = role.add_outgoing();
765                    }
766                    role
767                }
768            };
769
770            display_nodes.push(DisplayNode {
771                id: node.id.clone(),
772                type_label: node
773                    .type_name
774                    .clone()
775                    .unwrap_or_else(|| "unknown".to_string()),
776                node_type,
777                inputs: node.inputs.clone(),
778                outputs: node.outputs.clone(),
779            });
780            let idx = display_nodes.len() - 1;
781            node_lookup.insert(node.id.clone(), idx);
782
783            let status_idx = match node.kind {
784                ComponentKind::Task => task_ids.iter().position(|id| *id == node.id.as_str()),
785                ComponentKind::Bridge => None,
786            };
787            status_index_map.push(status_idx);
788        }
789
790        let mut connections: Vec<Connection> = Vec::with_capacity(topology.connections.len());
791        for cnx in topology.connections.iter() {
792            let Some(&src_idx) = node_lookup.get(&cnx.src) else {
793                continue;
794            };
795            let Some(&dst_idx) = node_lookup.get(&cnx.dst) else {
796                continue;
797            };
798            let src_node = &display_nodes[src_idx];
799            let dst_node = &display_nodes[dst_idx];
800            let src_port = cnx
801                .src_port
802                .as_ref()
803                .and_then(|p| src_node.outputs.iter().position(|name| name == p))
804                .unwrap_or(0);
805            let dst_port = cnx
806                .dst_port
807                .as_ref()
808                .and_then(|p| dst_node.inputs.iter().position(|name| name == p))
809                .unwrap_or(0);
810
811            connections.push(Connection::new(
812                src_idx,
813                src_port + NODE_PORT_ROW_OFFSET,
814                dst_idx,
815                dst_port + NODE_PORT_ROW_OFFSET,
816            ));
817        }
818
819        // tui-nodes drops all nodes when every node has an outgoing edge (no roots).
820        // If that happens, drop the outgoing edges for the first node so at least one root exists.
821        if !display_nodes.is_empty() {
822            let mut from_set = std::collections::HashSet::new();
823            for conn in &connections {
824                from_set.insert(conn.from_node);
825            }
826            if from_set.len() == display_nodes.len() {
827                let root_idx = 0;
828                connections.retain(|c| c.from_node != root_idx);
829            }
830        }
831
832        NodesScrollableWidgetState {
833            display_nodes,
834            connections,
835            statuses: errors,
836            status_index_map,
837            task_count: task_ids.len(),
838            nodes_scrollable_state: ScrollViewState::default(),
839            graph_cache: GraphCache::new(),
840        }
841    }
842
843    fn mark_graph_dirty(&mut self) {
844        self.graph_cache.dirty = true;
845    }
846
847    fn ensure_graph_cache(&mut self, area: Rect) -> Size {
848        let key = self.graph_cache_key(area);
849        if self.graph_cache.needs_rebuild(key) {
850            self.rebuild_graph_cache(area, key);
851        }
852        self.graph_cache.content_size
853    }
854
855    fn graph(&self) -> &NodeGraph<'static> {
856        self.graph_cache
857            .graph
858            .as_ref()
859            .expect("graph cache must be initialized before render")
860    }
861
862    fn graph_cache_key(&self, area: Rect) -> GraphCacheKey {
863        GraphCacheKey {
864            area: area.into(),
865            node_count: self.display_nodes.len(),
866            connection_count: self.connections.len(),
867        }
868    }
869
870    fn build_graph(&self, content_size: Size) -> NodeGraph<'static> {
871        let mut graph = NodeGraph::new(
872            self.build_node_layouts(),
873            self.connections.clone(),
874            content_size.width as usize,
875            content_size.height as usize,
876        );
877        graph.calculate();
878        graph
879    }
880
881    fn rebuild_graph_cache(&mut self, area: Rect, key: GraphCacheKey) {
882        let content_size = if self.display_nodes.is_empty() {
883            Size::new(area.width.max(NODE_WIDTH), area.height.max(NODE_HEIGHT))
884        } else {
885            let node_count = self.display_nodes.len();
886            let content_width = (node_count as u16)
887                .saturating_mul(NODE_WIDTH + 20)
888                .max(NODE_WIDTH);
889            let max_ports = self
890                .display_nodes
891                .iter()
892                .map(|node| node.inputs.len().max(node.outputs.len()))
893                .max()
894                .unwrap_or_default();
895            let content_height =
896                (((max_ports + NODE_PORT_ROW_OFFSET) as u16) * 12).max(NODE_HEIGHT * 6);
897
898            let initial_size = Size::new(content_width, content_height);
899            let graph = self.build_graph(initial_size);
900            let bounds = graph.content_bounds();
901            let desired_width = bounds
902                .width
903                .saturating_add(GRAPH_WIDTH_PADDING)
904                .max(NODE_WIDTH);
905            let desired_height = bounds
906                .height
907                .saturating_add(GRAPH_HEIGHT_PADDING)
908                .max(NODE_HEIGHT);
909            Size::new(desired_width, desired_height)
910        };
911
912        self.graph_cache.graph = Some(self.build_graph(content_size));
913        self.graph_cache.content_size = content_size;
914        self.graph_cache.key = Some(key);
915        self.graph_cache.dirty = false;
916
917        self.clamp_scroll_offset(area, content_size);
918    }
919
920    fn build_node_layouts(&self) -> Vec<NodeLayout<'static>> {
921        self.display_nodes
922            .iter()
923            .map(|node| {
924                let ports = node.inputs.len().max(node.outputs.len());
925                let content_rows = ports + NODE_PORT_ROW_OFFSET;
926                let height = (content_rows as u16).saturating_add(2).max(NODE_HEIGHT);
927                let title_line = Line::from(vec![
928                    Span::styled(
929                        format!(" {}", node.node_type),
930                        Style::default().fg(node.node_type.color()),
931                    ),
932                    Span::styled(format!(" {} ", node.id), Style::default().fg(Color::White)),
933                ]);
934                NodeLayout::new((NODE_WIDTH, height)).with_title_line(title_line)
935            })
936            .collect()
937    }
938
939    fn clamp_scroll_offset(&mut self, area: Rect, content_size: Size) {
940        let max_x = content_size
941            .width
942            .saturating_sub(area.width.saturating_sub(1));
943        let max_y = content_size
944            .height
945            .saturating_sub(area.height.saturating_sub(1));
946        let offset = self.nodes_scrollable_state.offset();
947        let clamped = Position::new(offset.x.min(max_x), offset.y.min(max_y));
948        self.nodes_scrollable_state.set_offset(clamped);
949    }
950}
951
952struct NodesScrollableWidget<'a> {
953    _marker: PhantomData<&'a ()>,
954}
955
956const NODE_WIDTH: u16 = 29;
957const NODE_WIDTH_CONTENT: u16 = NODE_WIDTH - 2;
958
959const NODE_HEIGHT: u16 = 5;
960const NODE_META_LINES: usize = 2;
961const NODE_PORT_ROW_OFFSET: usize = NODE_META_LINES;
962const MAX_CULIST_MAP: usize = 512;
963
964fn clip_tail(value: &str, max_chars: usize) -> String {
965    if max_chars == 0 {
966        return String::new();
967    }
968    let char_count = value.chars().count();
969    if char_count <= max_chars {
970        return value.to_string();
971    }
972    let skip = char_count.saturating_sub(max_chars);
973    let start = value
974        .char_indices()
975        .nth(skip)
976        .map(|(idx, _)| idx)
977        .unwrap_or(value.len());
978    value[start..].to_string()
979}
980
981#[allow(dead_code)]
982const NODE_HEIGHT_CONTENT: u16 = NODE_HEIGHT - 2;
983const GRAPH_WIDTH_PADDING: u16 = NODE_WIDTH * 2;
984const GRAPH_HEIGHT_PADDING: u16 = NODE_HEIGHT * 4;
985
986impl StatefulWidget for NodesScrollableWidget<'_> {
987    type State = NodesScrollableWidgetState;
988
989    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
990        let content_size = state.ensure_graph_cache(area);
991        let mut scroll_view = ScrollView::new(content_size);
992
993        {
994            let graph = state.graph();
995            let zones = graph.split(scroll_view.area());
996
997            let mut statuses = state.statuses.lock().unwrap();
998            if statuses.len() <= state.task_count {
999                statuses.resize(state.task_count + 1, TaskStatus::default());
1000            }
1001            for (idx, ea_zone) in zones.into_iter().enumerate() {
1002                let fallback_idx = state.task_count;
1003                let status_idx = state
1004                    .status_index_map
1005                    .get(idx)
1006                    .and_then(|opt| *opt)
1007                    .unwrap_or(fallback_idx);
1008                let safe_index = status_idx.min(statuses.len().saturating_sub(1));
1009                let status = &mut statuses[safe_index];
1010                let node = &state.display_nodes[idx];
1011                let status_line = if status.is_error {
1012                    format!("❌ {}", status.error)
1013                } else {
1014                    format!("✓ {}", status.status_txt)
1015                };
1016
1017                let label_width = (NODE_WIDTH_CONTENT as usize).saturating_sub(2);
1018                let type_label = clip_tail(&node.type_label, label_width);
1019                let status_text = clip_tail(&status_line, label_width);
1020                let base_style = if status.is_error {
1021                    Style::default().fg(Color::Red)
1022                } else {
1023                    Style::default().fg(Color::Green)
1024                };
1025                let mut lines: Vec<Line> = Vec::new();
1026                lines.push(Line::styled(format!(" {}", type_label), base_style));
1027                lines.push(Line::styled(format!(" {}", status_text), base_style));
1028
1029                let max_ports = node.inputs.len().max(node.outputs.len());
1030                if max_ports > 0 {
1031                    let left_width = (NODE_WIDTH_CONTENT as usize - 2) / 2;
1032                    let right_width = NODE_WIDTH_CONTENT as usize - 2 - left_width;
1033                    let input_style = Style::default().fg(Color::Yellow);
1034                    let output_style = Style::default().fg(Color::Cyan);
1035                    let dotted_style = Style::default().fg(Color::DarkGray);
1036                    for port_idx in 0..max_ports {
1037                        let input = node
1038                            .inputs
1039                            .get(port_idx)
1040                            .map(|label| clip_tail(label, left_width))
1041                            .unwrap_or_default();
1042                        let output = node
1043                            .outputs
1044                            .get(port_idx)
1045                            .map(|label| clip_tail(label, right_width))
1046                            .unwrap_or_default();
1047                        let mut port_line = Line::default();
1048                        port_line.spans.push(Span::styled(
1049                            format!(" {:<left_width$}", input, left_width = left_width),
1050                            input_style,
1051                        ));
1052                        port_line.spans.push(Span::styled("┆", dotted_style));
1053                        port_line.spans.push(Span::styled(
1054                            format!("{:>right_width$}", output, right_width = right_width),
1055                            output_style,
1056                        ));
1057                        lines.push(port_line);
1058                    }
1059                }
1060
1061                let paragraph = Paragraph::new(Text::from(lines));
1062                status.is_error = false; // reset if it was displayed
1063                scroll_view.render_widget(paragraph, ea_zone);
1064            }
1065
1066            let content_area = Rect::new(0, 0, content_size.width, content_size.height);
1067            scroll_view.render_widget(graph, content_area);
1068        }
1069
1070        scroll_view.render(area, buf, &mut state.nodes_scrollable_state);
1071    }
1072}
1073
1074/// A TUI based realtime console for Copper.
1075pub struct CuConsoleMon {
1076    config: CuConfig,
1077    taskids: &'static [&'static str],
1078    task_stats: Arc<Mutex<TaskStats>>,
1079    task_statuses: Arc<Mutex<Vec<TaskStatus>>>,
1080    culist_to_task: [usize; MAX_CULIST_MAP],
1081    ui_handle: Option<JoinHandle<()>>,
1082    pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1083    copperlist_stats: Arc<Mutex<CopperListStats>>,
1084    quitting: Arc<AtomicBool>,
1085    topology: Option<MonitorTopology>,
1086}
1087
1088impl Drop for CuConsoleMon {
1089    fn drop(&mut self) {
1090        self.quitting.store(true, Ordering::SeqCst);
1091        let _ = restore_terminal();
1092        if let Some(handle) = self.ui_handle.take() {
1093            let _ = handle.join();
1094        }
1095    }
1096}
1097
1098struct UI {
1099    task_ids: &'static [&'static str],
1100    active_screen: Screen,
1101    sysinfo: String,
1102    task_stats: Arc<Mutex<TaskStats>>,
1103    quitting: Arc<AtomicBool>,
1104    tab_hitboxes: Vec<TabHitbox>,
1105    help_hitboxes: Vec<HelpHitbox>,
1106    nodes_scrollable_widget_state: NodesScrollableWidgetState,
1107    #[cfg(feature = "debug_pane")]
1108    error_redirect: Option<gag::BufferRedirect>,
1109    #[cfg(feature = "debug_pane")]
1110    debug_output: Option<debug_pane::DebugLog>,
1111    #[cfg(feature = "debug_pane")]
1112    debug_output_area: Option<Rect>,
1113    #[cfg(feature = "debug_pane")]
1114    debug_output_visible_offset: usize,
1115    #[cfg(feature = "debug_pane")]
1116    debug_output_lines: Vec<debug_pane::StyledLine>,
1117    #[cfg(feature = "debug_pane")]
1118    debug_selection: DebugSelection,
1119    #[cfg(feature = "debug_pane")]
1120    clipboard: Option<Clipboard>,
1121    pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1122    copperlist_stats: Arc<Mutex<CopperListStats>>,
1123}
1124
1125impl UI {
1126    #[cfg(feature = "debug_pane")]
1127    #[allow(clippy::too_many_arguments)]
1128    fn new(
1129        config: CuConfig,
1130        mission: Option<&str>,
1131        task_ids: &'static [&'static str],
1132        task_stats: Arc<Mutex<TaskStats>>,
1133        task_statuses: Arc<Mutex<Vec<TaskStatus>>>,
1134        quitting: Arc<AtomicBool>,
1135        error_redirect: Option<gag::BufferRedirect>,
1136        debug_output: Option<debug_pane::DebugLog>,
1137        pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1138        copperlist_stats: Arc<Mutex<CopperListStats>>,
1139        topology: Option<MonitorTopology>,
1140    ) -> UI {
1141        init_error_hooks();
1142        let nodes_scrollable_widget_state = NodesScrollableWidgetState::new(
1143            &config,
1144            task_statuses.clone(),
1145            mission,
1146            task_ids,
1147            topology.clone(),
1148        );
1149
1150        Self {
1151            task_ids,
1152            active_screen: Screen::Neofetch,
1153            sysinfo: sysinfo::pfetch_info(),
1154            task_stats,
1155            quitting,
1156            tab_hitboxes: Vec::new(),
1157            help_hitboxes: Vec::new(),
1158            nodes_scrollable_widget_state,
1159            error_redirect,
1160            debug_output,
1161            debug_output_area: None,
1162            debug_output_visible_offset: 0,
1163            debug_output_lines: Vec::new(),
1164            debug_selection: DebugSelection::default(),
1165            clipboard: None,
1166            pool_stats,
1167            copperlist_stats,
1168        }
1169    }
1170
1171    #[cfg(not(feature = "debug_pane"))]
1172    fn new(
1173        config: CuConfig,
1174        task_ids: &'static [&'static str],
1175        task_stats: Arc<Mutex<TaskStats>>,
1176        task_statuses: Arc<Mutex<Vec<TaskStatus>>>,
1177        quitting: Arc<AtomicBool>,
1178        pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1179        copperlist_stats: Arc<Mutex<CopperListStats>>,
1180        topology: Option<MonitorTopology>,
1181    ) -> UI {
1182        init_error_hooks();
1183        let nodes_scrollable_widget_state = NodesScrollableWidgetState::new(
1184            &config,
1185            task_statuses.clone(),
1186            None,
1187            task_ids,
1188            topology.clone(),
1189        );
1190
1191        Self {
1192            task_ids,
1193            active_screen: Screen::Neofetch,
1194            sysinfo: sysinfo::pfetch_info(),
1195            task_stats,
1196            quitting,
1197            tab_hitboxes: Vec::new(),
1198            help_hitboxes: Vec::new(),
1199            nodes_scrollable_widget_state,
1200            pool_stats,
1201            copperlist_stats,
1202        }
1203    }
1204
1205    fn draw_latency_table(&self, f: &mut Frame, area: Rect) {
1206        let header_cells = [
1207            "🛠 Task",
1208            "⬇ Min",
1209            "⬆ Max",
1210            "∅ Mean",
1211            "σ Stddev",
1212            "⧖∅ Jitter",
1213            "⧗⬆ Jitter",
1214        ]
1215        .iter()
1216        .map(|h| {
1217            Cell::from(Line::from(*h).alignment(Alignment::Right)).style(
1218                Style::default()
1219                    .fg(Color::Yellow)
1220                    .add_modifier(Modifier::BOLD),
1221            )
1222        });
1223
1224        let header = Row::new(header_cells)
1225            .style(Style::default().fg(Color::Yellow))
1226            .bottom_margin(1)
1227            .top_margin(1);
1228
1229        let task_stats = self.task_stats.lock().unwrap(); // Acquire lock to read task_stats
1230        let mut rows = task_stats
1231            .stats
1232            .iter()
1233            .enumerate()
1234            .map(|(i, stat)| {
1235                let cells = vec![
1236                    Cell::from(Line::from(self.task_ids[i]).alignment(Alignment::Right))
1237                        .light_blue(),
1238                    Cell::from(Line::from(stat.min().to_string()).alignment(Alignment::Right))
1239                        .style(Style::default()),
1240                    Cell::from(Line::from(stat.max().to_string()).alignment(Alignment::Right))
1241                        .style(Style::default()),
1242                    Cell::from(Line::from(stat.mean().to_string()).alignment(Alignment::Right))
1243                        .style(Style::default()),
1244                    Cell::from(Line::from(stat.stddev().to_string()).alignment(Alignment::Right))
1245                        .style(Style::default()),
1246                    Cell::from(
1247                        Line::from(stat.jitter_mean().to_string()).alignment(Alignment::Right),
1248                    )
1249                    .style(Style::default()),
1250                    Cell::from(
1251                        Line::from(stat.jitter_max().to_string()).alignment(Alignment::Right),
1252                    )
1253                    .style(Style::default()),
1254                ];
1255                Row::new(cells)
1256            })
1257            .collect::<Vec<Row>>();
1258
1259        let cells = vec![
1260            Cell::from(
1261                Line::from("End2End")
1262                    .light_red()
1263                    .alignment(Alignment::Right),
1264            ),
1265            Cell::from(
1266                Line::from(task_stats.end2end.min().to_string())
1267                    .light_red()
1268                    .alignment(Alignment::Right),
1269            )
1270            .style(Style::default()),
1271            Cell::from(
1272                Line::from(task_stats.end2end.max().to_string())
1273                    .light_red()
1274                    .alignment(Alignment::Right),
1275            )
1276            .style(Style::default()),
1277            Cell::from(
1278                Line::from(task_stats.end2end.mean().to_string())
1279                    .light_red()
1280                    .alignment(Alignment::Right),
1281            )
1282            .style(Style::default()),
1283            Cell::from(
1284                Line::from(task_stats.end2end.stddev().to_string())
1285                    .light_red()
1286                    .alignment(Alignment::Right),
1287            )
1288            .style(Style::default()),
1289            Cell::from(
1290                Line::from(task_stats.end2end.jitter_mean().to_string())
1291                    .light_red()
1292                    .alignment(Alignment::Right),
1293            )
1294            .style(Style::default()),
1295            Cell::from(
1296                Line::from(task_stats.end2end.jitter_max().to_string())
1297                    .light_red()
1298                    .alignment(Alignment::Right),
1299            )
1300            .style(Style::default()),
1301        ];
1302        rows.push(Row::new(cells).top_margin(1));
1303
1304        let table = Table::new(
1305            rows,
1306            &[
1307                Constraint::Length(10),
1308                Constraint::Length(10),
1309                Constraint::Length(12),
1310                Constraint::Length(12),
1311                Constraint::Length(10),
1312                Constraint::Length(12),
1313                Constraint::Length(13),
1314            ],
1315        )
1316        .header(header)
1317        .block(
1318            Block::default()
1319                .borders(Borders::ALL)
1320                .border_type(BorderType::Rounded)
1321                .title(" Latencies "),
1322        );
1323
1324        f.render_widget(table, area);
1325    }
1326
1327    fn draw_memory_pools(&self, f: &mut Frame, area: Rect) {
1328        let header_cells = [
1329            "Pool ID",
1330            "Used/Total",
1331            "Buffer Size",
1332            "Handles in Use",
1333            "Handles/sec",
1334        ]
1335        .iter()
1336        .map(|h| {
1337            Cell::from(Line::from(*h).alignment(Alignment::Right)).style(
1338                Style::default()
1339                    .fg(Color::Yellow)
1340                    .add_modifier(Modifier::BOLD),
1341            )
1342        });
1343
1344        let header = Row::new(header_cells)
1345            .style(Style::default().fg(Color::Yellow))
1346            .bottom_margin(1);
1347
1348        let pool_stats = self.pool_stats.lock().unwrap();
1349        let rows = pool_stats
1350            .iter()
1351            .map(|stat| {
1352                let used = stat.total_size - stat.space_left;
1353                let percent = if stat.total_size > 0 {
1354                    100.0 * used as f64 / stat.total_size as f64
1355                } else {
1356                    0.0
1357                };
1358                let buffer_size = stat.buffer_size;
1359                let mb_unit = 1024.0 * 1024.0;
1360
1361                let cells = vec![
1362                    Cell::from(Line::from(stat.id.to_string()).alignment(Alignment::Right))
1363                        .light_blue(),
1364                    Cell::from(
1365                        Line::from(format!(
1366                            "{:.2} MB / {:.2} MB ({:.1}%)",
1367                            used as f64 * buffer_size as f64 / mb_unit,
1368                            stat.total_size as f64 * buffer_size as f64 / mb_unit,
1369                            percent
1370                        ))
1371                        .alignment(Alignment::Right),
1372                    ),
1373                    Cell::from(
1374                        Line::from(format!("{} KB", stat.buffer_size / 1024))
1375                            .alignment(Alignment::Right),
1376                    ),
1377                    Cell::from(
1378                        Line::from(format!("{}", stat.handles_in_use)).alignment(Alignment::Right),
1379                    ),
1380                    Cell::from(
1381                        Line::from(format!("{}/s", stat.handles_per_second))
1382                            .alignment(Alignment::Right),
1383                    ),
1384                ];
1385                Row::new(cells)
1386            })
1387            .collect::<Vec<Row>>();
1388
1389        let table = Table::new(
1390            rows,
1391            &[
1392                Constraint::Percentage(30),
1393                Constraint::Percentage(20),
1394                Constraint::Percentage(15),
1395                Constraint::Percentage(15),
1396                Constraint::Percentage(20),
1397            ],
1398        )
1399        .header(header)
1400        .block(
1401            Block::default()
1402                .borders(Borders::ALL)
1403                .border_type(BorderType::Rounded)
1404                .title(" Memory Pools "),
1405        );
1406
1407        f.render_widget(table, area);
1408    }
1409
1410    fn draw_copperlist_stats(&self, f: &mut Frame, area: Rect) {
1411        let stats = self.copperlist_stats.lock().unwrap();
1412        let size_display = format_bytes_or(stats.size_bytes as u64, "unknown");
1413        let raw_total = stats.raw_culist_bytes.max(stats.size_bytes as u64);
1414        let handles_display = format_bytes_or(stats.handle_bytes, "0 B");
1415        let mem_total = raw_total
1416            .saturating_add(stats.keyframe_bytes)
1417            .saturating_add(stats.structured_bytes_per_cl);
1418        let mem_total_display = format_bytes_or(mem_total, "unknown");
1419        let encoded_display = format_bytes_or(stats.encoded_bytes, "n/a");
1420        let efficiency_display = if raw_total > 0 && stats.encoded_bytes > 0 {
1421            let ratio = (stats.encoded_bytes as f64) / (raw_total as f64);
1422            format!("{:.1}%", ratio * 100.0)
1423        } else {
1424            "n/a".to_string()
1425        };
1426        let rate_display = format!("{:.2} Hz", stats.rate_hz);
1427        let raw_bw = format_rate_bytes_or_na(mem_total, stats.rate_hz);
1428        let keyframe_display = format_bytes_or(stats.keyframe_bytes, "0 B");
1429        let structured_display = format_bytes_or(stats.structured_bytes_per_cl, "0 B");
1430        let structured_bw = format_rate_bytes_or_na(stats.structured_bytes_per_cl, stats.rate_hz);
1431        let disk_total_bytes = stats
1432            .encoded_bytes
1433            .saturating_add(stats.keyframe_bytes)
1434            .saturating_add(stats.structured_bytes_per_cl);
1435        let disk_total_bw = format_rate_bytes_or_na(disk_total_bytes, stats.rate_hz);
1436
1437        let header_cells = ["Metric", "Value"].iter().map(|h| {
1438            Cell::from(Line::from(*h)).style(
1439                Style::default()
1440                    .fg(Color::Yellow)
1441                    .add_modifier(Modifier::BOLD),
1442            )
1443        });
1444
1445        let header = Row::new(header_cells).bottom_margin(1);
1446
1447        let row = |metric: &'static str, value: String| {
1448            Row::new(vec![
1449                Cell::from(Line::from(metric)),
1450                Cell::from(Line::from(value).alignment(Alignment::Right)),
1451            ])
1452        };
1453        let spacer = row(" ", " ".to_string());
1454
1455        let rate_style = Style::default().fg(Color::Cyan);
1456        let mem_rows = vec![
1457            row("Observed rate", rate_display).style(rate_style),
1458            spacer.clone(),
1459            row("CopperList size", size_display),
1460            row("Pool memory used", handles_display),
1461            row("Keyframe size", keyframe_display),
1462            row("Mem total (CL+KF+SL)", mem_total_display),
1463            spacer.clone(),
1464            row("RAM BW (raw)", raw_bw),
1465        ];
1466
1467        let disk_rows = vec![
1468            row("CL serialized size", encoded_display),
1469            row("CL encoding efficiency", efficiency_display),
1470            row("Structured log / CL", structured_display),
1471            row("Structured BW", structured_bw),
1472            spacer.clone(),
1473            row("Total disk BW", disk_total_bw),
1474        ];
1475
1476        let mem_table = Table::new(mem_rows, &[Constraint::Length(24), Constraint::Length(12)])
1477            .header(header.clone())
1478            .block(
1479                Block::default()
1480                    .borders(Borders::ALL)
1481                    .border_type(BorderType::Rounded)
1482                    .title(" Memory BW "),
1483            );
1484
1485        let disk_table = Table::new(disk_rows, &[Constraint::Length(24), Constraint::Length(12)])
1486            .header(header)
1487            .block(
1488                Block::default()
1489                    .borders(Borders::ALL)
1490                    .border_type(BorderType::Rounded)
1491                    .title(" Disk / Encoding "),
1492            );
1493
1494        let layout = Layout::default()
1495            .direction(Direction::Horizontal)
1496            .constraints([Constraint::Length(42), Constraint::Length(42)].as_ref())
1497            .split(area);
1498
1499        f.render_widget(mem_table, layout[0]);
1500        f.render_widget(disk_table, layout[1]);
1501    }
1502
1503    fn draw_nodes(&mut self, f: &mut Frame, space: Rect) {
1504        NodesScrollableWidget {
1505            _marker: Default::default(),
1506        }
1507        .render(
1508            space,
1509            f.buffer_mut(),
1510            &mut self.nodes_scrollable_widget_state,
1511        )
1512    }
1513
1514    fn render_tabs(&mut self, f: &mut Frame, area: Rect) {
1515        let base_bg = Color::Rgb(16, 18, 20);
1516        let active_bg = Color::Rgb(56, 110, 120);
1517        let inactive_bg = Color::Rgb(40, 44, 52);
1518        let active_fg = Color::Rgb(245, 246, 247);
1519        let inactive_fg = Color::Rgb(198, 200, 204);
1520        let key_fg = Color::Rgb(255, 208, 128);
1521
1522        let mut spans = Vec::new();
1523        self.tab_hitboxes.clear();
1524        let mut cursor_x = area.x;
1525        spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1526        cursor_x = cursor_x.saturating_add(1);
1527
1528        for tab in TAB_DEFS {
1529            let is_active = self.active_screen == tab.screen;
1530            let bg = if is_active { active_bg } else { inactive_bg };
1531            let fg = if is_active { active_fg } else { inactive_fg };
1532            let label_style = if is_active {
1533                Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)
1534            } else {
1535                Style::default().fg(fg).bg(bg)
1536            };
1537            let tab_width = segment_width(tab.key, tab.label);
1538            self.tab_hitboxes.push(TabHitbox {
1539                screen: tab.screen,
1540                x: cursor_x,
1541                y: area.y,
1542                width: tab_width,
1543                height: area.height,
1544            });
1545            cursor_x = cursor_x.saturating_add(tab_width);
1546
1547            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1548            spans.push(Span::styled(" ", Style::default().bg(bg)));
1549            spans.push(Span::styled(
1550                tab.key,
1551                Style::default()
1552                    .fg(key_fg)
1553                    .bg(bg)
1554                    .add_modifier(Modifier::BOLD),
1555            ));
1556            spans.push(Span::styled(" ", Style::default().bg(bg)));
1557            spans.push(Span::styled(tab.label, label_style));
1558            spans.push(Span::styled(" ", Style::default().bg(bg)));
1559            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1560            spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1561        }
1562
1563        let tabs = Paragraph::new(Line::from(spans))
1564            .style(Style::default().bg(base_bg))
1565            .block(
1566                Block::default()
1567                    .borders(Borders::BOTTOM)
1568                    .style(Style::default().bg(base_bg)),
1569            );
1570        f.render_widget(tabs, area);
1571    }
1572
1573    fn render_help(&mut self, f: &mut Frame, area: Rect) {
1574        let base_bg = Color::Rgb(18, 16, 22);
1575        let key_fg = Color::Rgb(248, 231, 176);
1576        let text_fg = Color::Rgb(236, 236, 236);
1577
1578        let mut spans = Vec::new();
1579        self.help_hitboxes.clear();
1580        let mut cursor_x = area.x;
1581        spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1582        cursor_x = cursor_x.saturating_add(1);
1583
1584        let tab_hint = if cfg!(feature = "debug_pane") {
1585            "1-6"
1586        } else {
1587            "1-5"
1588        };
1589
1590        let segments = [
1591            (tab_hint, "Tabs", Color::Rgb(86, 114, 98)),
1592            ("r", "Reset latency", Color::Rgb(136, 92, 78)),
1593            ("hjkl/←↑→↓", "Scroll", Color::Rgb(92, 102, 150)),
1594            ("q", "Quit", Color::Rgb(124, 118, 76)),
1595        ];
1596
1597        for (key, label, bg) in segments {
1598            let segment_len = segment_width(key, label);
1599            let action = help_action(key);
1600            if let Some(action) = action {
1601                self.help_hitboxes.push(HelpHitbox {
1602                    action,
1603                    x: cursor_x,
1604                    y: area.y,
1605                    width: segment_len,
1606                    height: area.height,
1607                });
1608            }
1609            cursor_x = cursor_x.saturating_add(segment_len);
1610
1611            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1612            spans.push(Span::styled(" ", Style::default().bg(bg)));
1613            spans.push(Span::styled(
1614                key,
1615                Style::default()
1616                    .fg(key_fg)
1617                    .bg(bg)
1618                    .add_modifier(Modifier::BOLD),
1619            ));
1620            spans.push(Span::styled(" ", Style::default().bg(bg)));
1621            spans.push(Span::styled(label, Style::default().fg(text_fg).bg(bg)));
1622            spans.push(Span::styled(" ", Style::default().bg(bg)));
1623            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1624            spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1625        }
1626
1627        let help = Paragraph::new(Line::from(spans))
1628            .style(Style::default().bg(base_bg))
1629            .block(
1630                Block::default()
1631                    .borders(Borders::TOP)
1632                    .style(Style::default().bg(base_bg)),
1633            );
1634        f.render_widget(help, area);
1635    }
1636
1637    fn draw(&mut self, f: &mut Frame) {
1638        let layout = Layout::default()
1639            .direction(Direction::Vertical)
1640            .constraints(
1641                [
1642                    Constraint::Length(2), // Top tabs
1643                    Constraint::Min(0),    // Main content
1644                    Constraint::Length(2), // Bottom help bar
1645                ]
1646                .as_ref(),
1647            )
1648            .split(f.area());
1649
1650        self.render_tabs(f, layout[0]);
1651        self.render_help(f, layout[2]);
1652
1653        match self.active_screen {
1654            Screen::Neofetch => {
1655                const VERSION: &str = env!("CARGO_PKG_VERSION");
1656                let text: Text = format!("\n   -> Copper v{}\n\n{}\n\n ", VERSION, self.sysinfo)
1657                    .into_text()
1658                    .unwrap();
1659                let p = Paragraph::new::<Text>(text).block(
1660                    Block::default()
1661                        .title(" System Info ")
1662                        .borders(Borders::ALL)
1663                        .border_type(BorderType::Rounded),
1664                );
1665                f.render_widget(p, layout[1]);
1666            }
1667            Screen::Dag => {
1668                self.draw_nodes(f, layout[1]);
1669            }
1670            Screen::Latency => self.draw_latency_table(f, layout[1]),
1671            Screen::MemoryPools => self.draw_memory_pools(f, layout[1]),
1672            Screen::CopperList => self.draw_copperlist_stats(f, layout[1]),
1673            #[cfg(feature = "debug_pane")]
1674            Screen::DebugOutput => self.draw_debug_output(f, layout[1]),
1675        };
1676    }
1677
1678    fn handle_tab_click(&mut self, mouse: event::MouseEvent) {
1679        if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1680            return;
1681        }
1682
1683        for hitbox in &self.tab_hitboxes {
1684            if mouse_inside(&mouse, hitbox.x, hitbox.y, hitbox.width, hitbox.height) {
1685                self.active_screen = hitbox.screen;
1686                break;
1687            }
1688        }
1689    }
1690
1691    fn handle_help_click(&mut self, mouse: event::MouseEvent) -> bool {
1692        if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1693            return false;
1694        }
1695
1696        for hitbox in &self.help_hitboxes {
1697            if !mouse_inside(&mouse, hitbox.x, hitbox.y, hitbox.width, hitbox.height) {
1698                continue;
1699            }
1700
1701            match hitbox.action {
1702                HelpAction::ResetLatency => {
1703                    if self.active_screen == Screen::Latency {
1704                        self.task_stats.lock().unwrap().reset();
1705                    }
1706                }
1707                HelpAction::Quit => {
1708                    self.quitting.store(true, Ordering::SeqCst);
1709                }
1710            }
1711            return true;
1712        }
1713
1714        false
1715    }
1716
1717    fn handle_scroll_mouse(&mut self, mouse: event::MouseEvent) {
1718        if self.active_screen != Screen::Dag {
1719            return;
1720        }
1721
1722        match mouse.kind {
1723            MouseEventKind::ScrollDown => {
1724                self.nodes_scrollable_widget_state
1725                    .nodes_scrollable_state
1726                    .scroll_down();
1727            }
1728            MouseEventKind::ScrollUp => {
1729                self.nodes_scrollable_widget_state
1730                    .nodes_scrollable_state
1731                    .scroll_up();
1732            }
1733            MouseEventKind::ScrollRight => {
1734                for _ in 0..5 {
1735                    self.nodes_scrollable_widget_state
1736                        .nodes_scrollable_state
1737                        .scroll_right();
1738                }
1739            }
1740            MouseEventKind::ScrollLeft => {
1741                for _ in 0..5 {
1742                    self.nodes_scrollable_widget_state
1743                        .nodes_scrollable_state
1744                        .scroll_left();
1745                }
1746            }
1747            _ => {}
1748        }
1749    }
1750
1751    #[cfg(feature = "debug_pane")]
1752    fn handle_mouse_event(&mut self, mouse: event::MouseEvent) {
1753        self.handle_tab_click(mouse);
1754        if self.handle_help_click(mouse) {
1755            return;
1756        }
1757        self.handle_scroll_mouse(mouse);
1758
1759        if self.active_screen != Screen::DebugOutput {
1760            return;
1761        }
1762
1763        let Some(area) = self.debug_output_area else {
1764            return;
1765        };
1766
1767        if !mouse_inside(&mouse, area.x, area.y, area.width, area.height) {
1768            if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1769                self.debug_selection.clear();
1770            }
1771            return;
1772        }
1773
1774        let rel_row = (mouse.row - area.y) as usize;
1775        let rel_col = (mouse.column - area.x) as usize;
1776        let line_index = self.debug_output_visible_offset.saturating_add(rel_row);
1777        let Some(line) = self.debug_output_lines.get(line_index) else {
1778            return;
1779        };
1780        let line_len = line.text.chars().count();
1781        let point = SelectionPoint {
1782            row: line_index,
1783            col: rel_col.min(line_len),
1784        };
1785
1786        match mouse.kind {
1787            MouseEventKind::Down(MouseButton::Left) => {
1788                self.debug_selection.start(point);
1789            }
1790            MouseEventKind::Drag(MouseButton::Left) => {
1791                self.debug_selection.update(point);
1792            }
1793            MouseEventKind::Up(MouseButton::Left) => {
1794                self.debug_selection.update(point);
1795                if let Some(text) = self.selected_debug_text() {
1796                    self.copy_debug_text(text);
1797                }
1798            }
1799            _ => {}
1800        }
1801    }
1802
1803    #[cfg(not(feature = "debug_pane"))]
1804    fn handle_mouse_event(&mut self, mouse: event::MouseEvent) {
1805        self.handle_tab_click(mouse);
1806        let _ = self.handle_help_click(mouse);
1807        self.handle_scroll_mouse(mouse);
1808    }
1809
1810    #[cfg(feature = "debug_pane")]
1811    fn selected_debug_text(&self) -> Option<String> {
1812        let (start, end) = self.debug_selection.range()?;
1813        if start == end {
1814            return None;
1815        }
1816        if self.debug_output_lines.is_empty() {
1817            return None;
1818        }
1819        if start.row >= self.debug_output_lines.len() || end.row >= self.debug_output_lines.len() {
1820            return None;
1821        }
1822
1823        let mut selected = Vec::new();
1824        for row in start.row..=end.row {
1825            let line = &self.debug_output_lines[row];
1826            let line_len = line.text.chars().count();
1827            let Some((start_col, end_col)) = line_selection_bounds(row, line_len, start, end)
1828            else {
1829                selected.push(String::new());
1830                continue;
1831            };
1832            let (_, selected_part, _) = slice_char_range(&line.text, start_col, end_col);
1833            selected.push(selected_part.to_string());
1834        }
1835        Some(selected.join("\n"))
1836    }
1837
1838    #[cfg(feature = "debug_pane")]
1839    fn copy_debug_text(&mut self, text: String) {
1840        if text.is_empty() {
1841            return;
1842        }
1843
1844        if self.clipboard.is_none() {
1845            match Clipboard::new() {
1846                Ok(clipboard) => self.clipboard = Some(clipboard),
1847                Err(err) => {
1848                    eprintln!("CuConsoleMon clipboard init failed: {err}");
1849                    return;
1850                }
1851            }
1852        }
1853
1854        if let Some(clipboard) = self.clipboard.as_mut()
1855            && let Err(err) = clipboard.set_text(text)
1856        {
1857            eprintln!("CuConsoleMon clipboard copy failed: {err}");
1858        }
1859    }
1860
1861    #[cfg(feature = "debug_pane")]
1862    fn build_debug_output_text(&self, area: Rect) -> Text<'_> {
1863        let mut rendered_lines = Vec::new();
1864        let selection = self
1865            .debug_selection
1866            .range()
1867            .filter(|(start, end)| start != end);
1868        let selection_style = Style::default().bg(Color::Blue).fg(Color::Black);
1869        let visible = self
1870            .debug_output_lines
1871            .iter()
1872            .skip(self.debug_output_visible_offset)
1873            .take(area.height as usize);
1874
1875        for (idx, line) in visible.enumerate() {
1876            let line_index = self.debug_output_visible_offset + idx;
1877            let spans = if let Some((start, end)) = selection {
1878                let line_len = line.text.chars().count();
1879                if let Some((start_col, end_col)) =
1880                    line_selection_bounds(line_index, line_len, start, end)
1881                {
1882                    let (before, selected, after) =
1883                        slice_char_range(&line.text, start_col, end_col);
1884                    let mut spans = Vec::new();
1885                    if !before.is_empty() {
1886                        spans.push(Span::raw(before.to_string()));
1887                    }
1888                    spans.push(Span::styled(selected.to_string(), selection_style));
1889                    if !after.is_empty() {
1890                        spans.push(Span::raw(after.to_string()));
1891                    }
1892                    spans
1893                } else {
1894                    spans_from_runs(line)
1895                }
1896            } else {
1897                spans_from_runs(line)
1898            };
1899            rendered_lines.push(Line::from(spans));
1900        }
1901
1902        Text::from(rendered_lines)
1903    }
1904
1905    fn run_app<B: Backend<Error = io::Error>>(
1906        &mut self,
1907        terminal: &mut Terminal<B>,
1908    ) -> io::Result<()> {
1909        loop {
1910            if self.quitting.load(Ordering::SeqCst) {
1911                break;
1912            }
1913            #[cfg(feature = "debug_pane")]
1914            self.update_debug_output();
1915
1916            terminal.draw(|f| {
1917                self.draw(f);
1918            })?;
1919
1920            if event::poll(Duration::from_millis(50))? {
1921                let event = event::read()?;
1922
1923                match event {
1924                    Event::Key(key) => match key.code {
1925                        KeyCode::Char('1') => self.active_screen = Screen::Neofetch,
1926                        KeyCode::Char('2') => self.active_screen = Screen::Dag,
1927                        KeyCode::Char('3') => self.active_screen = Screen::Latency,
1928                        KeyCode::Char('4') => self.active_screen = Screen::CopperList,
1929                        KeyCode::Char('5') => self.active_screen = Screen::MemoryPools,
1930                        #[cfg(feature = "debug_pane")]
1931                        KeyCode::Char('6') => self.active_screen = Screen::DebugOutput,
1932                        KeyCode::Char('r') => {
1933                            if self.active_screen == Screen::Latency {
1934                                self.task_stats.lock().unwrap().reset()
1935                            }
1936                        }
1937                        KeyCode::Char('j') | KeyCode::Down => {
1938                            if self.active_screen == Screen::Dag {
1939                                for _ in 0..1 {
1940                                    self.nodes_scrollable_widget_state
1941                                        .nodes_scrollable_state
1942                                        .scroll_down();
1943                                }
1944                            }
1945                        }
1946                        KeyCode::Char('k') | KeyCode::Up => {
1947                            if self.active_screen == Screen::Dag {
1948                                for _ in 0..1 {
1949                                    self.nodes_scrollable_widget_state
1950                                        .nodes_scrollable_state
1951                                        .scroll_up();
1952                                }
1953                            }
1954                        }
1955                        KeyCode::Char('h') | KeyCode::Left => {
1956                            if self.active_screen == Screen::Dag {
1957                                for _ in 0..5 {
1958                                    self.nodes_scrollable_widget_state
1959                                        .nodes_scrollable_state
1960                                        .scroll_left();
1961                                }
1962                            }
1963                        }
1964                        KeyCode::Char('l') | KeyCode::Right => {
1965                            if self.active_screen == Screen::Dag {
1966                                for _ in 0..5 {
1967                                    self.nodes_scrollable_widget_state
1968                                        .nodes_scrollable_state
1969                                        .scroll_right();
1970                                }
1971                            }
1972                        }
1973                        KeyCode::Char('q') => {
1974                            self.quitting.store(true, Ordering::SeqCst);
1975                            break;
1976                        }
1977                        _ => {}
1978                    },
1979
1980                    Event::Mouse(mouse) => {
1981                        self.handle_mouse_event(mouse);
1982                    }
1983                    Event::Resize(_columns, rows) => {
1984                        self.nodes_scrollable_widget_state.mark_graph_dirty();
1985                        #[cfg(not(feature = "debug_pane"))]
1986                        let _ = rows;
1987                        #[cfg(feature = "debug_pane")]
1988                        if let Some(debug_output) = self.debug_output.as_mut() {
1989                            debug_output.max_rows.store(rows, Ordering::SeqCst)
1990                        }
1991                    }
1992                    _ => {}
1993                }
1994            }
1995        }
1996        Ok(())
1997    }
1998}
1999
2000impl CuMonitor for CuConsoleMon {
2001    fn new(config: &CuConfig, taskids: &'static [&'static str]) -> CuResult<Self>
2002    where
2003        Self: Sized,
2004    {
2005        let mut culist_to_task = [usize::MAX; MAX_CULIST_MAP];
2006        if let Ok(map) = build_culist_to_task_index(config, taskids) {
2007            culist_to_task = map;
2008        }
2009        let task_stats = Arc::new(Mutex::new(TaskStats::new(
2010            taskids.len(),
2011            CuDuration::from(Duration::from_secs(5)),
2012        )));
2013
2014        Ok(Self {
2015            config: config.clone(),
2016            taskids,
2017            task_stats,
2018            task_statuses: Arc::new(Mutex::new(vec![TaskStatus::default(); taskids.len()])),
2019            culist_to_task,
2020            ui_handle: None,
2021            quitting: Arc::new(AtomicBool::new(false)),
2022            pool_stats: Arc::new(Mutex::new(Vec::new())),
2023            copperlist_stats: Arc::new(Mutex::new(CopperListStats::new())),
2024            topology: None,
2025        })
2026    }
2027    fn set_topology(&mut self, topology: MonitorTopology) {
2028        self.topology = Some(topology);
2029    }
2030
2031    fn set_copperlist_info(&mut self, info: CopperListInfo) {
2032        let mut stats = self.copperlist_stats.lock().unwrap();
2033        stats.set_info(info);
2034    }
2035
2036    fn observe_copperlist_io(&self, stats: CopperListIoStats) {
2037        let mut cl_stats = self.copperlist_stats.lock().unwrap();
2038        cl_stats.update_io(stats);
2039    }
2040
2041    fn start(&mut self, _clock: &RobotClock) -> CuResult<()> {
2042        if !should_start_ui() {
2043            #[cfg(debug_assertions)]
2044            {
2045                register_live_log_listener(|entry, format_str, param_names| {
2046                    if let Some(line) = format_headless_log_line(entry, format_str, param_names) {
2047                        println!("{line}");
2048                    }
2049                });
2050            }
2051            return Ok(());
2052        }
2053
2054        let config_dup = self.config.clone();
2055        let taskids = self.taskids;
2056
2057        let task_stats_ui = self.task_stats.clone();
2058        let error_states = self.task_statuses.clone();
2059        let pool_stats_ui = self.pool_stats.clone();
2060        let copperlist_stats_ui = self.copperlist_stats.clone();
2061        let quitting = self.quitting.clone();
2062        let topology = self.topology.clone();
2063
2064        // Start the main UI loop
2065        let handle = thread::spawn(move || {
2066            let backend = CrosstermBackend::new(stdout());
2067            let _terminal_guard = TerminalRestoreGuard;
2068
2069            if let Err(err) = setup_terminal() {
2070                eprintln!("Failed to prepare terminal UI: {err}");
2071                return;
2072            }
2073
2074            let mut terminal = match Terminal::new(backend) {
2075                Ok(terminal) => terminal,
2076                Err(err) => {
2077                    eprintln!("Failed to initialize terminal backend: {err}");
2078                    return;
2079                }
2080            };
2081
2082            #[cfg(feature = "debug_pane")]
2083            {
2084                // redirect stderr, so it doesn't pop in the terminal
2085                let error_redirect = match gag::BufferRedirect::stderr() {
2086                    Ok(redirect) => Some(redirect),
2087                    Err(err) => {
2088                        eprintln!(
2089                            "Failed to redirect stderr for debug pane; continuing without redirect: {err}"
2090                        );
2091                        None
2092                    }
2093                };
2094
2095                let mut ui = UI::new(
2096                    config_dup,
2097                    None, // FIXME(gbin): Allow somethere an API to get the current mission running
2098                    taskids,
2099                    task_stats_ui,
2100                    error_states,
2101                    quitting.clone(),
2102                    error_redirect,
2103                    None,
2104                    pool_stats_ui,
2105                    copperlist_stats_ui,
2106                    topology.clone(),
2107                );
2108
2109                #[cfg(debug_assertions)]
2110                {
2111                    let max_lines = terminal.size().unwrap().height - 5;
2112                    let (mut debug_log, tx) = debug_pane::DebugLog::new(max_lines);
2113
2114                    cu29_log_runtime::register_live_log_listener(
2115                        move |entry, format_str, param_names| {
2116                            // Rebuild line from structured data, then push to bounded channel.
2117                            let params: Vec<String> =
2118                                entry.params.iter().map(|v| v.to_string()).collect();
2119                            let named_params: HashMap<String, String> = param_names
2120                                .iter()
2121                                .zip(params.iter())
2122                                .map(|(name, value)| (name.to_string(), value.clone()))
2123                                .collect();
2124                            let line = styled_line_from_structured(
2125                                entry.time,
2126                                entry.level,
2127                                format_str,
2128                                params.as_slice(),
2129                                &named_params,
2130                            );
2131                            // Non-blocking: drop log if the bounded channel is full to avoid stalling the runtime.
2132                            let _ = tx.try_send(line);
2133                        },
2134                    );
2135
2136                    // Drain any pending from the channel into the UI buffer once to size it.
2137                    debug_log.update_logs();
2138                    ui.debug_output = Some(debug_log);
2139                }
2140                if let Err(err) = ui.run_app(&mut terminal) {
2141                    let _ = restore_terminal();
2142                    eprintln!("CuConsoleMon UI exited with error: {err}");
2143                    cu29_log_runtime::unregister_live_log_listener();
2144                    return;
2145                }
2146                cu29_log_runtime::unregister_live_log_listener();
2147            }
2148
2149            #[cfg(not(feature = "debug_pane"))]
2150            {
2151                let stderr_gag = gag::Gag::stderr().unwrap();
2152
2153                let mut ui = UI::new(
2154                    config_dup,
2155                    taskids,
2156                    task_stats_ui,
2157                    error_states,
2158                    quitting.clone(),
2159                    pool_stats_ui,
2160                    copperlist_stats_ui,
2161                    topology,
2162                );
2163                if let Err(err) = ui.run_app(&mut terminal) {
2164                    let _ = restore_terminal();
2165                    eprintln!("CuConsoleMon UI exited with error: {err}");
2166                    return;
2167                }
2168
2169                drop(stderr_gag);
2170            }
2171
2172            quitting.store(true, Ordering::SeqCst);
2173            // restoring the terminal
2174            let _ = restore_terminal();
2175        });
2176
2177        self.ui_handle = Some(handle);
2178        Ok(())
2179    }
2180
2181    fn process_copperlist(&self, msgs: &[&CuMsgMetadata]) -> CuResult<()> {
2182        {
2183            let mut task_stats = self.task_stats.lock().unwrap();
2184            task_stats.update(msgs);
2185        }
2186        {
2187            let mut copperlist_stats = self.copperlist_stats.lock().unwrap();
2188            copperlist_stats.update_rate();
2189        }
2190        {
2191            let mut task_statuses = self.task_statuses.lock().unwrap();
2192            for (i, msg) in msgs.iter().enumerate() {
2193                let CuCompactString(status_txt) = &msg.status_txt;
2194                if let Some(&task_idx) = self.culist_to_task.get(i)
2195                    && task_idx != usize::MAX
2196                    && task_idx < task_statuses.len()
2197                {
2198                    task_statuses[task_idx].status_txt = status_txt.clone();
2199                }
2200            }
2201        }
2202
2203        // Update pool statistics
2204        {
2205            let pool_stats_data = pool::pools_statistics();
2206            let mut pool_stats = self.pool_stats.lock().unwrap();
2207
2208            // Update existing pools or add new ones
2209            for (id, space_left, total_size, buffer_size) in pool_stats_data {
2210                let id_str = id.to_string();
2211                if let Some(existing) = pool_stats.iter_mut().find(|p| p.id == id_str) {
2212                    existing.update(space_left, total_size);
2213                } else {
2214                    pool_stats.push(PoolStats::new(id_str, space_left, total_size, buffer_size));
2215                }
2216            }
2217        }
2218
2219        if self.quitting.load(Ordering::SeqCst) {
2220            return Err("Exiting...".into());
2221        }
2222        Ok(())
2223    }
2224
2225    fn process_error(&self, taskid: usize, step: CuTaskState, error: &CuError) -> Decision {
2226        {
2227            let status = &mut self.task_statuses.lock().unwrap()[taskid];
2228            status.is_error = true;
2229            status.error = error.to_compact_string();
2230        }
2231        match step {
2232            CuTaskState::Start => Decision::Shutdown,
2233            CuTaskState::Preprocess => Decision::Abort,
2234            CuTaskState::Process => Decision::Ignore,
2235            CuTaskState::Postprocess => Decision::Ignore,
2236            CuTaskState::Stop => Decision::Shutdown,
2237        }
2238    }
2239
2240    fn stop(&mut self, _clock: &RobotClock) -> CuResult<()> {
2241        self.quitting.store(true, Ordering::SeqCst);
2242        let _ = restore_terminal();
2243
2244        if let Some(handle) = self.ui_handle.take() {
2245            let _ = handle.join();
2246        }
2247
2248        #[cfg(debug_assertions)]
2249        if !should_start_ui() {
2250            unregister_live_log_listener();
2251        }
2252
2253        self.task_stats
2254            .lock()
2255            .unwrap()
2256            .stats
2257            .iter_mut()
2258            .for_each(|s| s.reset());
2259        Ok(())
2260    }
2261}
2262
2263struct TerminalRestoreGuard;
2264
2265impl Drop for TerminalRestoreGuard {
2266    fn drop(&mut self) {
2267        let _ = restore_terminal();
2268    }
2269}
2270
2271fn build_culist_to_task_index(
2272    config: &CuConfig,
2273    task_ids: &'static [&'static str],
2274) -> CuResult<[usize; MAX_CULIST_MAP]> {
2275    let graph = config.get_graph(None)?;
2276    let plan = compute_runtime_plan(graph)?;
2277
2278    let mut mapping = [usize::MAX; MAX_CULIST_MAP];
2279
2280    for unit in &plan.steps {
2281        if let CuExecutionUnit::Step(step) = unit
2282            && let Some(output_pack) = &step.output_msg_pack
2283        {
2284            if step.node.get_flavor() != Flavor::Task {
2285                continue;
2286            }
2287            let node_id = step.node.get_id();
2288            let culist_idx = output_pack.culist_index as usize;
2289            if culist_idx >= MAX_CULIST_MAP {
2290                continue;
2291            }
2292            if let Some(task_idx) = task_ids.iter().position(|id| *id == node_id.as_str()) {
2293                mapping[culist_idx] = task_idx;
2294            }
2295        }
2296    }
2297
2298    Ok(mapping)
2299}
2300
2301fn init_error_hooks() {
2302    static ONCE: OnceLock<()> = OnceLock::new();
2303    if ONCE.get().is_some() {
2304        return;
2305    }
2306
2307    let (_panic_hook, error) = HookBuilder::default().into_hooks();
2308    let error = error.into_eyre_hook();
2309    color_eyre::eyre::set_hook(Box::new(move |e| {
2310        let _ = restore_terminal();
2311        error(e)
2312    }))
2313    .unwrap();
2314    std::panic::set_hook(Box::new(move |info| {
2315        let _ = restore_terminal();
2316        let bt = Backtrace::force_capture();
2317        // stderr may be gagged; print to stdout so the panic is visible.
2318        println!("CuConsoleMon panic: {info}");
2319        println!("Backtrace:\n{bt}");
2320        let _ = stdout().flush();
2321        // Exit immediately so the process doesn't hang after the TUI restores.
2322        process::exit(1);
2323    }));
2324
2325    let _ = ONCE.set(());
2326}
2327
2328fn setup_terminal() -> io::Result<()> {
2329    enable_raw_mode()?;
2330    // Enable mouse capture for in-app log selection.
2331    execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
2332    Ok(())
2333}
2334
2335fn restore_terminal() -> io::Result<()> {
2336    execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
2337    disable_raw_mode()
2338}
2339
2340#[cfg(debug_assertions)]
2341fn format_timestamp(time: CuDuration) -> String {
2342    // Render CuTime/CuDuration as HH:mm:ss.xxxx (4 fractional digits of a second).
2343    let nanos = time.as_nanos();
2344    let total_seconds = nanos / 1_000_000_000;
2345    let hours = total_seconds / 3600;
2346    let minutes = (total_seconds / 60) % 60;
2347    let seconds = total_seconds % 60;
2348    let fractional_1e4 = (nanos % 1_000_000_000) / 100_000;
2349    format!("{hours:02}:{minutes:02}:{seconds:02}.{fractional_1e4:04}")
2350}
2351
2352#[cfg(debug_assertions)]
2353fn format_headless_log_line(
2354    entry: &cu29_log::CuLogEntry,
2355    format_str: &str,
2356    param_names: &[&str],
2357) -> Option<String> {
2358    let params: Vec<String> = entry.params.iter().map(|v| v.to_string()).collect();
2359    let named: HashMap<String, String> = param_names
2360        .iter()
2361        .zip(params.iter())
2362        .map(|(k, v)| (k.to_string(), v.clone()))
2363        .collect();
2364
2365    format_message_only(format_str, params.as_slice(), &named)
2366        .ok()
2367        .map(|msg| {
2368            let ts = format_timestamp(entry.time);
2369            format!("{} [{:?}] {}", ts, entry.level, msg)
2370        })
2371}
2372
2373fn should_start_ui() -> bool {
2374    if !stdout().is_tty() || !stdin().is_tty() {
2375        return false;
2376    }
2377
2378    #[cfg(unix)]
2379    {
2380        use std::os::unix::io::AsRawFd;
2381        let stdin_fd = stdin().as_raw_fd();
2382        // SAFETY: tcgetpgrp only reads process group state for a valid fd.
2383        let fg_pgrp = unsafe { libc::tcgetpgrp(stdin_fd) };
2384        if fg_pgrp == -1 {
2385            return false;
2386        }
2387        // SAFETY: getpgrp has no safety requirements beyond being called in a process.
2388        let pgrp = unsafe { libc::getpgrp() };
2389        if fg_pgrp != pgrp {
2390            return false;
2391        }
2392    }
2393
2394    true
2395}