Skip to main content

rustyclaw_tui/components/
sidebar.rs

1// ── Sidebar ─────────────────────────────────────────────────────────────────
2
3use crate::action::ThreadInfo;
4use crate::theme;
5use iocraft::prelude::*;
6
7/// Braille spinner frames for smooth animation.
8const SPINNER_FRAMES: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
9
10#[derive(Default, Props)]
11pub struct SidebarProps {
12    pub gateway_label: String,
13    pub task_text: String,
14    pub streaming: bool,
15    pub elapsed: String,
16    pub spinner_tick: usize,
17    pub threads: Vec<ThreadInfo>,
18    pub focused: bool,
19    pub selected: usize,
20}
21
22#[component]
23pub fn Sidebar(props: &SidebarProps) -> impl Into<AnyElement<'static>> {
24    let spinner = SPINNER_FRAMES[props.spinner_tick % SPINNER_FRAMES.len()];
25    let has_threads = !props.threads.is_empty();
26
27    // Border color reflects focus state
28    let border_color = if props.focused {
29        theme::ACCENT
30    } else {
31        theme::MUTED
32    };
33
34    element! {
35        View(
36            width: 24,
37            height: 100pct,
38            flex_direction: FlexDirection::Column,
39            border_style: BorderStyle::Round,
40            border_color: border_color,
41            border_edges: Edges::Left,
42            padding_left: 1,
43            padding_right: 1,
44        ) {
45            // Session
46            Text(content: " Session", color: theme::ACCENT_BRIGHT, weight: Weight::Bold)
47            View(margin_top: 1) {
48                Text(content: format!("Status: {}", props.gateway_label), color: theme::TEXT_DIM)
49            }
50
51            // Streaming indicator
52            #(if props.streaming {
53                element! {
54                    View(margin_top: 1, flex_direction: FlexDirection::Row) {
55                        Text(content: format!("{} ", spinner), color: theme::ACCENT)
56                        Text(content: format!("Streaming {}", props.elapsed), color: theme::TEXT_DIM)
57                    }
58                }.into_any()
59            } else {
60                element! { View() }.into_any()
61            })
62
63            // Unified threads section (includes tasks)
64            #(if has_threads {
65                element! {
66                    View(margin_top: 1, flex_direction: FlexDirection::Column) {
67                        Text(content: " Threads", color: theme::ACCENT_BRIGHT, weight: Weight::Bold)
68                        View(margin_top: 1, flex_direction: FlexDirection::Column) {
69                            #(props.threads.iter().enumerate().take(10).map(|(i, thread)| {
70                                let is_selected = props.focused && i == props.selected;
71
72                                // Use structured status_icon from gateway if available,
73                                // otherwise fall back to string matching
74                                let status_icon = if is_selected {
75                                    "▸".to_string()
76                                } else if let Some(ref icon) = thread.status_icon {
77                                    icon.clone()
78                                } else {
79                                    match thread.status.as_deref() {
80                                        Some("Running") => "▶",
81                                        Some("Pending") => "◯",
82                                        Some("Completed") => "✓",
83                                        Some("Failed") => "✗",
84                                        Some("Cancelled") => "⊘",
85                                        Some("Paused") => "⏸",
86                                        None if thread.is_foreground => "★",
87                                        None if thread.has_summary => "⌁",
88                                        _ => " ",
89                                    }.to_string()
90                                };
91
92                                // Truncate label to fit sidebar width (char-safe)
93                                let label = if thread.label.chars().count() > 16 {
94                                    let truncated: String = thread.label.chars().take(15).collect();
95                                    format!("{}…", truncated)
96                                } else {
97                                    thread.label.clone()
98                                };
99
100                                // Build description line if present
101                                let desc = thread.description.as_ref().map(|d| {
102                                    if d.chars().count() > 20 {
103                                        let truncated: String = d.chars().take(19).collect();
104                                        format!("{}…", truncated)
105                                    } else {
106                                        d.clone()
107                                    }
108                                });
109
110                                element! {
111                                    View(key: i as u64, flex_direction: FlexDirection::Column) {
112                                        View(flex_direction: FlexDirection::Row) {
113                                            Text(
114                                                content: status_icon,
115                                                color: if thread.is_foreground || is_selected { theme::ACCENT } else { theme::MUTED },
116                                            )
117                                            Text(
118                                                content: format!(" {}", label),
119                                                color: if is_selected { theme::TEXT } else { theme::TEXT_DIM },
120                                                weight: if is_selected { Weight::Bold } else { Weight::Normal },
121                                            )
122                                        }
123                                        #(if let Some(ref d) = desc {
124                                            element! {
125                                                Text(
126                                                    content: format!("  {}", d),
127                                                    color: theme::MUTED,
128                                                )
129                                            }.into_any()
130                                        } else {
131                                            element! { View() }.into_any()
132                                        })
133                                    }
134                                }
135                            }))
136                            #(if props.threads.len() > 10 {
137                                element! {
138                                    Text(
139                                        content: format!("  +{} more", props.threads.len() - 10),
140                                        color: theme::MUTED,
141                                    )
142                                }.into_any()
143                            } else {
144                                element! { View() }.into_any()
145                            })
146                        }
147                    }
148                }.into_any()
149            } else {
150                element! {
151                    View(margin_top: 1, flex_direction: FlexDirection::Column) {
152                        Text(content: " Threads", color: theme::ACCENT_BRIGHT, weight: Weight::Bold)
153                        View(margin_top: 1) {
154                            Text(content: &props.task_text, color: theme::MUTED)
155                        }
156                    }
157                }.into_any()
158            })
159
160            // Spacer
161            View(flex_grow: 1.0)
162        }
163    }
164}