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