rustyclaw_tui/components/
sidebar.rs1use crate::action::ThreadInfo;
4use crate::theme;
5use iocraft::prelude::*;
6
7const 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 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 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 #(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 #(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 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 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 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 View(flex_grow: 1.0)
162 }
163 }
164}