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 {
175                scheduled,
176                available,
177            } => {
178                if let Some(spinner) = self.state.spinners.remove("scheduler") {
179                    spinner.finish_and_clear();
180                    systemprompt_logging::CliService::info(&format!(
181                        "  {} Scheduler ({scheduled} scheduled, {available} available)",
182                        BrandColors::running("✓"),
183                    ));
184                }
185            },
186            StartupEvent::Warning { message, context } => {
187                self.state.warnings.push(message.clone());
188                match context {
189                    Some(ctx) => render_warning(&format!("{}: {}", message, ctx)),
190                    None => render_warning(&message),
191                }
192            },
193            StartupEvent::Error { message, fatal } => {
194                if fatal {
195                    self.state.finish_all_spinners();
196                }
197                render_warning(&format!("ERROR: {}", message));
198            },
199            other => return Some(other),
200        }
201        None
202    }
203
204    fn handle_terminal_event(&mut self, event: StartupEvent) -> bool {
205        match event {
206            StartupEvent::StartupComplete {
207                duration,
208                api_url,
209                services,
210            } => {
211                self.state.finish_all_spinners();
212                for svc in services {
213                    if !self.state.services.iter().any(|s| s.name == svc.name) {
214                        self.state.services.push(svc);
215                    }
216                }
217                if !self.state.services.is_empty() {
218                    ServiceTable::render("Services", &self.state.services);
219                }
220                CompletionMessage::render_success(duration, &api_url);
221                true
222            },
223            StartupEvent::StartupFailed { error, duration } => {
224                self.state.finish_all_spinners();
225                CompletionMessage::render_failure(duration, &error);
226                true
227            },
228            _ => false,
229        }
230    }
231
232    fn create_phase_spinner(name: &str) -> ProgressBar {
233        let spinner = ProgressBar::new_spinner();
234        spinner.set_style(
235            ProgressStyle::default_spinner()
236                .template("  {spinner:.cyan} {msg}")
237                .unwrap_or_else(|_| ProgressStyle::default_spinner())
238                .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
239        );
240        spinner.set_message(format!("{}...", name));
241        spinner.enable_steady_tick(Duration::from_millis(80));
242        spinner
243    }
244}