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