Skip to main content

systemprompt_cli/presentation/
renderer.rs

1//! Interactive startup renderer consuming phase/service events.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}