systemprompt_cli/presentation/
renderer.rs1use futures_util::StreamExt;
2use systemprompt_traits::{
3 Phase, ServiceInfo, ServiceState, ServiceType, StartupEvent, StartupEventReceiver,
4};
5
6use super::state::RenderState;
7use indicatif::{ProgressBar, ProgressStyle};
8use std::time::Duration;
9use systemprompt_logging::services::cli::BrandColors;
10
11use super::widgets::{CompletionMessage, ServiceTable, StartupBanner, render_warning};
12
13#[derive(Debug)]
14pub struct StartupRenderer {
15 receiver: StartupEventReceiver,
16 state: RenderState,
17}
18
19impl StartupRenderer {
20 pub fn new(receiver: StartupEventReceiver) -> Self {
21 Self {
22 receiver,
23 state: RenderState::new(),
24 }
25 }
26
27 pub async fn run(mut self) {
28 StartupBanner::render(Some("Starting services..."));
29
30 while let Some(event) = self.receiver.next().await {
31 if self.handle_event(event) {
32 break;
33 }
34 }
35 }
36
37 fn handle_event(&mut self, event: StartupEvent) -> bool {
38 let Some(event) = self.handle_phase_event(event) else {
39 return false;
40 };
41 let Some(event) = self.handle_service_event(event) else {
42 return false;
43 };
44 let Some(event) = self.handle_status_event(event) else {
45 return false;
46 };
47 self.handle_terminal_event(event)
48 }
49
50 fn handle_phase_event(&mut self, event: StartupEvent) -> Option<StartupEvent> {
51 match event {
52 StartupEvent::PhaseStarted { phase } => {
53 self.state.finish_all_spinners();
54 self.state.current_phase = Some(phase);
55 self.state.is_blocking = phase.is_blocking();
56 if matches!(phase, Phase::McpServers | Phase::Agents) {
57 let spinner = Self::create_phase_spinner(phase.name());
58 self.state
59 .spinners
60 .insert(format!("phase_{}", phase.name()), spinner);
61 }
62 },
63 StartupEvent::PhaseCompleted { phase } => {
64 let phase_key = format!("phase_{}", phase.name());
65 if let Some(spinner) = self.state.spinners.remove(&phase_key) {
66 spinner.finish_and_clear();
67 let (running, total) = match phase {
68 Phase::McpServers => self.state.mcp_count,
69 Phase::Agents => self.state.agent_count,
70 _ => (0, 0),
71 };
72 systemprompt_logging::CliService::info(&format!(
73 " {} {} ({}/{})",
74 BrandColors::running("✓"),
75 phase.name(),
76 running,
77 total
78 ));
79 }
80 },
81 StartupEvent::PhaseFailed { phase, error } => {
82 let phase_key = format!("phase_{}", phase.name());
83 if let Some(spinner) = self.state.spinners.remove(&phase_key) {
84 spinner.finish_and_clear();
85 systemprompt_logging::CliService::info(&format!(
86 " {} {} failed: {}",
87 BrandColors::stopped("✗"),
88 phase.name(),
89 error
90 ));
91 } else {
92 render_warning(&format!("{} failed: {}", phase.name(), error));
93 }
94 },
95 other => return Some(other),
96 }
97 None
98 }
99
100 fn handle_service_event(&mut self, event: StartupEvent) -> Option<StartupEvent> {
101 match event {
102 StartupEvent::McpServerReady {
103 name,
104 port,
105 startup_time,
106 tools: _,
107 } => {
108 self.state.add_service(ServiceInfo {
109 name,
110 service_type: ServiceType::Mcp,
111 port: Some(port),
112 state: ServiceState::Running,
113 startup_time: Some(startup_time),
114 });
115 },
116 StartupEvent::McpServerFailed { name, error } => {
117 render_warning(&format!("MCP {} failed: {}", name, error));
118 self.state.add_service(ServiceInfo {
119 name,
120 service_type: ServiceType::Mcp,
121 port: None,
122 state: ServiceState::Failed,
123 startup_time: None,
124 });
125 },
126 StartupEvent::McpReconciliationComplete { running, required } => {
127 self.state.mcp_count = (running, required);
128 },
129 StartupEvent::AgentReady {
130 name,
131 port,
132 startup_time,
133 } => {
134 self.state.add_service(ServiceInfo {
135 name,
136 service_type: ServiceType::Agent,
137 port: Some(port),
138 state: ServiceState::Running,
139 startup_time: Some(startup_time),
140 });
141 },
142 StartupEvent::AgentFailed { name, error } => {
143 render_warning(&format!("Agent {} failed: {}", name, error));
144 self.state.add_service(ServiceInfo {
145 name,
146 service_type: ServiceType::Agent,
147 port: None,
148 state: ServiceState::Failed,
149 startup_time: None,
150 });
151 },
152 StartupEvent::AgentReconciliationComplete { running, total } => {
153 self.state.agent_count = (running, total);
154 },
155 other => return Some(other),
156 }
157 None
158 }
159
160 fn handle_status_event(&mut self, event: StartupEvent) -> Option<StartupEvent> {
161 match event {
162 StartupEvent::PortConflict { port, pid } => {
163 render_warning(&format!("Port {} in use by PID {}", port, pid));
164 },
165 StartupEvent::SchedulerInitializing => {
166 let spinner = Self::create_phase_spinner("Scheduler");
167 self.state.spinners.insert("scheduler".to_owned(), spinner);
168 },
169 StartupEvent::SchedulerReady { job_count } => {
170 if let Some(spinner) = self.state.spinners.remove("scheduler") {
171 spinner.finish_and_clear();
172 systemprompt_logging::CliService::info(&format!(
173 " {} Scheduler ({} jobs)",
174 BrandColors::running("✓"),
175 job_count
176 ));
177 }
178 },
179 StartupEvent::Warning { message, context } => {
180 self.state.warnings.push(message.clone());
181 match context {
182 Some(ctx) => render_warning(&format!("{}: {}", message, ctx)),
183 None => render_warning(&message),
184 }
185 },
186 StartupEvent::Error { message, fatal } => {
187 if fatal {
188 self.state.finish_all_spinners();
189 }
190 render_warning(&format!("ERROR: {}", message));
191 },
192 other => return Some(other),
193 }
194 None
195 }
196
197 fn handle_terminal_event(&mut self, event: StartupEvent) -> bool {
198 match event {
199 StartupEvent::StartupComplete {
200 duration,
201 api_url,
202 services,
203 } => {
204 self.state.finish_all_spinners();
205 for svc in services {
206 if !self.state.services.iter().any(|s| s.name == svc.name) {
207 self.state.services.push(svc);
208 }
209 }
210 if !self.state.services.is_empty() {
211 ServiceTable::render("Services", &self.state.services);
212 }
213 CompletionMessage::render_success(duration, &api_url);
214 true
215 },
216 StartupEvent::StartupFailed { error, duration } => {
217 self.state.finish_all_spinners();
218 CompletionMessage::render_failure(duration, &error);
219 true
220 },
221 _ => false,
222 }
223 }
224
225 fn create_phase_spinner(name: &str) -> ProgressBar {
226 let spinner = ProgressBar::new_spinner();
227 spinner.set_style(
228 ProgressStyle::default_spinner()
229 .template(" {spinner:.cyan} {msg}")
230 .unwrap_or_else(|_| ProgressStyle::default_spinner())
231 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
232 );
233 spinner.set_message(format!("{}...", name));
234 spinner.enable_steady_tick(Duration::from_millis(80));
235 spinner
236 }
237}