返回 CodeWhale
session_boot.rs
根目录 / crates / tui / src / tui / session_boot.rs
1 //! Session-page MCP + plugin boot surface.
2 //!
3 //! Plugin discovery and every enabled MCP server boot as a **set**, not a
4 //! toast per name. The Tideline footer carries the compact pulse
5 //! (`MCP · 4 connecting` or `Plugins · Problems: 2 · /plugins`); detailed
6 //! diagnosis and actions belong in `/mcp` or `/plugins`, never as multi-row
7 //! boot output between the transcript and composer.
8 //!
9 //! One exception, and it is a place rather than a second source: the launch
10 //! screen has the vertical room the footer does not, so
11 //! [`crate::tui::underwater::launch_empty_state`] projects the *same*
12 //! [`SessionBootSurface`] into a block under the recent-work list that names
13 //! the servers that failed or need a login. Nothing here computes MCP state
14 //! twice; that renderer reads [`SessionBootSurface::servers`].
15
16 use unicode_width::UnicodeWidthStr;
17
18 use crate::mcp::{McpManagerSnapshot, McpServerSnapshot};
19 use crate::plugins::PluginRegistry;
20 use crate::plugins::types::{PluginDiagnosticLevel, PluginTrustStatus};
21 use crate::tui::app::App;
22 use codewhale_localization::{Locale, MessageId, tr};
23
24 pub(crate) const ITEM_SEPARATOR: &str = " · ";
25 const MAX_NAMED_CHIPS: usize = 4;
26
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 pub enum SessionBootPhase {
29 Hidden,
30 Booting,
31 Settled,
32 }
33
34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 pub enum McpServerBootState {
36 Connecting,
37 Connected,
38 Failed,
39 NeedsLogin,
40 Disabled,
41 }
42
43 #[derive(Debug, Clone, PartialEq, Eq)]
44 pub struct McpServerBootRow {
45 pub name: String,
46 pub state: McpServerBootState,
47 }
48
49 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50 pub struct PluginBootSummary {
51 pub loaded: usize,
52 pub invalid: usize,
53 pub duplicate: usize,
54 pub needs_setup: usize,
55 }
56
57 impl PluginBootSummary {
58 #[must_use]
59 pub fn is_quiet(self) -> bool {
60 self.problem_count() == 0
61 }
62
63 #[must_use]
64 pub fn problem_count(self) -> usize {
65 self.invalid + self.duplicate + self.needs_setup
66 }
67
68 #[must_use]
69 pub fn has_failures(self) -> bool {
70 self.invalid > 0 || self.duplicate > 0
71 }
72
73 #[must_use]
74 pub fn from_registry(registry: &PluginRegistry) -> Self {
75 let loaded = registry.list().len();
76 let mut invalid = 0usize;
77 let mut duplicate = 0usize;
78 let mut needs_setup = 0usize;
79 for diagnostic in registry.diagnostics() {
80 match diagnostic.code {
81 "duplicate-root" | "name-conflict" => duplicate += 1,
82 _ if diagnostic.level == PluginDiagnosticLevel::Error => invalid += 1,
83 _ => {}
84 }
85 }
86 for plugin in registry.list() {
87 if plugin
88 .diagnostics
89 .iter()
90 .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error)
91 {
92 invalid += 1;
93 } else if plugin_trust_needs_setup(plugin.trust_status) {
94 needs_setup += 1;
95 }
96 }
97 Self {
98 loaded,
99 invalid,
100 duplicate,
101 needs_setup,
102 }
103 }
104 }
105
106 fn plugin_trust_needs_setup(status: PluginTrustStatus) -> bool {
107 matches!(
108 status,
109 PluginTrustStatus::NeverReviewed
110 | PluginTrustStatus::ContentChanged
111 | PluginTrustStatus::CapabilitiesChanged
112 )
113 }
114
115 /// Semantic severity for the compact boot activity notice.
116 ///
117 /// The text carries no color names or inferred state. Its consumer maps this
118 /// closed state into the Tideline palette, so a plugin warning cannot inherit
119 /// an unrelated MCP color merely because both use the same footer slot.
120 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
121 pub enum SessionBootActivityLevel {
122 Active,
123 Attention,
124 Failure,
125 }
126
127 #[derive(Debug, Clone, PartialEq, Eq)]
128 pub struct SessionBootActivityChip {
129 pub text: String,
130 pub level: SessionBootActivityLevel,
131 }
132
133 #[derive(Debug, Clone, PartialEq, Eq)]
134 pub struct SessionBootSurface {
135 pub phase: SessionBootPhase,
136 pub servers: Vec<McpServerBootRow>,
137 pub plugins: PluginBootSummary,
138 /// Enabled-server count used when names have not arrived yet, so the
139 /// first frame can still say `MCP · N connecting` instead of hiding.
140 unnamed_connecting: usize,
141 }
142
143 impl SessionBootSurface {
144 #[must_use]
145 pub fn from_app(app: &App) -> Self {
146 Self::from_parts(
147 app.mcp_snapshot.as_ref(),
148 app.mcp_initializing,
149 &app.mcp_connecting,
150 app.mcp_configured_count,
151 PluginBootSummary::from_registry(app.plugin_registry.as_ref()),
152 )
153 }
154
155 #[must_use]
156 pub fn from_parts(
157 snapshot: Option<&McpManagerSnapshot>,
158 initializing: bool,
159 connecting: &[String],
160 configured_count: usize,
161 plugins: PluginBootSummary,
162 ) -> Self {
163 let servers = if let Some(snapshot) = snapshot {
164 snapshot
165 .servers
166 .iter()
167 .filter_map(|server| row_from_snapshot(server, connecting))
168 .collect()
169 } else if initializing {
170 let mut names = connecting.to_vec();
171 names.sort();
172 names
173 .into_iter()
174 .map(|name| McpServerBootRow {
175 name,
176 state: McpServerBootState::Connecting,
177 })
178 .collect()
179 } else {
180 Vec::new()
181 };
182
183 let connecting_count = servers
184 .iter()
185 .filter(|row| row.state == McpServerBootState::Connecting)
186 .count();
187 // The pre-event gap is the only moment the in-flight names are
188 // genuinely unknown: once `connecting` arrives it is the engine's
189 // real in-flight set (#6033), and an empty set under lazy boot means
190 // nothing is connecting — not "names have not arrived yet".
191 let unnamed_connecting =
192 if connecting_count == 0 && initializing && connecting.is_empty() && snapshot.is_none()
193 {
194 configured_count
195 } else {
196 0
197 };
198 let phase = if servers.is_empty() && plugins.is_quiet() && unnamed_connecting == 0 {
199 SessionBootPhase::Hidden
200 } else if initializing || connecting_count > 0 || unnamed_connecting > 0 {
201 SessionBootPhase::Booting
202 } else {
203 SessionBootPhase::Settled
204 };
205
206 Self {
207 phase,
208 servers,
209 plugins,
210 unnamed_connecting,
211 }
212 }
213
214 /// Enabled servers known to be connecting before any name has arrived.
215 ///
216 /// The first launch frame can have `mcp_initializing` set with an empty
217 /// name list; without this a 23-server workspace would paint nothing at
218 /// all until the first boot event lands.
219 #[must_use]
220 pub fn connecting_without_names(&self) -> usize {
221 self.unnamed_connecting
222 }
223
224 #[must_use]
225 pub fn activity_notice(
226 &self,
227 locale: Locale,
228 budget: usize,
229 ) -> Option<SessionBootActivityChip> {
230 if self.phase == SessionBootPhase::Hidden || budget == 0 {
231 return None;
232 }
233 let connecting: Vec<&str> = self
234 .servers
235 .iter()
236 .filter(|row| row.state == McpServerBootState::Connecting)
237 .map(|row| row.name.as_str())
238 .collect();
239 // A server whose login expired is not a failure: the engine already
240 // knows the remedy (`codewhale mcp login <server>` / the login tool),
241 // so the chip counts it under the shared auth-required label and only
242 // calls the rest failed (#5926).
243 let need_login = self
244 .servers
245 .iter()
246 .filter(|row| row.state == McpServerBootState::NeedsLogin)
247 .count();
248 let failed = self
249 .servers
250 .iter()
251 .filter(|row| row.state == McpServerBootState::Failed)
252 .count();
253 let connected = self
254 .servers
255 .iter()
256 .filter(|row| row.state == McpServerBootState::Connected)
257 .count();
258
259 if !connecting.is_empty() {
260 let count = connecting.len();
261 let named = named_chip_line("MCP", count, "connecting", &connecting);
262 return activity_notice_from_candidates(
263 SessionBootActivityLevel::Active,
264 vec![named, format!("MCP{ITEM_SEPARATOR}{count} connecting")],
265 budget,
266 );
267 }
268 if failed > 0 || need_login > 0 {
269 let auth_label = mcp_auth_required_state_label();
270 let mut full = format!(
271 "MCP{ITEM_SEPARATOR}{connected} {}",
272 tr(locale, MessageId::ExtensionsStateConnected)
273 );
274 // The narrow form drops the glyph and shortens the verb so both
275 // counts survive an 80-column footer.
276 let mut compact = String::from("MCP");
277 if need_login > 0 {
278 full.push_str(&format!("{ITEM_SEPARATOR}{need_login} {auth_label}"));
279 compact.push_str(&format!("{ITEM_SEPARATOR}{need_login} login"));
280 }
281 if failed > 0 {
282 full.push_str(&format!(
283 "{ITEM_SEPARATOR}{failed} {}",
284 tr(locale, MessageId::PhaseFailed)
285 ));
286 compact.push_str(&format!("{ITEM_SEPARATOR}{failed} failed"));
287 }
288 let level = if failed > 0 {
289 SessionBootActivityLevel::Failure
290 } else {
291 SessionBootActivityLevel::Attention
292 };
293 return activity_notice_from_candidates(level, vec![full, compact], budget);
294 }
295 if self.phase == SessionBootPhase::Booting {
296 let count = self.servers.len().max(self.unnamed_connecting);
297 if count > 0 {
298 return activity_notice_from_candidates(
299 SessionBootActivityLevel::Active,
300 vec![format!("MCP{ITEM_SEPARATOR}{count} connecting")],
301 budget,
302 );
303 }
304 }
305
306 if self.plugins.is_quiet() {
307 return None;
308 }
309
310 let plugins = tr(locale, MessageId::ExtensionsTabPlugins);
311 let problems = tr(locale, MessageId::ExtensionsGroupProblems);
312 let count = self.plugins.problem_count();
313 let level = if self.plugins.has_failures() {
314 SessionBootActivityLevel::Failure
315 } else {
316 SessionBootActivityLevel::Attention
317 };
318 activity_notice_from_candidates(
319 level,
320 vec![
321 format!("{plugins}{ITEM_SEPARATOR}{problems}: {count}{ITEM_SEPARATOR}/plugins"),
322 format!("{plugins}{ITEM_SEPARATOR}{problems}: {count}"),
323 format!("{plugins}{ITEM_SEPARATOR}{count}"),
324 ],
325 budget,
326 )
327 }
328 }
329
330 fn activity_notice_from_candidates(
331 level: SessionBootActivityLevel,
332 candidates: Vec<String>,
333 budget: usize,
334 ) -> Option<SessionBootActivityChip> {
335 candidates
336 .into_iter()
337 .find(|line| line.width() <= budget)
338 .map(|text| SessionBootActivityChip { text, level })
339 }
340
341 fn row_from_snapshot(
342 server: &McpServerSnapshot,
343 connecting: &[String],
344 ) -> Option<McpServerBootRow> {
345 if !server.enabled {
346 return Some(McpServerBootRow {
347 name: server.name.clone(),
348 state: McpServerBootState::Disabled,
349 });
350 }
351 if server.connected {
352 return Some(McpServerBootRow {
353 name: server.name.clone(),
354 state: McpServerBootState::Connected,
355 });
356 }
357 if let Some(error) = server.error.as_deref() {
358 let state = if server.auth_required || mcp_error_requires_login(error) {
359 McpServerBootState::NeedsLogin
360 } else {
361 McpServerBootState::Failed
362 };
363 return Some(McpServerBootRow {
364 name: server.name.clone(),
365 state,
366 });
367 }
368 if connecting.iter().any(|name| name == &server.name) {
369 return Some(McpServerBootRow {
370 name: server.name.clone(),
371 state: McpServerBootState::Connecting,
372 });
373 }
374 // Enabled, unconnected, no diagnosis, not in flight: a lazy server
375 // nobody has asked for yet (#6033). It is not boot activity, so it gets
376 // no row — calling it Failed or Connecting would both be lies.
377 None
378 }
379
380 /// Text fallback for the typed [`McpServerSnapshot::auth_required`] state:
381 /// the one shared auth-required classifier, plus the shape the session-boot
382 /// receipt itself prints.
383 #[must_use]
384 pub fn mcp_error_requires_login(error: &str) -> bool {
385 let lowered = error.to_ascii_lowercase();
386 crate::mcp::oauth::error_text_looks_auth_required(error)
387 || (lowered.contains("oauth") && lowered.contains("authenticat"))
388 }
389
390 /// The `◆ auth required` state label every TUI MCP surface prints for a
391 /// server whose login is missing, expired, or revoked.
392 #[must_use]
393 pub fn mcp_auth_required_state_label() -> String {
394 format!("{} auth required", crate::tui::glyphs::ATTENTION)
395 }
396
397 fn named_chip_line(kind: &str, count: usize, verb: &str, names: &[&str]) -> String {
398 let chips = names
399 .iter()
400 .take(MAX_NAMED_CHIPS)
401 .copied()
402 .collect::<Vec<_>>();
403 let extra = names.len().saturating_sub(chips.len());
404 let mut line = format!("{kind}{ITEM_SEPARATOR}{count} {verb}");
405 if !chips.is_empty() {
406 line.push_str(ITEM_SEPARATOR);
407 line.push_str(&chips.join(ITEM_SEPARATOR));
408 if extra > 0 {
409 line.push_str(&format!("{ITEM_SEPARATOR}+{extra}"));
410 }
411 }
412 line
413 }
414
415 #[cfg(test)]
416 mod tests {
417 use super::*;
418 use crate::mcp::{McpManagerSnapshot, McpServerCapabilityMetadata, McpServerSnapshot};
419 use std::path::PathBuf;
420
421 fn server(
422 name: &str,
423 enabled: bool,
424 connected: bool,
425 error: Option<&str>,
426 ) -> McpServerSnapshot {
427 McpServerSnapshot {
428 name: name.to_string(),
429 enabled,
430 required: false,
431 transport: "stdio".to_string(),
432 command_or_url: format!("cmd-{name}"),
433 connect_timeout: 5,
434 execute_timeout: 5,
435 read_timeout: 5,
436 connected,
437 error: error.map(str::to_string),
438 auth_required: false,
439 capability_metadata: McpServerCapabilityMetadata::NotObserved,
440 tools: Vec::new(),
441 resources: Vec::new(),
442 prompts: Vec::new(),
443 }
444 }
445
446 fn snapshot(servers: Vec<McpServerSnapshot>) -> McpManagerSnapshot {
447 McpManagerSnapshot {
448 config_path: PathBuf::from("mcp.json"),
449 config_exists: true,
450 reload_required: false,
451 servers,
452 }
453 }
454
455 #[test]
456 fn zero_servers_and_quiet_plugins_hide() {
457 let surface =
458 SessionBootSurface::from_parts(None, false, &[], 0, PluginBootSummary::default());
459 assert_eq!(surface.phase, SessionBootPhase::Hidden);
460 assert!(
461 surface
462 .activity_notice(Locale::En, 80)
463 .map(|notice| notice.text)
464 .is_none()
465 );
466 }
467
468 #[test]
469 fn healthy_loaded_plugins_do_not_claim_the_boot_surface() {
470 let surface = SessionBootSurface::from_parts(
471 None,
472 false,
473 &[],
474 0,
475 PluginBootSummary {
476 loaded: 2,
477 ..PluginBootSummary::default()
478 },
479 );
480 assert_eq!(surface.phase, SessionBootPhase::Hidden);
481 assert!(surface.activity_notice(Locale::En, 80).is_none());
482 }
483
484 #[test]
485 fn changed_plugin_content_requires_setup() {
486 assert!(plugin_trust_needs_setup(PluginTrustStatus::ContentChanged));
487 assert!(plugin_trust_needs_setup(PluginTrustStatus::NeverReviewed));
488 assert!(plugin_trust_needs_setup(
489 PluginTrustStatus::CapabilitiesChanged
490 ));
491 assert!(!plugin_trust_needs_setup(PluginTrustStatus::Trusted));
492 }
493
494 #[test]
495 fn plugin_problems_have_a_compact_footer_action() {
496 let surface = SessionBootSurface::from_parts(
497 None,
498 false,
499 &[],
500 0,
501 PluginBootSummary {
502 loaded: 3,
503 invalid: 1,
504 duplicate: 1,
505 needs_setup: 1,
506 },
507 );
508 assert_eq!(surface.phase, SessionBootPhase::Settled);
509 assert_eq!(
510 surface.activity_notice(Locale::En, 40),
511 Some(SessionBootActivityChip {
512 text: "Plugins · Problems: 3 · /plugins".to_string(),
513 level: SessionBootActivityLevel::Failure,
514 })
515 );
516 }
517
518 #[test]
519 fn plugin_review_notice_uses_attention_and_sheds_whole_fields() {
520 let surface = SessionBootSurface::from_parts(
521 None,
522 false,
523 &[],
524 0,
525 PluginBootSummary {
526 loaded: 1,
527 needs_setup: 1,
528 ..PluginBootSummary::default()
529 },
530 );
531 assert_eq!(
532 surface.activity_notice(Locale::En, 40),
533 Some(SessionBootActivityChip {
534 text: "Plugins · Problems: 1 · /plugins".to_string(),
535 level: SessionBootActivityLevel::Attention,
536 })
537 );
538 assert_eq!(
539 surface
540 .activity_notice(Locale::En, 22)
541 .map(|notice| notice.text)
542 .as_deref(),
543 Some("Plugins · Problems: 1")
544 );
545 assert_eq!(
546 surface
547 .activity_notice(Locale::En, 12)
548 .map(|notice| notice.text)
549 .as_deref(),
550 Some("Plugins · 1")
551 );
552 }
553
554 #[test]
555 fn mcp_activity_outranks_plugin_problems() {
556 let snap = snapshot(vec![server("alpha", true, false, None)]);
557 let surface = SessionBootSurface::from_parts(
558 Some(&snap),
559 true,
560 &["alpha".to_string()],
561 1,
562 PluginBootSummary {
563 invalid: 1,
564 ..PluginBootSummary::default()
565 },
566 );
567 assert_eq!(
568 surface.activity_notice(Locale::En, 80),
569 Some(SessionBootActivityChip {
570 text: "MCP · 1 connecting · alpha".to_string(),
571 level: SessionBootActivityLevel::Active,
572 })
573 );
574 }
575
576 #[test]
577 fn one_connecting_server_names_itself() {
578 let snap = snapshot(vec![server("alpha", true, false, None)]);
579 let surface = SessionBootSurface::from_parts(
580 Some(&snap),
581 true,
582 &["alpha".to_string()],
583 1,
584 PluginBootSummary::default(),
585 );
586 assert_eq!(surface.phase, SessionBootPhase::Booting);
587 assert_eq!(surface.servers.len(), 1);
588 assert_eq!(surface.servers[0].state, McpServerBootState::Connecting);
589 let chip = surface
590 .activity_notice(Locale::En, 80)
591 .map(|notice| notice.text)
592 .expect("chip");
593 assert!(chip.contains("alpha"), "{chip}");
594 assert!(!chip.to_ascii_lowercase().contains("slack"), "{chip}");
595 }
596
597 #[test]
598 fn many_connecting_servers_use_count_and_named_chips() {
599 let snap = snapshot(vec![
600 server("alpha", true, false, None),
601 server("beta", true, false, None),
602 server("gamma", true, false, None),
603 server("docs", true, false, None),
604 ]);
605 let connecting = ["alpha", "beta", "gamma", "docs"]
606 .into_iter()
607 .map(str::to_string)
608 .collect::<Vec<_>>();
609 let surface = SessionBootSurface::from_parts(
610 Some(&snap),
611 true,
612 &connecting,
613 4,
614 PluginBootSummary::default(),
615 );
616 assert_eq!(surface.phase, SessionBootPhase::Booting);
617 let chip = surface
618 .activity_notice(Locale::En, 80)
619 .map(|notice| notice.text)
620 .expect("chip");
621 assert!(chip.contains("4 connecting"), "{chip}");
622 assert!(chip.contains("alpha"), "{chip}");
623 assert!(chip.contains("docs"), "{chip}");
624 assert!(!chip.to_ascii_lowercase().contains("slack"), "{chip}");
625 }
626
627 #[test]
628 fn settled_failures_remain_classified_for_the_footer_chip() {
629 let snap = snapshot(vec![
630 server("alpha", true, true, None),
631 server("beta", true, false, Some("protocol negotiation timed out")),
632 server(
633 "gamma",
634 true,
635 false,
636 Some("MCP server 'gamma' requires OAuth authentication. Run `/mcp login gamma`"),
637 ),
638 server("docs", false, false, Some("disabled")),
639 ]);
640 let surface = SessionBootSurface::from_parts(
641 Some(&snap),
642 false,
643 &[],
644 4,
645 PluginBootSummary::default(),
646 );
647 assert_eq!(surface.phase, SessionBootPhase::Settled);
648 assert_eq!(
649 surface
650 .servers
651 .iter()
652 .find(|row| row.name == "beta")
653 .map(|row| row.state),
654 Some(McpServerBootState::Failed)
655 );
656 assert_eq!(
657 surface
658 .servers
659 .iter()
660 .find(|row| row.name == "gamma")
661 .map(|row| row.state),
662 Some(McpServerBootState::NeedsLogin)
663 );
664 }
665
666 #[test]
667 fn typed_auth_required_state_routes_to_login_without_error_text_sniffing() {
668 let mut delta = server("delta", true, false, Some("request rejected"));
669 delta.auth_required = true;
670 let snap = snapshot(vec![delta]);
671 let surface = SessionBootSurface::from_parts(
672 Some(&snap),
673 false,
674 &[],
675 1,
676 PluginBootSummary::default(),
677 );
678 assert_eq!(
679 surface
680 .servers
681 .iter()
682 .find(|row| row.name == "delta")
683 .map(|row| row.state),
684 Some(McpServerBootState::NeedsLogin)
685 );
686 // The compact activity chip names a needs-login server under the
687 // shared auth-required label, at attention level: the remedy is a
688 // login, not a repair (#5926).
689 assert_eq!(surface.servers.len(), 1);
690 let chip = surface
691 .activity_notice(Locale::En, 100)
692 .expect("needs-login must surface on the boot chip");
693 assert_eq!(chip.level, SessionBootActivityLevel::Attention);
694 assert_eq!(
695 chip.text,
696 format!(
697 "MCP{ITEM_SEPARATOR}0 connected{ITEM_SEPARATOR}1 {}",
698 mcp_auth_required_state_label()
699 )
700 );
701 assert!(!chip.text.contains("failed"), "{}", chip.text);
702 }
703
704 #[test]
705 fn chip_separates_expired_logins_from_real_failures() {
706 let mut expired = server("slack", true, false, Some("401 Unauthorized"));
707 expired.auth_required = true;
708 let snap = snapshot(vec![
709 server("alpha", true, true, None),
710 expired,
711 server("beta", true, false, Some("Stdio transport closed")),
712 ]);
713 let surface = SessionBootSurface::from_parts(
714 Some(&snap),
715 false,
716 &[],
717 3,
718 PluginBootSummary::default(),
719 );
720 let chip = surface
721 .activity_notice(Locale::En, 100)
722 .expect("mixed states surface on the boot chip");
723 assert_eq!(chip.level, SessionBootActivityLevel::Failure);
724 assert_eq!(
725 chip.text,
726 format!(
727 "MCP{ITEM_SEPARATOR}1 connected{ITEM_SEPARATOR}1 {}{ITEM_SEPARATOR}1 failed",
728 mcp_auth_required_state_label()
729 )
730 );
731 // Under a tight budget the compact form keeps both counts.
732 let compact = surface
733 .activity_notice(Locale::En, 30)
734 .expect("compact chip");
735 assert_eq!(
736 compact.text,
737 format!("MCP{ITEM_SEPARATOR}1 login{ITEM_SEPARATOR}1 failed")
738 );
739 }
740
741 #[test]
742 fn narrow_activity_budget_sheds_names_keeps_count() {
743 let snap = snapshot(vec![
744 server("alpha", true, false, None),
745 server("beta", true, false, None),
746 server("gamma", true, false, None),
747 ]);
748 let connecting = ["alpha", "beta", "gamma"]
749 .into_iter()
750 .map(str::to_string)
751 .collect::<Vec<_>>();
752 let surface = SessionBootSurface::from_parts(
753 Some(&snap),
754 true,
755 &connecting,
756 3,
757 PluginBootSummary::default(),
758 );
759 let chip = surface
760 .activity_notice(Locale::En, 22)
761 .map(|notice| notice.text)
762 .expect("chip");
763 assert_eq!(chip, "MCP · 3 connecting");
764 }
765
766 #[test]
767 fn first_frame_names_enabled_servers_before_a_snapshot_arrives() {
768 let connecting = ["gamma", "alpha", "docs"]
769 .into_iter()
770 .map(str::to_string)
771 .collect::<Vec<_>>();
772 let surface = SessionBootSurface::from_parts(
773 None,
774 true,
775 &connecting,
776 3,
777 PluginBootSummary::default(),
778 );
779 assert_eq!(surface.phase, SessionBootPhase::Booting);
780 assert_eq!(
781 surface
782 .servers
783 .iter()
784 .map(|row| row.name.as_str())
785 .collect::<Vec<_>>(),
786 vec!["alpha", "docs", "gamma"]
787 );
788 let chip = surface
789 .activity_notice(Locale::En, 80)
790 .map(|notice| notice.text)
791 .expect("chip");
792 assert!(chip.contains("3 connecting"), "{chip}");
793 assert!(chip.contains("alpha"), "{chip}");
794 assert!(chip.contains("gamma"), "{chip}");
795 assert!(!chip.to_ascii_lowercase().contains("slack"), "{chip}");
796 }
797
798 #[test]
799 fn initializing_without_names_still_shows_the_count() {
800 let surface =
801 SessionBootSurface::from_parts(None, true, &[], 4, PluginBootSummary::default());
802 assert_eq!(surface.phase, SessionBootPhase::Booting);
803 assert!(surface.servers.is_empty());
804 assert_eq!(
805 surface
806 .activity_notice(Locale::En, 80)
807 .map(|notice| notice.text)
808 .as_deref(),
809 Some("MCP · 4 connecting")
810 );
811 }
812 }
813
813 lines RUST