返回 CodeWhale
extensions.rs
根目录 / crates / tui / src / tui / views / extensions.rs
1 //! Unified inventory for Codewhale extensions.
2 //!
3 //! This is deliberately a projection over the existing owners of Hooks,
4 //! Plugins, Marketplace catalogs, Skills, and MCP. It has no registry, trust
5 //! database, installer, or network fetch of its own. Future actions emitted by
6 //! this view must delegate to the existing command/mutation controllers.
7 //! The skills mutation manager remains at `/skills manage`; MCP setup is a
8 //! read-only suggestions handoff, not an inline installer or credential editor.
9
10 use std::borrow::Cow;
11 use std::cell::RefCell;
12 use std::collections::BTreeSet;
13 use std::fmt::Write as _;
14
15 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
16 use ratatui::{
17 buffer::Buffer,
18 layout::{Constraint, Direction, Layout, Rect},
19 style::{Modifier, Style},
20 text::{Line, Span},
21 widgets::{Paragraph, Widget, Wrap},
22 };
23
24 use super::{
25 CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
26 render_underwater_surface, truncate_view_text,
27 };
28 use crate::tui::app::App;
29 use crate::tui::menu_style;
30 use codewhale_localization::{Locale, MessageId, tr};
31 use codewhale_palette as palette;
32
33 fn localize(locale: Locale, id: MessageId, replacements: &[(&str, &str)]) -> String {
34 let mut value = tr(locale, id).into_owned();
35 for (name, replacement) in replacements {
36 value = value.replace(&format!("{{{name}}}"), replacement);
37 }
38 value
39 }
40
41 /// All extension surfaces in display order.
42 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
43 pub enum ExtensionsTab {
44 Hooks,
45 Plugins,
46 Marketplace,
47 Skills,
48 Mcp,
49 }
50
51 impl ExtensionsTab {
52 pub const ALL: [Self; 5] = [
53 Self::Hooks,
54 Self::Plugins,
55 Self::Marketplace,
56 Self::Skills,
57 Self::Mcp,
58 ];
59
60 #[must_use]
61 fn label(self, locale: Locale) -> String {
62 match self {
63 Self::Hooks => tr(locale, MessageId::ExtensionsTabHooks),
64 Self::Plugins => tr(locale, MessageId::ExtensionsTabPlugins),
65 Self::Marketplace => tr(locale, MessageId::ExtensionsTabMarketplace),
66 Self::Skills => tr(locale, MessageId::HelpSkills),
67 Self::Mcp => tr(locale, MessageId::ConfigSectionMcp),
68 }
69 .into_owned()
70 }
71
72 const fn index(self) -> usize {
73 match self {
74 Self::Hooks => 0,
75 Self::Plugins => 1,
76 Self::Marketplace => 2,
77 Self::Skills => 3,
78 Self::Mcp => 4,
79 }
80 }
81
82 const fn next(self) -> Self {
83 Self::ALL[(self.index() + 1) % Self::ALL.len()]
84 }
85
86 const fn previous(self) -> Self {
87 Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()]
88 }
89 }
90
91 /// A real capability contributed by one plugin product.
92 ///
93 /// Recommendations use the same component vocabulary as installed plugin
94 /// bundles. An MCP, Skill, browser driver, or sandbox helper is therefore a
95 /// component of a product, not a parallel kind of install pretending to be a
96 /// complete plugin.
97 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
98 pub enum PluginProductComponentKind {
99 Mcp,
100 Skills,
101 BrowserDriver,
102 SandboxRuntime,
103 }
104
105 impl PluginProductComponentKind {
106 fn label(self, locale: Locale) -> String {
107 match self {
108 Self::Mcp => tr(locale, MessageId::ConfigSectionMcp),
109 Self::Skills => tr(locale, MessageId::HelpSkills),
110 Self::BrowserDriver => tr(locale, MessageId::ExtensionsComponentBrowserDriver),
111 Self::SandboxRuntime => tr(locale, MessageId::ExtensionsComponentSandboxRuntime),
112 }
113 .into_owned()
114 }
115 }
116
117 #[derive(Debug, Clone, PartialEq, Eq)]
118 pub struct PluginProductComponent {
119 pub kind: PluginProductComponentKind,
120 pub name: String,
121 }
122
123 /// Marketplace-facing recommendation model.
124 ///
125 /// `source_reference` is display provenance only. It is intentionally not an
126 /// install command or executable plan; explicit installation still enters the
127 /// reviewed plugin installer and trust flow.
128 #[derive(Debug, Clone, PartialEq, Eq)]
129 pub struct PluginProduct {
130 pub id: String,
131 pub name: String,
132 pub description: String,
133 pub publisher: String,
134 pub source_reference: String,
135 pub components: Vec<PluginProductComponent>,
136 pub maturity: String,
137 }
138
139 impl PluginProduct {
140 fn into_row(self, locale: Locale) -> ExtensionItem {
141 let mut components = String::new();
142 for (index, component) in self.components.iter().enumerate() {
143 if index > 0 {
144 components.push_str(", ");
145 }
146 let _ = write!(
147 components,
148 "{} ({})",
149 component.name,
150 component.kind.label(locale)
151 );
152 }
153 ExtensionItem {
154 id: self.id,
155 label: self.name,
156 tone: ExtensionTone::Idle,
157 description: self.description,
158 state: self.maturity,
159 detail: localize(
160 locale,
161 MessageId::ExtensionsProductDetail,
162 &[
163 ("publisher", &self.publisher),
164 ("components", &components),
165 ("source", &self.source_reference),
166 ],
167 ),
168 action: None,
169 toggle: None,
170 remove: None,
171 }
172 }
173 }
174
175 /// What a row's state *means*, independent of the words it uses to say it.
176 ///
177 /// Every row on this screen used to paint in one colour, so twenty servers,
178 /// four of them broken, read as one undifferentiated wall — "incredibly
179 /// boring, plain, and hard on the eyes because of the sameness". The tone is
180 /// typed rather than sniffed out of the localized state string, because a
181 /// screen that only colours correctly in English is not coloured.
182 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
183 pub enum ExtensionTone {
184 /// Working: connected, enabled, active.
185 Ready,
186 /// Wants a person: auth required, disconnected, not yet reviewed.
187 Attention,
188 /// Broken: an error or a rejected entry.
189 Failure,
190 /// Deliberately off, or simply not configured.
191 #[default]
192 Idle,
193 }
194
195 impl ExtensionTone {
196 fn ink(self) -> codewhale_palette::ChromeInk {
197 use codewhale_palette::ChromeInk;
198 match self {
199 Self::Ready => ChromeInk::Outcome,
200 Self::Attention => ChromeInk::Attention,
201 Self::Failure => ChromeInk::Failure,
202 Self::Idle => ChromeInk::Metadata,
203 }
204 }
205 }
206
207 #[derive(Debug, Clone, PartialEq, Eq)]
208 pub struct ExtensionItem {
209 pub id: String,
210 pub label: String,
211 pub description: String,
212 pub state: String,
213 /// Semantic reading of `state`, resolved through the theme's ink grammar.
214 pub tone: ExtensionTone,
215 pub detail: String,
216 pub action: Option<ExtensionAction>,
217 /// Reversible on/off toggle for the row (`e`): enable or disable a
218 /// plugin or MCP server without leaving the panel.
219 pub toggle: Option<ExtensionAction>,
220 /// Destructive removal for the row (`d` / Delete / right-click, armed and
221 /// confirmed in two steps). Only MCP servers offer it today; plugins keep
222 /// their reviewed uninstall flow.
223 pub remove: Option<ExtensionAction>,
224 }
225
226 /// Where a row's command lands when the user activates it.
227 ///
228 /// Every row used to close the panel and drop a slash command into the
229 /// transcript — inspecting a plugin closed the list and pasted its detail
230 /// into chat, and a mutation left every other row reading open-time state.
231 /// Only the row knows which its command is, so the disposition lives on the
232 /// action: mutations and inspects act in place, and flows that own a
233 /// different surface (an editor, OAuth login, a composer-bound trust token)
234 /// still yield the panel to them.
235 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
236 pub enum RowActionDisposition {
237 /// Run the command with the panel open; the host refreshes the snapshot
238 /// afterwards so every row re-reads live state.
239 InPlace,
240 /// In place, and the command's text output renders in a pager stacked on
241 /// the panel — the inspect path that keeps detail out of the transcript.
242 InPlacePager,
243 /// The command owns a different surface; the panel yields to it.
244 LeavePanel,
245 }
246
247 /// A row affordance. Executable actions route back through the existing slash
248 /// command controller; status-only actions explain why Enter will not mutate.
249 #[derive(Debug, Clone, PartialEq, Eq)]
250 pub enum ExtensionAction {
251 Command {
252 label: String,
253 command: String,
254 disposition: RowActionDisposition,
255 },
256 Status {
257 label: String,
258 },
259 }
260
261 impl ExtensionAction {
262 fn label(&self) -> &str {
263 match self {
264 Self::Command { label, .. } | Self::Status { label } => label,
265 }
266 }
267
268 fn command(&self) -> Option<&str> {
269 match self {
270 Self::Command { command, .. } => Some(command),
271 Self::Status { .. } => None,
272 }
273 }
274 }
275
276 #[derive(Debug, Clone, PartialEq, Eq)]
277 pub struct ExtensionGroup {
278 pub id: String,
279 pub label: String,
280 pub items: Vec<ExtensionItem>,
281 }
282
283 #[derive(Debug, Clone, Default, PartialEq, Eq)]
284 pub struct ExtensionsTabModel {
285 pub groups: Vec<ExtensionGroup>,
286 pub problem: Option<String>,
287 }
288
289 /// Read model captured when the modal opens. No source is contacted over the
290 /// network and no extension process is started while building it.
291 #[derive(Debug, Clone, Default, PartialEq, Eq)]
292 pub struct ExtensionsSnapshot {
293 tabs: [ExtensionsTabModel; 5],
294 /// MCP manager generation and initializing flag at capture time. The
295 /// open panel reports these on its bounded poll so the host rebuilds the
296 /// model only when live state actually moved.
297 pub mcp_generation: u64,
298 pub mcp_initializing: bool,
299 }
300
301 impl ExtensionsSnapshot {
302 #[must_use]
303 pub fn from_app(app: &App) -> Self {
304 let mut snapshot = Self {
305 mcp_generation: app.mcp_snapshot_generation,
306 mcp_initializing: app.mcp_initializing,
307 ..Self::default()
308 };
309 snapshot.tabs[ExtensionsTab::Hooks.index()] = hooks_model(app, app.ui_locale);
310 snapshot.tabs[ExtensionsTab::Plugins.index()] = plugins_model(app, app.ui_locale);
311 snapshot.tabs[ExtensionsTab::Marketplace.index()] = marketplace_model(app, app.ui_locale);
312 snapshot.tabs[ExtensionsTab::Skills.index()] = skills_model(app, app.ui_locale);
313 snapshot.tabs[ExtensionsTab::Mcp.index()] = mcp_model(app, app.ui_locale);
314 snapshot
315 .with_recommendations(reviewed_product_catalog(app.ui_locale), app.ui_locale)
316 .with_recommended_actions(app)
317 }
318
319 #[must_use]
320 pub fn with_recommendations(mut self, products: Vec<PluginProduct>, locale: Locale) -> Self {
321 if !products.is_empty() {
322 self.tabs[ExtensionsTab::Marketplace.index()].groups.insert(
323 0,
324 ExtensionGroup {
325 id: "recommended".into(),
326 label: tr(locale, MessageId::ExtensionsGroupRecommended).into_owned(),
327 items: products
328 .into_iter()
329 .map(|product| product.into_row(locale))
330 .collect(),
331 },
332 );
333 }
334 self
335 }
336
337 fn with_recommended_actions(mut self, app: &App) -> Self {
338 let configured = crate::mcp::load_config_with_workspace_and_plugins(
339 &app.mcp_config_path,
340 &app.workspace,
341 app.plugin_registry.as_ref(),
342 )
343 .ok();
344 let Some(group) = self.tabs[ExtensionsTab::Marketplace.index()]
345 .groups
346 .iter_mut()
347 .find(|group| group.id == "recommended")
348 else {
349 return self;
350 };
351
352 if configured.is_none() {
353 for item in &mut group.items {
354 item.action = Some(ExtensionAction::Status {
355 label: tr(app.ui_locale, MessageId::PickerActionUnavailable).into_owned(),
356 });
357 }
358 return self;
359 }
360
361 for item in &mut group.items {
362 // The first-party row is not an MCP recommendation: the plugin is
363 // already in the binary, so the row asks the registry what it
364 // wants — trust it, enable it, or open it — through the same
365 // ladder the Plugins tab uses.
366 if item.id == "codewhale-computer-use" {
367 item.action = match app
368 .plugin_registry
369 .list()
370 .into_iter()
371 .find(|plugin| plugin.name() == "computer-use")
372 {
373 Some(plugin) => {
374 item.state = localized_plugin_state(app.ui_locale, plugin.state_label());
375 Some(plugin_row_action(app.ui_locale, plugin))
376 }
377 None => Some(ExtensionAction::Status {
378 label: tr(app.ui_locale, MessageId::PickerActionUnavailable).into_owned(),
379 }),
380 };
381 continue;
382 }
383 let recommendation = match item.id.as_str() {
384 "playwright-browser" => Some(("playwright", "playwright")),
385 "chrome-devtools" => Some(("chrome-devtools", "chrome-devtools")),
386 _ => None,
387 };
388 if let Some((server_name, recommendation_id)) = recommendation {
389 match configured
390 .as_ref()
391 .and_then(|config| config.servers.get(server_name))
392 {
393 None => {
394 item.state =
395 tr(app.ui_locale, MessageId::ExtensionsStateAvailable).into_owned();
396 item.action = Some(ExtensionAction::Command {
397 label: tr(app.ui_locale, MessageId::ExtensionsActionAdd).into_owned(),
398 command: format!("/mcp add recommended {recommendation_id}"),
399 disposition: RowActionDisposition::InPlace,
400 });
401 }
402 Some(server) if !server.is_enabled() => {
403 item.state =
404 tr(app.ui_locale, MessageId::HotbarSetupStatusDisabled).into_owned();
405 item.action = Some(ExtensionAction::Command {
406 label: tr(app.ui_locale, MessageId::ExtensionsActionEnable)
407 .into_owned(),
408 command: format!("/mcp enable {server_name}"),
409 disposition: RowActionDisposition::InPlace,
410 });
411 }
412 Some(_) => {
413 item.state =
414 tr(app.ui_locale, MessageId::PickerActionConfigured).into_owned();
415 item.action = Some(ExtensionAction::Status {
416 label: tr(app.ui_locale, MessageId::PickerActionConfigured)
417 .into_owned(),
418 });
419 }
420 }
421 } else {
422 item.action = Some(ExtensionAction::Status {
423 label: tr(app.ui_locale, MessageId::PickerActionUnavailable).into_owned(),
424 });
425 }
426 }
427 self
428 }
429
430 fn tab(&self, tab: ExtensionsTab) -> &ExtensionsTabModel {
431 &self.tabs[tab.index()]
432 }
433 }
434
435 /// Pinned review metadata only. These rows do not contain install commands,
436 /// do not fetch anything, and do not grant trust. The source-specific plugin
437 /// manifests produced by the packaging lane remain the installation authority.
438 fn reviewed_product_catalog(locale: Locale) -> Vec<PluginProduct> {
439 vec![
440 // First-party computer use. This is the only computer-use product
441 // row: third-party desktop-control MCPs are not recommended here.
442 PluginProduct {
443 id: "codewhale-computer-use".into(),
444 name: "Computer Use".into(),
445 description: tr(
446 locale,
447 MessageId::ExtensionsProductCodewhaleComputerUseDescription,
448 )
449 .into_owned(),
450 publisher: "Codewhale".into(),
451 source_reference: "crates/tui/plugins/computer-use".into(),
452 components: vec![
453 PluginProductComponent {
454 kind: PluginProductComponentKind::Mcp,
455 name: "Computer Use MCP".into(),
456 },
457 PluginProductComponent {
458 kind: PluginProductComponentKind::Skills,
459 name: "Computer Use Skill".into(),
460 },
461 ],
462 maturity: tr(locale, MessageId::ExtensionsStateFirstParty).into_owned(),
463 },
464 PluginProduct {
465 id: "playwright-browser".into(),
466 name: "Playwright Browser".into(),
467 description: tr(locale, MessageId::ExtensionsProductPlaywrightDescription).into_owned(),
468 publisher: "Microsoft".into(),
469 source_reference: "microsoft/playwright-mcp".into(),
470 components: vec![
471 PluginProductComponent {
472 kind: PluginProductComponentKind::Mcp,
473 name: "Playwright MCP".into(),
474 },
475 PluginProductComponent {
476 kind: PluginProductComponentKind::BrowserDriver,
477 name: "Playwright browser driver".into(),
478 },
479 ],
480 maturity: tr(locale, MessageId::ExtensionsStateReviewedCandidate).into_owned(),
481 },
482 PluginProduct {
483 id: "chrome-devtools".into(),
484 name: "Chrome DevTools".into(),
485 description: tr(locale, MessageId::ExtensionsProductChromeDescription).into_owned(),
486 publisher: "Chrome DevTools".into(),
487 source_reference: "ChromeDevTools/chrome-devtools-mcp".into(),
488 components: vec![
489 PluginProductComponent {
490 kind: PluginProductComponentKind::Mcp,
491 name: "Chrome DevTools MCP".into(),
492 },
493 PluginProductComponent {
494 kind: PluginProductComponentKind::BrowserDriver,
495 name: "Chrome".into(),
496 },
497 ],
498 maturity: tr(locale, MessageId::ExtensionsStateReviewedCandidate).into_owned(),
499 },
500 PluginProduct {
501 id: "browser-use".into(),
502 name: "Browser Use".into(),
503 description: tr(locale, MessageId::ExtensionsProductBrowserUseDescription).into_owned(),
504 publisher: "Browser Use".into(),
505 source_reference: "browser-use/browser-use".into(),
506 components: vec![
507 PluginProductComponent {
508 kind: PluginProductComponentKind::Skills,
509 name: "Browser Use Skill".into(),
510 },
511 PluginProductComponent {
512 kind: PluginProductComponentKind::BrowserDriver,
513 name: "Browser Use runtime".into(),
514 },
515 ],
516 maturity: tr(locale, MessageId::ExtensionsStateReviewedCandidate).into_owned(),
517 },
518 PluginProduct {
519 id: "anthropic-sandbox-runtime".into(),
520 name: "Sandbox Runtime".into(),
521 description: tr(locale, MessageId::ExtensionsProductSandboxDescription).into_owned(),
522 publisher: "Anthropic Experimental".into(),
523 source_reference: "anthropic-experimental/sandbox-runtime".into(),
524 components: vec![PluginProductComponent {
525 kind: PluginProductComponentKind::SandboxRuntime,
526 name: "Sandbox Runtime".into(),
527 }],
528 maturity: tr(locale, MessageId::ExtensionsStateBetaCandidate).into_owned(),
529 },
530 ]
531 }
532
533 fn hooks_model(app: &App, locale: Locale) -> ExtensionsTabModel {
534 let config = app.hooks.config();
535 let configured = config
536 .hooks
537 .iter()
538 .enumerate()
539 .map(|(index, hook)| ExtensionItem {
540 id: format!("hook-{index}"),
541 tone: if config.enabled {
542 ExtensionTone::Ready
543 } else {
544 ExtensionTone::Idle
545 },
546 label: hook.name.clone().unwrap_or_else(|| {
547 localize(
548 locale,
549 MessageId::ExtensionsHookFallback,
550 &[("event", hook.event.as_str())],
551 )
552 }),
553 description: hook.event.as_str().to_string(),
554 state: if config.enabled {
555 tr(locale, MessageId::ExtensionsStateEnabled)
556 } else {
557 tr(locale, MessageId::HotbarSetupStatusDisabled)
558 }
559 .into_owned(),
560 detail: localize(
561 locale,
562 MessageId::ExtensionsHookDetail,
563 &[
564 ("timeout", &hook.timeout_secs.to_string()),
565 ("background", &localized_bool(locale, hook.background)),
566 (
567 "continue_on_error",
568 &localized_bool(locale, hook.continue_on_error),
569 ),
570 ],
571 ),
572 action: Some(ExtensionAction::Command {
573 label: tr(locale, MessageId::ExtensionsActionEdit).into_owned(),
574 command: "/hooks edit".into(),
575 disposition: RowActionDisposition::LeavePanel,
576 }),
577 toggle: None,
578 remove: None,
579 })
580 .collect::<Vec<_>>();
581 let problems = config
582 .problems
583 .iter()
584 .enumerate()
585 .map(|(index, problem)| ExtensionItem {
586 id: format!("hook-problem-{index}"),
587 tone: if problem.rejected {
588 ExtensionTone::Failure
589 } else {
590 ExtensionTone::Attention
591 },
592 label: problem.name.clone().unwrap_or_else(|| {
593 tr(locale, MessageId::ExtensionsHooksConfiguration).into_owned()
594 }),
595 description: problem.detail.clone(),
596 state: if problem.rejected {
597 tr(locale, MessageId::ExtensionsStateRejected)
598 } else {
599 tr(locale, MessageId::ExtensionsStateWarning)
600 }
601 .into_owned(),
602 detail: problem.summary(),
603 action: Some(ExtensionAction::Command {
604 label: tr(locale, MessageId::ExtensionsActionEdit).into_owned(),
605 command: "/hooks edit".into(),
606 disposition: RowActionDisposition::LeavePanel,
607 }),
608 toggle: None,
609 remove: None,
610 })
611 .collect::<Vec<_>>();
612 let mut groups = Vec::new();
613 if !configured.is_empty() {
614 groups.push(ExtensionGroup {
615 id: "configured".into(),
616 label: tr(locale, MessageId::ExtensionsGroupConfigured).into_owned(),
617 items: configured,
618 });
619 }
620 if !problems.is_empty() {
621 groups.push(ExtensionGroup {
622 id: "problems".into(),
623 label: tr(locale, MessageId::ExtensionsGroupProblems).into_owned(),
624 items: problems,
625 });
626 }
627 // A screen with nothing on it and nothing to press is where "need to be
628 // able to add hooks!" comes from. The row that teaches the file is the
629 // row that opens it.
630 if groups.is_empty() {
631 groups.push(ExtensionGroup {
632 id: "start".into(),
633 label: tr(locale, MessageId::ExtensionsGroupConfigured).into_owned(),
634 items: vec![ExtensionItem {
635 id: "hooks-add".into(),
636 tone: ExtensionTone::Idle,
637 label: tr(locale, MessageId::ExtensionsHooksAddLabel).into_owned(),
638 description: tr(locale, MessageId::ExtensionsHooksAddDescription).into_owned(),
639 state: tr(locale, MessageId::ExtensionsStateAvailable).into_owned(),
640 detail: ".codewhale/hooks.toml".into(),
641 action: Some(ExtensionAction::Command {
642 label: tr(locale, MessageId::ExtensionsActionEdit).into_owned(),
643 command: "/hooks edit".into(),
644 disposition: RowActionDisposition::LeavePanel,
645 }),
646 toggle: None,
647 remove: None,
648 }],
649 });
650 }
651 ExtensionsTabModel {
652 groups,
653 problem: None,
654 }
655 }
656
657 fn inventory_summary(
658 inventory: &crate::plugins::manifest::PluginInventory,
659 locale: Locale,
660 ) -> String {
661 let mut parts = Vec::new();
662 if inventory.skills > 0 {
663 parts.push(localize(
664 locale,
665 MessageId::ExtensionsInventorySkills,
666 &[("count", &inventory.skills.to_string())],
667 ));
668 }
669 if inventory.mcp_servers > 0 {
670 parts.push(localize(
671 locale,
672 MessageId::ExtensionsInventoryMcp,
673 &[("count", &inventory.mcp_servers.to_string())],
674 ));
675 }
676 if inventory.hooks > 0 {
677 parts.push(localize(
678 locale,
679 MessageId::ExtensionsInventoryHooks,
680 &[("count", &inventory.hooks.to_string())],
681 ));
682 }
683 if inventory.commands > 0 {
684 parts.push(localize(
685 locale,
686 MessageId::ExtensionsInventoryCommands,
687 &[("count", &inventory.commands.to_string())],
688 ));
689 }
690 if inventory.agents > 0 {
691 parts.push(localize(
692 locale,
693 MessageId::ExtensionsInventoryAgents,
694 &[("count", &inventory.agents.to_string())],
695 ));
696 }
697 if parts.is_empty() {
698 tr(locale, MessageId::ExtensionsInventoryNone).into_owned()
699 } else {
700 parts.join(", ")
701 }
702 }
703
704 fn localized_bool(locale: Locale, value: bool) -> String {
705 tr(
706 locale,
707 if value {
708 MessageId::ExtensionsValueYes
709 } else {
710 MessageId::ExtensionsValueNo
711 },
712 )
713 .into_owned()
714 }
715
716 fn localized_plugin_state(locale: Locale, state: &str) -> String {
717 let id = match state {
718 "active" => MessageId::CtxInspActive,
719 "disabled" => MessageId::HotbarSetupStatusDisabled,
720 "enabled-untrusted" => MessageId::ExtensionsStateEnabledUntrusted,
721 "unstaged" => MessageId::ExtensionsStateUnstaged,
722 "inapplicable" => MessageId::ExtensionsStateInapplicable,
723 "unsupported" => MessageId::ExtensionsStateUnsupported,
724 "inactive" => MessageId::ExtensionsStateInactive,
725 _ => return state.to_string(),
726 };
727 tr(locale, id).into_owned()
728 }
729
730 fn localized_trust(locale: Locale, trust: &str) -> String {
731 let id = match trust {
732 "trusted" => MessageId::ExtensionsTrustTrusted,
733 "not-reviewed" => MessageId::ExtensionsTrustNotReviewed,
734 "content-changed" => MessageId::ExtensionsTrustContentChanged,
735 "capabilities-changed" => MessageId::ExtensionsTrustCapabilitiesChanged,
736 _ => return trust.to_string(),
737 };
738 tr(locale, id).into_owned()
739 }
740
741 fn localized_compatibility(locale: Locale, compatibility: &str) -> String {
742 let id = match compatibility {
743 "full" => MessageId::ExtensionsCompatibilityFull,
744 "partial" => MessageId::ExtensionsCompatibilityPartial,
745 "unsupported" => MessageId::ExtensionsStateUnsupported,
746 _ => return compatibility.to_string(),
747 };
748 tr(locale, id).into_owned()
749 }
750
751 fn localized_tier(locale: Locale, tier: &str) -> String {
752 let id = match tier {
753 "community" => MessageId::ExtensionsTierCommunity,
754 "official" => MessageId::ExtensionsTierOfficial,
755 "curated" => MessageId::ExtensionsTierCurated,
756 "partner" => MessageId::ExtensionsTierPartner,
757 _ => return tier.to_string(),
758 };
759 tr(locale, id).into_owned()
760 }
761
762 fn localized_skill_root(locale: Locale, kind: crate::skills::roots::SkillRootKind) -> String {
763 use crate::skills::roots::SkillRootKind;
764
765 match kind {
766 SkillRootKind::CodeWhaleProject => {
767 tr(locale, MessageId::ExtensionsSkillRootProject).into_owned()
768 }
769 SkillRootKind::CodeWhaleGlobal => {
770 tr(locale, MessageId::ExtensionsSkillRootGlobal).into_owned()
771 }
772 SkillRootKind::CompatibleProject(harness) => localize(
773 locale,
774 MessageId::ExtensionsSkillRootCompatibleProject,
775 &[("harness", harness.label())],
776 ),
777 SkillRootKind::CompatibleGlobal(harness) => localize(
778 locale,
779 MessageId::ExtensionsSkillRootCompatibleGlobal,
780 &[("harness", harness.label())],
781 ),
782 SkillRootKind::Configured => {
783 tr(locale, MessageId::ExtensionsSkillRootConfigured).into_owned()
784 }
785 SkillRootKind::BuiltIn => tr(locale, MessageId::ExtensionsGroupBuiltIn).into_owned(),
786 SkillRootKind::ReviewedPluginSnapshot => {
787 tr(locale, MessageId::ExtensionsSkillRootReviewedPlugin).into_owned()
788 }
789 SkillRootKind::RegistryCache => {
790 tr(locale, MessageId::ExtensionsSkillRootRegistryCache).into_owned()
791 }
792 }
793 }
794
795 /// The one action a plugin row offers, wherever that row is drawn.
796 ///
797 /// The Plugins tab and the marketplace's first-party row both need "what does
798 /// this plugin want from me right now?", and a second copy of the ladder is
799 /// how the marketplace ends up offering `Enable` for something already active.
800 fn plugin_row_action(
801 locale: Locale,
802 plugin: &crate::plugins::types::LoadedPlugin,
803 ) -> ExtensionAction {
804 let has_error_diagnostics = plugin
805 .diagnostics
806 .iter()
807 .any(|diagnostic| diagnostic.level == crate::plugins::types::PluginDiagnosticLevel::Error);
808 if has_error_diagnostics {
809 ExtensionAction::Command {
810 label: tr(locale, MessageId::ExtensionsActionDiagnose).into_owned(),
811 command: format!("/plugin validate {}", plugin.name()),
812 disposition: RowActionDisposition::InPlacePager,
813 }
814 } else if plugin.active() {
815 ExtensionAction::Command {
816 label: tr(locale, MessageId::LaunchHintOpen).into_owned(),
817 command: format!("/plugin show {}", plugin.name()),
818 disposition: RowActionDisposition::InPlacePager,
819 }
820 } else if plugin.trusted() && !plugin.enabled {
821 ExtensionAction::Command {
822 label: tr(locale, MessageId::ExtensionsActionEnable).into_owned(),
823 command: format!("/plugin enable {}", plugin.name()),
824 disposition: RowActionDisposition::InPlace,
825 }
826 } else {
827 // The command opens the exact-content review with its confirmation
828 // control, so this panel yields to that review.
829 ExtensionAction::Command {
830 label: tr(locale, MessageId::AutomationActionInspect).into_owned(),
831 command: format!("/plugin trust {}", plugin.name()),
832 disposition: RowActionDisposition::LeavePanel,
833 }
834 }
835 }
836
837 /// The reversible on/off control for a plugin row. A trusted plugin can be
838 /// switched off and on from the panel; an untrusted one still goes through
839 /// its reviewed trust flow first, so no toggle is offered.
840 fn plugin_row_toggle(
841 locale: Locale,
842 plugin: &crate::plugins::types::LoadedPlugin,
843 ) -> Option<ExtensionAction> {
844 if !plugin.trusted() {
845 return None;
846 }
847 Some(if plugin.enabled {
848 ExtensionAction::Command {
849 // English fallback until the Extensions vocabulary gains a
850 // localized "disable" (#3167 tracks the panel's localization).
851 label: "disable".into(),
852 command: format!("/plugin disable {}", plugin.name()),
853 disposition: RowActionDisposition::InPlace,
854 }
855 } else {
856 ExtensionAction::Command {
857 label: tr(locale, MessageId::ExtensionsActionEnable).into_owned(),
858 command: format!("/plugin enable {}", plugin.name()),
859 disposition: RowActionDisposition::InPlace,
860 }
861 })
862 }
863
864 /// How a plugin row reads, using the same ladder as [`plugin_row_action`].
865 fn plugin_row_tone(plugin: &crate::plugins::types::LoadedPlugin) -> ExtensionTone {
866 let has_error_diagnostics = plugin
867 .diagnostics
868 .iter()
869 .any(|diagnostic| diagnostic.level == crate::plugins::types::PluginDiagnosticLevel::Error);
870 if has_error_diagnostics {
871 ExtensionTone::Failure
872 } else if plugin.active() {
873 ExtensionTone::Ready
874 } else if plugin.trusted() {
875 // Trusted and deliberately disabled: off, not wrong.
876 ExtensionTone::Idle
877 } else {
878 // Untrusted is not broken either; it is waiting on a person.
879 ExtensionTone::Attention
880 }
881 }
882
883 fn plugins_model(app: &App, locale: Locale) -> ExtensionsTabModel {
884 let mut by_scope = [Vec::new(), Vec::new(), Vec::new()];
885 for plugin in app.plugin_registry.list() {
886 let scope = match plugin.scope {
887 crate::plugins::types::PluginScope::Builtin => 0,
888 crate::plugins::types::PluginScope::User => 1,
889 crate::plugins::types::PluginScope::Workspace => 2,
890 };
891 let diagnostic_count = plugin.diagnostics.len();
892 let action = plugin_row_action(locale, plugin);
893 let toggle = plugin_row_toggle(locale, plugin);
894 by_scope[scope].push(ExtensionItem {
895 id: plugin.id.as_str().to_string(),
896 tone: plugin_row_tone(plugin),
897 label: plugin.name().to_string(),
898 description: plugin
899 .manifest
900 .plugin
901 .description
902 .clone()
903 .unwrap_or_else(|| inventory_summary(&plugin.inventory, locale)),
904 state: localized_plugin_state(locale, plugin.state_label()),
905 detail: localize(
906 locale,
907 MessageId::ExtensionsPluginDetail,
908 &[
909 ("inventory", &inventory_summary(&plugin.inventory, locale)),
910 (
911 "trust",
912 &localized_trust(locale, plugin.trust_status.as_str()),
913 ),
914 (
915 "compatibility",
916 &localized_compatibility(locale, plugin.compatibility().as_str()),
917 ),
918 ("diagnostics", &diagnostic_count.to_string()),
919 ],
920 ),
921 action: Some(action),
922 toggle,
923 remove: None,
924 });
925 }
926 let labels = [
927 (
928 "builtin",
929 tr(locale, MessageId::ExtensionsGroupBuiltIn).into_owned(),
930 ),
931 (
932 "user",
933 tr(locale, MessageId::ExtensionsGroupUser).into_owned(),
934 ),
935 (
936 "workspace",
937 tr(locale, MessageId::ExtensionsGroupWorkspace).into_owned(),
938 ),
939 ];
940 let mut groups = labels
941 .into_iter()
942 .zip(by_scope)
943 .filter(|(_, items)| !items.is_empty())
944 .map(|((id, label), items)| ExtensionGroup {
945 id: id.into(),
946 label,
947 items,
948 })
949 .collect::<Vec<_>>();
950 let problems = app
951 .plugin_registry
952 .diagnostics()
953 .iter()
954 .enumerate()
955 .map(|(index, diagnostic)| ExtensionItem {
956 id: format!("plugin-diagnostic-{index}"),
957 tone: ExtensionTone::Failure,
958 label: diagnostic.code.to_string(),
959 description: diagnostic.message.clone(),
960 state: if diagnostic.level == crate::plugins::types::PluginDiagnosticLevel::Error {
961 tr(locale, MessageId::ExtensionsStateInvalid)
962 } else {
963 tr(locale, MessageId::ExtensionsStateWarning)
964 }
965 .into_owned(),
966 detail: diagnostic
967 .path
968 .as_ref()
969 .map(|path| path.display().to_string())
970 .unwrap_or_else(|| diagnostic.message.clone()),
971 action: Some(ExtensionAction::Command {
972 label: tr(locale, MessageId::ExtensionsActionDiagnose).into_owned(),
973 command: "/plugin validate".into(),
974 disposition: RowActionDisposition::InPlacePager,
975 }),
976 toggle: None,
977 remove: None,
978 })
979 .collect::<Vec<_>>();
980 if !problems.is_empty() {
981 groups.push(ExtensionGroup {
982 id: "problems".into(),
983 label: tr(locale, MessageId::ExtensionsGroupProblems).into_owned(),
984 items: problems,
985 });
986 }
987 ExtensionsTabModel {
988 groups,
989 problem: app.plugin_registry.state_error().map(ToString::to_string),
990 }
991 }
992
993 fn marketplace_model(app: &App, locale: Locale) -> ExtensionsTabModel {
994 use crate::plugins::marketplace::document::{
995 CatalogInstallResolution, resolve_candidate_install,
996 };
997 let Some(store) = crate::plugins::marketplace::store::MarketplaceStore::open(
998 app.plugin_registry.state_path(),
999 ) else {
1000 return ExtensionsTabModel {
1001 groups: Vec::new(),
1002 problem: Some(tr(locale, MessageId::ExtensionsMarketplaceUnavailable).into_owned()),
1003 };
1004 };
1005 let state = match store.load() {
1006 Ok(state) => state,
1007 Err(error) => {
1008 return ExtensionsTabModel {
1009 groups: Vec::new(),
1010 problem: Some(error),
1011 };
1012 }
1013 };
1014 let groups = state
1015 .catalogs()
1016 .values()
1017 .map(|stored| {
1018 let catalog = &stored.catalog;
1019 ExtensionGroup {
1020 id: catalog.id.as_str().to_string(),
1021 label: catalog
1022 .display_name
1023 .clone()
1024 .unwrap_or_else(|| catalog.name.clone()),
1025 items: catalog
1026 .candidates
1027 .iter()
1028 .map(|candidate| {
1029 let resolution =
1030 resolve_candidate_install(stored, candidate, &app.plugin_registry);
1031 if let CatalogInstallResolution::AlreadyPresent { plugin, .. } = &resolution
1032 {
1033 // A catalog name match is only occupancy. Show and
1034 // review the actual local bundle, not catalog claims.
1035 return ExtensionItem {
1036 id: candidate.id.as_str().to_string(),
1037 tone: plugin_row_tone(plugin),
1038 label: plugin.name().to_string(),
1039 description: plugin
1040 .manifest
1041 .plugin
1042 .description
1043 .clone()
1044 .unwrap_or_default(),
1045 state: if plugin.scope
1046 == crate::plugins::types::PluginScope::Builtin
1047 {
1048 tr(locale, MessageId::ExtensionsStateFirstParty).into_owned()
1049 } else {
1050 localized_plugin_state(locale, plugin.state_label())
1051 },
1052 detail: plugin.canonical_root.display().to_string(),
1053 action: Some(plugin_row_action(locale, plugin)),
1054 toggle: None,
1055 remove: None,
1056 };
1057 }
1058 let installable =
1059 matches!(resolution, CatalogInstallResolution::Supported { .. });
1060 ExtensionItem {
1061 id: candidate.id.as_str().to_string(),
1062 tone: ExtensionTone::Attention,
1063 label: candidate
1064 .display_name
1065 .clone()
1066 .unwrap_or_else(|| candidate.name.clone()),
1067 description: candidate.description.clone().unwrap_or_default(),
1068 state: if candidate.has_errors() {
1069 tr(locale, MessageId::ExtensionsStateInvalid)
1070 } else if installable {
1071 tr(locale, MessageId::ExtensionsStateAvailable)
1072 } else {
1073 tr(locale, MessageId::AutomationActionInspect)
1074 }
1075 .into_owned(),
1076 detail: {
1077 let unknown = tr(locale, MessageId::CmdCostUnknownValue);
1078 localize(
1079 locale,
1080 MessageId::ExtensionsMarketplaceDetail,
1081 &[
1082 (
1083 "publisher",
1084 candidate
1085 .provenance
1086 .publisher
1087 .as_deref()
1088 .unwrap_or(unknown.as_ref()),
1089 ),
1090 (
1091 "tier",
1092 &localized_tier(
1093 locale,
1094 candidate.provenance.tier.as_str(),
1095 ),
1096 ),
1097 ("installable", &localized_bool(locale, installable)),
1098 ],
1099 )
1100 },
1101 action: if installable {
1102 Some(ExtensionAction::Command {
1103 label: tr(locale, MessageId::ExtensionsActionAdd).into_owned(),
1104 command: format!(
1105 "/plugin marketplace install {} {}",
1106 catalog.id.as_str(),
1107 candidate.name
1108 ),
1109 disposition: RowActionDisposition::InPlace,
1110 })
1111 } else {
1112 Some(ExtensionAction::Status {
1113 label: tr(locale, MessageId::PickerActionUnavailable)
1114 .into_owned(),
1115 })
1116 },
1117 toggle: None,
1118 remove: None,
1119 }
1120 })
1121 .collect(),
1122 }
1123 })
1124 .collect();
1125 ExtensionsTabModel {
1126 groups,
1127 problem: None,
1128 }
1129 }
1130
1131 fn skills_model(app: &App, locale: Locale) -> ExtensionsTabModel {
1132 use crate::skills::audit::{ParserState, SkillAuditMode, scan_with_configured};
1133
1134 let home = crate::config::effective_home_dir();
1135 let audit = scan_with_configured(
1136 &app.workspace,
1137 home.as_deref(),
1138 Some(&app.skills_dir),
1139 SkillAuditMode::OwnedOnly,
1140 None,
1141 );
1142 let mut groups = Vec::<ExtensionGroup>::new();
1143 for skill in audit.skills {
1144 let group_id = format!("{:?}", skill.root.kind);
1145 let position = groups.iter().position(|group| group.id == group_id);
1146 let item = ExtensionItem {
1147 id: format!("{}:{}", group_id, skill.id.canonical_name),
1148 tone: ExtensionTone::Ready,
1149 label: skill.name,
1150 description: skill.description.unwrap_or_default(),
1151 state: match skill.parser {
1152 ParserState::Valid => tr(locale, MessageId::HotbarSetupStatusReady),
1153 ParserState::Warning(_) => tr(locale, MessageId::ExtensionsStateWarning),
1154 ParserState::Broken(_) | ParserState::Oversized => {
1155 tr(locale, MessageId::ExtensionsStateInvalid)
1156 }
1157 }
1158 .into_owned(),
1159 detail: skill.safe_display_path,
1160 // Enter opens the skills manager, which is where install, update,
1161 // remove and trust already live (`views/skills_manager.rs`, 1,000
1162 // lines, driving `skills::mutation::SkillMutationRequest`). This
1163 // tab used to dead-end on `action: None` — founder live-test:
1164 // "skills - no way to delete them or edit or anything either" —
1165 // even though the manager it needed was one command away. Routing
1166 // rather than reimplementing: the mutation authority stays in one
1167 // place.
1168 action: Some(ExtensionAction::Command {
1169 label: tr(locale, MessageId::ExtensionsActionManage).into_owned(),
1170 command: "/skills manage".into(),
1171 disposition: RowActionDisposition::LeavePanel,
1172 }),
1173 toggle: None,
1174 remove: None,
1175 };
1176 if let Some(position) = position {
1177 groups[position].items.push(item);
1178 } else {
1179 groups.push(ExtensionGroup {
1180 id: group_id.clone(),
1181 label: localized_skill_root(locale, skill.root.kind),
1182 items: vec![item],
1183 });
1184 }
1185 }
1186 ExtensionsTabModel {
1187 groups,
1188 problem: None,
1189 }
1190 }
1191
1192 /// Whether a listed MCP row belongs to the user's own config, and may
1193 /// therefore be removed or toggled from the Extensions panel.
1194 ///
1195 /// `owned` is the set of servers in the user's config without plugin
1196 /// contributions; `None` means that config could not be read, in which case
1197 /// ownership is unknown and the gestures are kept rather than silently
1198 /// withdrawn. Plugin-contributed servers are never in that set: their names are
1199 /// synthesized and `/mcp remove` resolves against the config file, so offering
1200 /// the gesture produced a guaranteed "server not found".
1201 fn mcp_row_is_mutable(owned: Option<&BTreeSet<String>>, name: &str) -> bool {
1202 owned.is_none_or(|owned| owned.contains(name))
1203 }
1204
1205 fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
1206 let configured = crate::mcp::load_config_with_workspace_and_plugins(
1207 &app.mcp_config_path,
1208 &app.workspace,
1209 app.plugin_registry.as_ref(),
1210 )
1211 .ok();
1212 // The rows above are the union of the user's config and every plugin's
1213 // contribution. Only the user's own servers can be removed or toggled: a
1214 // plugin server's name is synthesized (`plugin-{len}-{plugin}-{server}`)
1215 // and never appears in the config file `/mcp remove` resolves against, so
1216 // offering the gesture there was a guaranteed 404. Derive the set by
1217 // loading the same config without plugin contributions and taking the
1218 // difference, rather than parsing the shape of the synthesized name.
1219 let user_owned: Option<BTreeSet<String>> =
1220 crate::mcp::load_config_with_workspace(&app.mcp_config_path, &app.workspace)
1221 .ok()
1222 .map(|config| config.servers.keys().cloned().collect());
1223 let snapshot = app.mcp_snapshot.as_ref();
1224 // Configured names are the count authority used by the surrounding shell.
1225 // Snapshot data enriches those exact rows; it must never independently
1226 // filter the list down to only the last discovered subset.
1227 let names = configured.as_ref().map_or_else(
1228 || {
1229 snapshot
1230 .into_iter()
1231 .flat_map(|snapshot| snapshot.servers.iter().map(|server| server.name.clone()))
1232 .collect::<BTreeSet<_>>()
1233 },
1234 |config| config.servers.keys().cloned().collect::<BTreeSet<_>>(),
1235 );
1236 let total = names.len();
1237 let items: Vec<_> = names
1238 .into_iter()
1239 .map(|name| {
1240 let observed = snapshot
1241 .and_then(|snapshot| snapshot.servers.iter().find(|server| server.name == name));
1242 let config = configured
1243 .as_ref()
1244 .and_then(|configured| configured.servers.get(&name));
1245 let enabled = observed
1246 .map(|server| server.enabled)
1247 .or_else(|| config.map(crate::mcp::McpServerConfig::is_enabled))
1248 .unwrap_or(true);
1249 // `connecting` is the engine's real in-flight set (#6033): under
1250 // lazy boot a configured-but-unstarted server reads "configured",
1251 // never "connecting".
1252 let initializing = enabled
1253 && app
1254 .mcp_connecting
1255 .iter()
1256 .any(|connecting| connecting == &name)
1257 && observed.is_none_or(|server| !server.connected && server.error.is_none());
1258 let state = if !enabled {
1259 tr(locale, MessageId::HotbarSetupStatusDisabled)
1260 } else if initializing {
1261 Cow::Borrowed("connecting")
1262 } else if observed.is_some_and(|server| server.connected) {
1263 tr(locale, MessageId::ExtensionsStateConnected)
1264 } else if observed.is_some_and(|server| server.auth_required) {
1265 Cow::Owned(crate::tui::session_boot::mcp_auth_required_state_label())
1266 } else if observed.is_some_and(|server| server.error.is_some()) {
1267 tr(locale, MessageId::ExtensionsStateError)
1268 } else if observed.is_none() {
1269 tr(locale, MessageId::ExtensionsStateNotInspected)
1270 } else {
1271 tr(locale, MessageId::PickerActionConfigured)
1272 }
1273 .into_owned();
1274 let oauth_capable = config.is_some_and(crate::mcp::mcp_server_oauth_capable);
1275 let recovery = match observed {
1276 Some(server) => server.recovery_kind(oauth_capable),
1277 None => crate::mcp::mcp_recovery_kind(enabled, false, false, None, oauth_capable),
1278 };
1279 let action = match (initializing, recovery) {
1280 // Still connecting: the state is the whole story.
1281 (true, _) => ExtensionAction::Status {
1282 label: state.clone(),
1283 },
1284 // Healthy. A row that needs nothing offers nothing — the
1285 // actionable rows are the ones worth finding in a list of 20.
1286 (false, None) => ExtensionAction::Status {
1287 label: state.clone(),
1288 },
1289 (false, Some(recovery))
1290 if crate::mcp::mcp_name_is_command_safe(&name)
1291 || matches!(
1292 recovery,
1293 crate::mcp::McpRecoveryKind::Connect
1294 | crate::mcp::McpRecoveryKind::Reconnect
1295 | crate::mcp::McpRecoveryKind::Diagnose
1296 ) =>
1297 {
1298 ExtensionAction::Command {
1299 label: tr(locale, recovery.label_key()).into_owned(),
1300 command: recovery.slash_command(&name),
1301 // Re-auth hands off to the OAuth login flow; every
1302 // other recovery runs against live state the panel
1303 // re-reads when it lands.
1304 disposition: match recovery {
1305 crate::mcp::McpRecoveryKind::Reauth => RowActionDisposition::LeavePanel,
1306 _ => RowActionDisposition::InPlace,
1307 },
1308 }
1309 }
1310 (false, Some(_)) => ExtensionAction::Command {
1311 label: tr(locale, MessageId::ExtensionsActionDiagnose).into_owned(),
1312 command: "/mcp validate".into(),
1313 disposition: RowActionDisposition::InPlace,
1314 },
1315 };
1316 let command_safe = crate::mcp::mcp_name_is_command_safe(&name);
1317 let mutable = command_safe && mcp_row_is_mutable(user_owned.as_ref(), &name);
1318 let toggle = mutable.then(|| {
1319 if enabled {
1320 ExtensionAction::Command {
1321 label: "disable".into(),
1322 command: format!("/mcp disable {name}"),
1323 disposition: RowActionDisposition::InPlace,
1324 }
1325 } else {
1326 ExtensionAction::Command {
1327 label: tr(locale, MessageId::ExtensionsActionEnable).into_owned(),
1328 command: format!("/mcp enable {name}"),
1329 disposition: RowActionDisposition::InPlace,
1330 }
1331 }
1332 });
1333 let remove = mutable.then(|| ExtensionAction::Command {
1334 label: "remove".into(),
1335 command: format!("/mcp remove {name}"),
1336 disposition: RowActionDisposition::InPlace,
1337 });
1338 ExtensionItem {
1339 id: name.clone(),
1340 tone: match (enabled, initializing, recovery) {
1341 (false, ..) => ExtensionTone::Idle,
1342 (true, true, _) => ExtensionTone::Attention,
1343 (true, false, None) => ExtensionTone::Ready,
1344 // A server that reports an error is broken; one that only
1345 // wants a login or a reconnect is waiting on a person.
1346 (true, false, Some(crate::mcp::McpRecoveryKind::Diagnose)) => {
1347 ExtensionTone::Failure
1348 }
1349 (true, false, Some(_)) => ExtensionTone::Attention,
1350 },
1351 label: name,
1352 description: observed.map_or_else(String::new, |server| {
1353 localize(
1354 locale,
1355 MessageId::ExtensionsMcpSummary,
1356 &[
1357 ("transport", &server.transport),
1358 ("tools", &server.tools.len().to_string()),
1359 ("resources", &server.resources.len().to_string()),
1360 ],
1361 )
1362 }),
1363 state,
1364 // The passive snapshot can carry a command line or URL. Do
1365 // not mirror either into this broad inventory surface.
1366 detail: observed.map_or_else(
1367 || tr(locale, MessageId::ExtensionsMcpNotInspected).into_owned(),
1368 |server| {
1369 server.error.clone().unwrap_or_else(|| {
1370 localize(
1371 locale,
1372 MessageId::ExtensionsMcpDetail,
1373 &[
1374 ("tools", &server.tools.len().to_string()),
1375 ("resources", &server.resources.len().to_string()),
1376 ("prompts", &server.prompts.len().to_string()),
1377 ],
1378 )
1379 })
1380 },
1381 ),
1382 action: Some(action),
1383 toggle,
1384 remove,
1385 }
1386 })
1387 .collect();
1388 let groups = if items.is_empty() && configured.is_some() {
1389 vec![ExtensionGroup {
1390 id: "mcp-start".into(),
1391 label: tr(locale, MessageId::ExtensionsMcpEmpty).into_owned(),
1392 items: vec![ExtensionItem {
1393 id: "mcp-suggestions".into(),
1394 tone: ExtensionTone::Idle,
1395 label: tr(locale, MessageId::ExtensionsMcpBrowse).into_owned(),
1396 description: tr(locale, MessageId::McpRecommendationsHeading).into_owned(),
1397 state: tr(locale, MessageId::ExtensionsStateAvailable).into_owned(),
1398 detail: localize(
1399 locale,
1400 MessageId::McpRecommendationsSafety,
1401 &[("restart_command", "/mcp restart")],
1402 ),
1403 action: Some(ExtensionAction::Command {
1404 label: tr(locale, MessageId::AutomationActionInspect).into_owned(),
1405 command: "/mcp recommendations".into(),
1406 disposition: RowActionDisposition::InPlacePager,
1407 }),
1408 toggle: None,
1409 remove: None,
1410 }],
1411 }]
1412 } else {
1413 mcp_groups(locale, items)
1414 };
1415 ExtensionsTabModel {
1416 groups,
1417 problem: (configured.is_none() && app.mcp_configured_count > total).then(|| {
1418 localize(
1419 locale,
1420 MessageId::ExtensionsMcpRefresh,
1421 &[("count", &app.mcp_configured_count.to_string())],
1422 )
1423 }),
1424 }
1425 }
1426
1427 /// Group id of the `/mcp` rows whose one action is a login.
1428 const MCP_LOGIN_GROUP_ID: &str = "login";
1429
1430 /// Whether a row's one action is the login flow.
1431 fn mcp_item_needs_login(item: &ExtensionItem) -> bool {
1432 item.action
1433 .as_ref()
1434 .and_then(ExtensionAction::command)
1435 .is_some_and(|command| command.starts_with("/mcp login "))
1436 }
1437
1438 /// Order the `/mcp` rows by what a person has to do about them. Everything
1439 /// that needs a human leads: with twenty servers configured, the four that
1440 /// failed or want re-auth were impossible to pick out of a flat alphabetical
1441 /// list — founder live-test on the same screen. Within that, the servers
1442 /// that only need a login come first, as their own group, because "failed"
1443 /// is the wrong word for an expired login and the fix is one key (#5926):
1444 /// Enter on the row runs `/mcp login <server>`. Real failures follow with
1445 /// their reason in the detail line; a healthy server renders its state and
1446 /// sorts below.
1447 fn mcp_groups(locale: Locale, items: Vec<ExtensionItem>) -> Vec<ExtensionGroup> {
1448 let (login, rest): (Vec<_>, Vec<_>) = items.into_iter().partition(mcp_item_needs_login);
1449 let (attention, healthy): (Vec<_>, Vec<_>) = rest
1450 .into_iter()
1451 .partition(|item| item.action.as_ref().is_some_and(|a| a.command().is_some()));
1452 [
1453 (
1454 MCP_LOGIN_GROUP_ID,
1455 MessageId::ExtensionsGroupNeedsLogin,
1456 login,
1457 ),
1458 (
1459 "attention",
1460 MessageId::ExtensionsGroupNeedsAttention,
1461 attention,
1462 ),
1463 ("servers", MessageId::ExtensionsGroupServers, healthy),
1464 ]
1465 .into_iter()
1466 .filter(|(_, _, items)| !items.is_empty())
1467 .map(|(id, label, items)| ExtensionGroup {
1468 id: id.into(),
1469 label: tr(locale, label).into_owned(),
1470 items,
1471 })
1472 .collect()
1473 }
1474
1475 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1476 enum ExtensionsFocus {
1477 Tabs,
1478 Search,
1479 List,
1480 }
1481
1482 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1483 enum VisibleEntry<'a> {
1484 Group(&'a ExtensionGroup),
1485 Item(&'a ExtensionGroup, &'a ExtensionItem),
1486 Problem(&'a str),
1487 Empty,
1488 }
1489
1490 #[derive(Default)]
1491 struct HitAreas {
1492 tabs: Vec<(Rect, ExtensionsTab)>,
1493 search: Option<Rect>,
1494 rows: Vec<(Rect, usize)>,
1495 }
1496
1497 pub struct ExtensionsView {
1498 snapshot: ExtensionsSnapshot,
1499 locale: Locale,
1500 active_tab: ExtensionsTab,
1501 focus: ExtensionsFocus,
1502 query: String,
1503 selected: [usize; 5],
1504 scroll: [usize; 5],
1505 folded_groups: BTreeSet<String>,
1506 /// The live theme, captured at open so row ink resolves through the same
1507 /// grammar the rest of the chrome uses instead of raw palette constants.
1508 theme: codewhale_palette::UiTheme,
1509 hits: RefCell<HitAreas>,
1510 hovered_row: Option<usize>,
1511 hovered_tab: Option<ExtensionsTab>,
1512 /// Last time `tick` asked the host for a fresh snapshot. Bounds the poll
1513 /// so a per-frame tick cannot turn into a rebuild every frame.
1514 last_poll: std::time::Instant,
1515 /// Row id whose removal is armed. A second `d` / Delete / right-click on
1516 /// the same row confirms; any navigation or Esc disarms.
1517 pending_remove: Option<String>,
1518 }
1519
1520 impl ExtensionsView {
1521 #[must_use]
1522 pub fn new(app: &App, tab: ExtensionsTab) -> Self {
1523 let mut view =
1524 Self::from_snapshot_with_locale(ExtensionsSnapshot::from_app(app), tab, app.ui_locale);
1525 view.theme = app.ui_theme;
1526 view
1527 }
1528
1529 fn from_snapshot_with_locale(
1530 snapshot: ExtensionsSnapshot,
1531 tab: ExtensionsTab,
1532 locale: Locale,
1533 ) -> Self {
1534 let mut view = Self {
1535 snapshot,
1536 locale,
1537 active_tab: tab,
1538 focus: ExtensionsFocus::List,
1539 query: String::new(),
1540 selected: [0; 5],
1541 scroll: [0; 5],
1542 folded_groups: BTreeSet::new(),
1543 theme: codewhale_palette::UI_THEME,
1544 hits: RefCell::new(HitAreas::default()),
1545 hovered_row: None,
1546 hovered_tab: None,
1547 last_poll: std::time::Instant::now(),
1548 pending_remove: None,
1549 };
1550 // Each tab lands on a real item, preserving login-first MCP sorting.
1551 // Group headings remain reachable for folding with Up.
1552 for initial_tab in ExtensionsTab::ALL {
1553 view.active_tab = initial_tab;
1554 if let Some(index) = view
1555 .visible_entries()
1556 .iter()
1557 .position(|entry| matches!(entry, VisibleEntry::Item(_, _)))
1558 {
1559 view.selected[initial_tab.index()] = index;
1560 }
1561 }
1562 view.active_tab = tab;
1563 view
1564 }
1565
1566 fn fold_key(&self, group: &ExtensionGroup) -> String {
1567 format!("{}:{}", self.active_tab.index(), group.id)
1568 }
1569
1570 fn group_matches(&self, group: &ExtensionGroup, query: &str) -> bool {
1571 group.label.to_lowercase().contains(query)
1572 || group.items.iter().any(|item| item_matches(item, query))
1573 }
1574
1575 fn visible_entries(&self) -> Vec<VisibleEntry<'_>> {
1576 let model = self.snapshot.tab(self.active_tab);
1577 let query = self.query.trim().to_lowercase();
1578 let searching = !query.is_empty();
1579 let mut entries = Vec::new();
1580 if let Some(problem) = model.problem.as_deref() {
1581 entries.push(VisibleEntry::Problem(problem));
1582 }
1583 for group in &model.groups {
1584 if searching && !self.group_matches(group, &query) {
1585 continue;
1586 }
1587 entries.push(VisibleEntry::Group(group));
1588 let folded = !searching && self.folded_groups.contains(&self.fold_key(group));
1589 if folded {
1590 continue;
1591 }
1592 let group_name_matches = searching && group.label.to_lowercase().contains(&query);
1593 entries.extend(
1594 group
1595 .items
1596 .iter()
1597 .filter(|item| !searching || group_name_matches || item_matches(item, &query))
1598 .map(|item| VisibleEntry::Item(group, item)),
1599 );
1600 }
1601 if entries.is_empty() {
1602 entries.push(VisibleEntry::Empty);
1603 }
1604 entries
1605 }
1606
1607 fn clamp_selection(&mut self) {
1608 let len = self.visible_entries().len();
1609 let index = self.active_tab.index();
1610 self.selected[index] = self.selected[index].min(len.saturating_sub(1));
1611 self.scroll[index] = self.scroll[index].min(self.selected[index]);
1612 }
1613
1614 fn move_selection(&mut self, delta: isize) {
1615 self.pending_remove = None;
1616 let len = self.visible_entries().len();
1617 if len == 0 {
1618 return;
1619 }
1620 let index = self.active_tab.index();
1621 self.selected[index] =
1622 (self.selected[index] as isize + delta).rem_euclid(len as isize) as usize;
1623 }
1624
1625 fn selected_item(&self) -> Option<&ExtensionItem> {
1626 let selected = self.selected[self.active_tab.index()];
1627 match self.visible_entries().get(selected).copied() {
1628 Some(VisibleEntry::Item(_, item)) => Some(item),
1629 _ => None,
1630 }
1631 }
1632
1633 /// `e`: run the row's reversible on/off command in place.
1634 fn toggle_selected(&mut self) -> ViewAction {
1635 self.pending_remove = None;
1636 match self.selected_item().and_then(|item| item.toggle.as_ref()) {
1637 Some(ExtensionAction::Command { command, .. }) => {
1638 ViewAction::Emit(ViewEvent::ExecutePanelCommand {
1639 command: command.clone(),
1640 pager_title: None,
1641 })
1642 }
1643 _ => ViewAction::None,
1644 }
1645 }
1646
1647 /// `d` / Delete / right-click: arm removal on the first gesture, run the
1648 /// row's remove command on the second. Rows without a remove command
1649 /// ignore the gesture.
1650 fn remove_selected(&mut self) -> ViewAction {
1651 let Some((id, command)) = self.selected_item().and_then(|item| match &item.remove {
1652 Some(ExtensionAction::Command { command, .. }) => {
1653 Some((item.id.clone(), command.clone()))
1654 }
1655 _ => None,
1656 }) else {
1657 self.pending_remove = None;
1658 return ViewAction::None;
1659 };
1660 if self.pending_remove.as_deref() == Some(id.as_str()) {
1661 self.pending_remove = None;
1662 return ViewAction::Emit(ViewEvent::ExecutePanelCommand {
1663 command,
1664 pager_title: None,
1665 });
1666 }
1667 self.pending_remove = Some(id);
1668 ViewAction::None
1669 }
1670
1671 fn activate_selected(&mut self) -> ViewAction {
1672 let selected = self.selected[self.active_tab.index()];
1673 match self.visible_entries().get(selected).copied() {
1674 Some(VisibleEntry::Group(group)) => {
1675 let group = group.clone();
1676 let key = self.fold_key(&group);
1677 if !self.folded_groups.remove(&key) {
1678 self.folded_groups.insert(key);
1679 }
1680 self.clamp_selection();
1681 ViewAction::None
1682 }
1683 Some(VisibleEntry::Item(_, item)) => match item.action.as_ref() {
1684 Some(ExtensionAction::Command {
1685 command,
1686 disposition,
1687 ..
1688 }) => match disposition {
1689 RowActionDisposition::LeavePanel => {
1690 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
1691 action: CommandPaletteAction::ExecuteCommand {
1692 command: command.clone(),
1693 },
1694 })
1695 }
1696 RowActionDisposition::InPlace => {
1697 ViewAction::Emit(ViewEvent::ExecutePanelCommand {
1698 command: command.clone(),
1699 pager_title: None,
1700 })
1701 }
1702 RowActionDisposition::InPlacePager => {
1703 ViewAction::Emit(ViewEvent::ExecutePanelCommand {
1704 command: command.clone(),
1705 pager_title: Some(item.label.clone()),
1706 })
1707 }
1708 },
1709 _ => ViewAction::Emit(ViewEvent::OpenTextPager {
1710 title: item.label.clone(),
1711 content: format!("{}\n\n{}\n\n{}", item.state, item.description, item.detail),
1712 }),
1713 },
1714 Some(VisibleEntry::Problem(problem)) => ViewAction::Emit(ViewEvent::OpenTextPager {
1715 title: self.active_tab.label(self.locale),
1716 content: problem.to_string(),
1717 }),
1718 _ => ViewAction::None,
1719 }
1720 }
1721
1722 /// Swap in a fresh read model while keeping everything the user is doing:
1723 /// active tab, focus, search query, selection, scroll, and folded groups
1724 /// all survive; the selection only moves when the refreshed list no
1725 /// longer reaches it.
1726 pub fn refresh_snapshot(&mut self, snapshot: ExtensionsSnapshot) {
1727 self.snapshot = snapshot;
1728 self.clamp_selection();
1729 }
1730
1731 fn set_tab(&mut self, tab: ExtensionsTab) {
1732 self.hovered_row = None;
1733 self.hovered_tab = None;
1734 self.pending_remove = None;
1735 self.active_tab = tab;
1736 self.clamp_selection();
1737 }
1738
1739 fn selected_status(&self) -> String {
1740 if let Some(item) = self.selected_item()
1741 && self.pending_remove.as_deref() == Some(item.id.as_str())
1742 {
1743 return format!(
1744 "Remove {}? Press d, Enter or right-click again to confirm · Esc cancels",
1745 item.label
1746 );
1747 }
1748 let index = self.selected[self.active_tab.index()];
1749 match self.visible_entries().get(index).copied() {
1750 Some(VisibleEntry::Group(group)) => localize(
1751 self.locale,
1752 MessageId::ExtensionsGroupStatus,
1753 &[("count", &group.items.len().to_string())],
1754 ),
1755 Some(VisibleEntry::Item(_, item)) => {
1756 let action = item
1757 .action
1758 .as_ref()
1759 .map(|action| format!(" · {}", action.label()))
1760 .unwrap_or_default();
1761 format!("{} · {}{action} · {}", item.label, item.state, item.detail)
1762 }
1763 Some(VisibleEntry::Problem(problem)) => problem.to_string(),
1764 Some(VisibleEntry::Empty) | None => {
1765 tr(self.locale, MessageId::ExtensionsNoItems).into_owned()
1766 }
1767 }
1768 }
1769 }
1770
1771 fn item_matches(item: &ExtensionItem, query: &str) -> bool {
1772 item.label.to_lowercase().contains(query)
1773 || item.description.to_lowercase().contains(query)
1774 || item.state.to_lowercase().contains(query)
1775 || item.detail.to_lowercase().contains(query)
1776 || item
1777 .action
1778 .as_ref()
1779 .is_some_and(|action| action.label().to_lowercase().contains(query))
1780 }
1781
1782 impl ModalView for ExtensionsView {
1783 fn kind(&self) -> ModalKind {
1784 ModalKind::Extensions
1785 }
1786
1787 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
1788 self.hovered_row = None;
1789 self.hovered_tab = None;
1790 // One navigation grammar (grokbuild, the stated authority): Tab and
1791 // Shift+Tab / BackTab move across the tab bar, always — even during
1792 // a search, which keeps its query on the new tab. `/` searches, Esc
1793 // backs out, ↑↓ move, Enter acts. Tab never cycles focus.
1794 if key.code == KeyCode::BackTab
1795 || (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
1796 {
1797 self.set_tab(self.active_tab.previous());
1798 return ViewAction::None;
1799 }
1800 if key.code == KeyCode::Tab {
1801 self.set_tab(self.active_tab.next());
1802 return ViewAction::None;
1803 }
1804 if self.focus == ExtensionsFocus::Search {
1805 match key.code {
1806 KeyCode::Esc => {
1807 if self.query.is_empty() {
1808 self.focus = ExtensionsFocus::List;
1809 } else {
1810 self.query.clear();
1811 self.clamp_selection();
1812 }
1813 }
1814 KeyCode::Backspace => {
1815 self.query.pop();
1816 self.clamp_selection();
1817 }
1818 KeyCode::Enter | KeyCode::Down => self.focus = ExtensionsFocus::List,
1819 KeyCode::Char(ch)
1820 if !key.modifiers.intersects(
1821 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
1822 ) =>
1823 {
1824 self.query.push(ch);
1825 self.clamp_selection();
1826 }
1827 _ => {}
1828 }
1829 return ViewAction::None;
1830 }
1831 if self.pending_remove.is_some()
1832 && !matches!(
1833 key.code,
1834 KeyCode::Char('d') | KeyCode::Delete | KeyCode::Enter | KeyCode::Char('y')
1835 )
1836 {
1837 // Anything but the confirming key disarms a pending removal.
1838 self.pending_remove = None;
1839 if key.code == KeyCode::Esc {
1840 return ViewAction::None;
1841 }
1842 }
1843 match key.code {
1844 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
1845 KeyCode::Char('/') => {
1846 self.focus = ExtensionsFocus::Search;
1847 ViewAction::None
1848 }
1849 KeyCode::Char('e') => {
1850 self.focus = ExtensionsFocus::List;
1851 self.toggle_selected()
1852 }
1853 KeyCode::Char('d') | KeyCode::Delete => {
1854 self.focus = ExtensionsFocus::List;
1855 self.remove_selected()
1856 }
1857 KeyCode::Char('y') | KeyCode::Enter if self.pending_remove.is_some() => {
1858 self.remove_selected()
1859 }
1860 // Left/Right and `[`/`]` are the same move for hands that reach
1861 // for them; the advertised chord is Tab.
1862 KeyCode::Left | KeyCode::Char('[') | KeyCode::Char('h') => {
1863 self.set_tab(self.active_tab.previous());
1864 ViewAction::None
1865 }
1866 KeyCode::Right | KeyCode::Char(']') | KeyCode::Char('l') => {
1867 self.set_tab(self.active_tab.next());
1868 ViewAction::None
1869 }
1870 KeyCode::Up | KeyCode::Char('k') => {
1871 self.focus = ExtensionsFocus::List;
1872 self.move_selection(-1);
1873 ViewAction::None
1874 }
1875 KeyCode::Down | KeyCode::Char('j') => {
1876 self.focus = ExtensionsFocus::List;
1877 self.move_selection(1);
1878 ViewAction::None
1879 }
1880 KeyCode::Enter | KeyCode::Char(' ') => {
1881 self.focus = ExtensionsFocus::List;
1882 self.activate_selected()
1883 }
1884 _ => ViewAction::None,
1885 }
1886 }
1887
1888 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
1889 match mouse.kind {
1890 MouseEventKind::Moved => {
1891 let hits = self.hits.borrow();
1892 let point = (mouse.column, mouse.row).into();
1893 self.hovered_row = hits
1894 .rows
1895 .iter()
1896 .find_map(|(rect, row)| rect.contains(point).then_some(*row));
1897 self.hovered_tab = hits
1898 .tabs
1899 .iter()
1900 .find_map(|(rect, tab)| rect.contains(point).then_some(*tab));
1901 return ViewAction::None;
1902 }
1903 // The wheel moves this list, not the transcript behind it.
1904 MouseEventKind::ScrollUp => {
1905 self.pending_remove = None;
1906 self.focus = ExtensionsFocus::List;
1907 self.move_selection(-1);
1908 return ViewAction::None;
1909 }
1910 MouseEventKind::ScrollDown => {
1911 self.pending_remove = None;
1912 self.focus = ExtensionsFocus::List;
1913 self.move_selection(1);
1914 return ViewAction::None;
1915 }
1916 // Right-click on a row selects it and arms (then confirms) its
1917 // removal, the same two-step gesture as `d`.
1918 MouseEventKind::Down(MouseButton::Right) => {
1919 let row = self
1920 .hits
1921 .borrow()
1922 .rows
1923 .iter()
1924 .find(|(rect, _)| rect.contains((mouse.column, mouse.row).into()))
1925 .map(|(_, row)| *row);
1926 let Some(row) = row else {
1927 self.pending_remove = None;
1928 return ViewAction::None;
1929 };
1930 self.focus = ExtensionsFocus::List;
1931 if self.selected[self.active_tab.index()] != row {
1932 self.pending_remove = None;
1933 self.selected[self.active_tab.index()] = row;
1934 }
1935 return self.remove_selected();
1936 }
1937 MouseEventKind::Down(MouseButton::Left) => {}
1938 _ => return ViewAction::None,
1939 }
1940 let hits = self.hits.borrow();
1941 if let Some((_, tab)) = hits
1942 .tabs
1943 .iter()
1944 .find(|(rect, _)| rect.contains((mouse.column, mouse.row).into()))
1945 .copied()
1946 {
1947 drop(hits);
1948 self.focus = ExtensionsFocus::Tabs;
1949 self.set_tab(tab);
1950 return ViewAction::None;
1951 }
1952 if hits
1953 .search
1954 .is_some_and(|rect| rect.contains((mouse.column, mouse.row).into()))
1955 {
1956 drop(hits);
1957 self.focus = ExtensionsFocus::Search;
1958 return ViewAction::None;
1959 }
1960 if let Some((_, row)) = hits
1961 .rows
1962 .iter()
1963 .find(|(rect, _)| rect.contains((mouse.column, mouse.row).into()))
1964 .copied()
1965 {
1966 drop(hits);
1967 self.focus = ExtensionsFocus::List;
1968 self.selected[self.active_tab.index()] = row;
1969 return self.activate_selected();
1970 }
1971 ViewAction::None
1972 }
1973
1974 fn render(&self, area: Rect, buf: &mut Buffer) {
1975 *self.hits.borrow_mut() = HitAreas::default();
1976 let body = render_underwater_surface(
1977 area,
1978 buf,
1979 tr(self.locale, MessageId::ExtensionsTitle).into_owned(),
1980 );
1981 if body.width == 0 || body.height < 5 {
1982 return;
1983 }
1984 let rows = Layout::default()
1985 .direction(Direction::Vertical)
1986 .constraints([
1987 Constraint::Length(1),
1988 Constraint::Length(1),
1989 Constraint::Min(1),
1990 Constraint::Length(if body.height >= 16 { 3 } else { 1 }),
1991 Constraint::Length(1),
1992 ])
1993 .split(body);
1994
1995 let mut hits = HitAreas::default();
1996 let mut x = rows[0].x;
1997 let available = rows[0].right();
1998 for tab in ExtensionsTab::ALL {
1999 let label = if body.width < 58 && tab == ExtensionsTab::Marketplace {
2000 tr(self.locale, MessageId::ExtensionsTabMarketplaceCompact).into_owned()
2001 } else {
2002 tab.label(self.locale)
2003 };
2004 let width = (label.chars().count() as u16 + 2).min(available.saturating_sub(x));
2005 if width == 0 {
2006 break;
2007 }
2008 let tab_area = Rect::new(x, rows[0].y, width, 1);
2009 let active = tab == self.active_tab;
2010 let focused = active && self.focus == ExtensionsFocus::Tabs;
2011 let style = if focused {
2012 menu_style::selected_row_style()
2013 } else if self.hovered_tab == Some(tab) {
2014 menu_style::hovered_row_style().fg(palette::TEXT_PRIMARY)
2015 } else if active {
2016 Style::default()
2017 .fg(palette::WHALE_ACTION)
2018 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
2019 } else {
2020 Style::default().fg(palette::TEXT_MUTED)
2021 };
2022 Paragraph::new(Line::from(Span::styled(format!(" {label} "), style)))
2023 .render(tab_area, buf);
2024 hits.tabs.push((tab_area, tab));
2025 x = x.saturating_add(width);
2026 }
2027
2028 let search_style = if self.focus == ExtensionsFocus::Search {
2029 Style::default()
2030 .fg(palette::WHALE_ACTION)
2031 .add_modifier(Modifier::BOLD)
2032 } else {
2033 Style::default().fg(palette::TEXT_MUTED)
2034 };
2035 let cursor = if self.focus == ExtensionsFocus::Search {
2036 "_"
2037 } else {
2038 ""
2039 };
2040 Paragraph::new(Line::from(vec![
2041 Span::styled(
2042 tr(self.locale, MessageId::ExtensionsSearchLabel),
2043 search_style,
2044 ),
2045 Span::styled(
2046 format!("{}{cursor}", self.query),
2047 Style::default().fg(palette::TEXT_PRIMARY),
2048 ),
2049 ]))
2050 .render(rows[1], buf);
2051 hits.search = Some(rows[1]);
2052
2053 let entries = self.visible_entries();
2054 let list_height = usize::from(rows[2].height);
2055 let mut scroll = self.scroll[self.active_tab.index()];
2056 let selected = self.selected[self.active_tab.index()];
2057 if selected < scroll {
2058 scroll = selected;
2059 } else if selected >= scroll.saturating_add(list_height.max(1)) {
2060 scroll = selected.saturating_sub(list_height.saturating_sub(1));
2061 }
2062 for (visible_offset, (entry_index, entry)) in entries
2063 .iter()
2064 .enumerate()
2065 .skip(scroll)
2066 .take(list_height)
2067 .enumerate()
2068 {
2069 let row_area = Rect::new(
2070 rows[2].x,
2071 rows[2].y.saturating_add(visible_offset as u16),
2072 rows[2].width,
2073 1,
2074 );
2075 let is_selected = entry_index == selected;
2076 let hovered = self.hovered_row == Some(entry_index);
2077 let style = if is_selected && self.focus == ExtensionsFocus::List {
2078 menu_style::selected_row_style()
2079 } else if hovered {
2080 menu_style::hovered_row_style().fg(palette::TEXT_PRIMARY)
2081 } else if is_selected {
2082 Style::default()
2083 .fg(palette::WHALE_ACTION)
2084 .add_modifier(Modifier::BOLD)
2085 } else {
2086 Style::default().fg(palette::TEXT_PRIMARY)
2087 };
2088 // Rows are built as (text, optional ink) pairs. The ink is what
2089 // stops every row on the screen from reading the same: the action
2090 // chip is an invitation, the state is a verdict, the description
2091 // is background. A selected row keeps one style — a highlight the
2092 // eye can follow beats four colours fighting a fill.
2093 let mut parts: Vec<(String, Option<codewhale_palette::ChromeInk>)> = Vec::new();
2094 match entry {
2095 VisibleEntry::Group(group) => {
2096 let folded = self.folded_groups.contains(&self.fold_key(group));
2097 parts.push((
2098 format!(
2099 "{} {} ({})",
2100 if folded { "▸" } else { "▾" },
2101 group.label,
2102 group.items.len()
2103 ),
2104 None,
2105 ));
2106 }
2107 VisibleEntry::Item(_, item) => {
2108 parts.push((" ".into(), None));
2109 if let Some(action) = item.action.as_ref() {
2110 parts.push((
2111 format!("{} · ", action.label()),
2112 Some(match action {
2113 ExtensionAction::Command { .. } => {
2114 codewhale_palette::ChromeInk::Identity
2115 }
2116 ExtensionAction::Status { .. } => item.tone.ink(),
2117 }),
2118 ));
2119 }
2120 parts.push((item.label.clone(), None));
2121 parts.push((format!(" · {}", item.state), Some(item.tone.ink())));
2122 }
2123 VisibleEntry::Problem(problem) => parts.push((
2124 format!("! {problem}"),
2125 Some(codewhale_palette::ChromeInk::Failure),
2126 )),
2127 VisibleEntry::Empty => parts.push((
2128 if self.query.is_empty() {
2129 tr(self.locale, MessageId::ExtensionsNoItems).into_owned()
2130 } else {
2131 localize(
2132 self.locale,
2133 MessageId::ExtensionsNoMatches,
2134 &[("query", &self.query)],
2135 )
2136 },
2137 Some(codewhale_palette::ChromeInk::MetadataHint),
2138 )),
2139 }
2140
2141 // Truncate across the whole row, not per span, so the width bound
2142 // is the one the flat row always had.
2143 let joined = parts
2144 .iter()
2145 .map(|(text, _)| text.as_str())
2146 .collect::<String>();
2147 let clipped = truncate_view_text(&joined, usize::from(row_area.width));
2148 let spans = if is_selected || hovered || clipped.len() != joined.len() {
2149 vec![Span::styled(clipped, style)]
2150 } else {
2151 parts
2152 .into_iter()
2153 .filter(|(text, _)| !text.is_empty())
2154 .map(|(text, ink)| {
2155 let span_style = match ink {
2156 Some(ink) => Style::default().fg(ink.color(&self.theme)),
2157 None => style,
2158 };
2159 Span::styled(text, span_style)
2160 })
2161 .collect()
2162 };
2163 Paragraph::new(Line::from(spans))
2164 .style(style)
2165 .render(row_area, buf);
2166 hits.rows.push((row_area, entry_index));
2167 }
2168
2169 let status = if rows[3].height > 1 && self.pending_remove.is_none() {
2170 self.selected_item().map_or_else(
2171 || self.selected_status(),
2172 |item| {
2173 format!(
2174 "{} · {}\n{}\n{}",
2175 item.label, item.state, item.description, item.detail
2176 )
2177 },
2178 )
2179 } else {
2180 self.selected_status()
2181 };
2182 Paragraph::new(status)
2183 .style(Style::default().fg(if self.pending_remove.is_some() {
2184 palette::STATUS_WARNING
2185 } else {
2186 palette::TEXT_MUTED
2187 }))
2188 .wrap(Wrap { trim: false })
2189 .render(rows[3], buf);
2190 let mut compact_hints = vec![
2191 super::ActionHint::new("Tab", tr(self.locale, MessageId::ExtensionsActionTabs)),
2192 super::ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)),
2193 super::ActionHint::new("Esc", tr(self.locale, MessageId::SessionsActionClose)),
2194 ];
2195 let enter_label = match entries.get(selected).copied() {
2196 Some(VisibleEntry::Item(_, item)) => Some(
2197 item.action
2198 .as_ref()
2199 .filter(|action| action.command().is_some())
2200 .map_or_else(
2201 || tr(self.locale, MessageId::AutomationActionInspect).into_owned(),
2202 |action| action.label().to_string(),
2203 ),
2204 ),
2205 Some(VisibleEntry::Group(_)) => {
2206 Some(tr(self.locale, MessageId::ExtensionsActionFold).into_owned())
2207 }
2208 Some(VisibleEntry::Problem(_)) => {
2209 Some(tr(self.locale, MessageId::AutomationActionInspect).into_owned())
2210 }
2211 _ => None,
2212 };
2213 if let Some(label) = enter_label.as_ref() {
2214 compact_hints.insert(1, super::ActionHint::new("Enter", label.clone()));
2215 }
2216 let mut full_hints = vec![
2217 super::ActionHint::new("Tab", tr(self.locale, MessageId::ExtensionsActionTabs)),
2218 super::ActionHint::new("↑↓", tr(self.locale, MessageId::LaunchHintMove)),
2219 ];
2220 if let Some(label) = enter_label {
2221 full_hints.push(super::ActionHint::new("Enter", label));
2222 }
2223 if let Some(item) = self.selected_item() {
2224 if let Some(toggle) = item.toggle.as_ref() {
2225 full_hints.push(super::ActionHint::new("e", toggle.label().to_string()));
2226 }
2227 if let Some(remove) = item.remove.as_ref() {
2228 full_hints.push(super::ActionHint::new("d", remove.label().to_string()));
2229 }
2230 }
2231 full_hints.push(super::ActionHint::new(
2232 "/",
2233 tr(self.locale, MessageId::SessionsActionSearch),
2234 ));
2235 full_hints.push(super::ActionHint::new(
2236 "Esc",
2237 tr(self.locale, MessageId::SessionsActionClose),
2238 ));
2239 render_modal_footer(
2240 rows[4],
2241 buf,
2242 if rows[4].width < 64 {
2243 &compact_hints
2244 } else {
2245 &full_hints
2246 },
2247 );
2248 *self.hits.borrow_mut() = hits;
2249 }
2250
2251 fn tick(&mut self) -> ViewAction {
2252 // MCP rows go live while the panel is open — a retry lands, a login
2253 // finishes, a diagnosis resolves — and the open-time capture would
2254 // read stale until reopen. Ask the host for a fresh model at a
2255 // bounded cadence; it rebuilds only when the generation or the
2256 // initializing flag actually moved.
2257 if self.last_poll.elapsed() < std::time::Duration::from_millis(750) {
2258 return ViewAction::None;
2259 }
2260 self.last_poll = std::time::Instant::now();
2261 ViewAction::Emit(ViewEvent::RefreshExtensions {
2262 mcp_generation: self.snapshot.mcp_generation,
2263 mcp_initializing: self.snapshot.mcp_initializing,
2264 })
2265 }
2266
2267 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
2268 self
2269 }
2270 }
2271
2272 #[cfg(test)]
2273 mod tests {
2274 use super::*;
2275 use crate::mcp::McpRecoveryKind;
2276
2277 #[test]
2278 fn passive_rows_open_details_without_recovery_or_mutation() {
2279 let mut view = view_on_item(ExtensionAction::Status {
2280 label: "connected".into(),
2281 });
2282 let ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) =
2283 view.activate_selected()
2284 else {
2285 panic!("a passive inventory row must have useful details");
2286 };
2287 assert_eq!(title, "row");
2288 assert!(content.contains("state"));
2289 assert!(view.pending_remove.is_none());
2290 }
2291
2292 #[test]
2293 fn empty_mcp_opens_suggestions_without_installing_and_skills_manage_does_not_loop() {
2294 let _env = crate::test_support::lock_test_env();
2295 let root = tempfile::tempdir().unwrap();
2296 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
2297 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
2298 .registry_for_workspace(root.path());
2299 let app = App::new_with_plugin_registry(
2300 crate::test_support::test_tui_options(root.path()),
2301 &crate::config::Config::default(),
2302 registry,
2303 );
2304 let model = mcp_model(&app, Locale::En);
2305 assert_eq!(model.groups.len(), 1);
2306 assert_eq!(model.groups[0].label, "No MCP servers configured");
2307 let item = &model.groups[0].items[0];
2308 assert!(item.toggle.is_none() && item.remove.is_none());
2309 assert!(
2310 matches!(item.action.as_ref(), Some(ExtensionAction::Command {
2311 command, disposition: RowActionDisposition::InPlacePager, ..
2312 }) if command == "/mcp recommendations")
2313 );
2314 let skills = skills_model(&app, Locale::En);
2315 for item in skills.groups.iter().flat_map(|group| &group.items) {
2316 assert_eq!(
2317 item.action.as_ref().and_then(ExtensionAction::command),
2318 Some("/skills manage")
2319 );
2320 }
2321 let mut snapshot = ExtensionsSnapshot::default();
2322 snapshot.tabs[ExtensionsTab::Mcp.index()] = model;
2323 let mut view =
2324 ExtensionsView::from_snapshot_with_locale(snapshot, ExtensionsTab::Mcp, Locale::En);
2325 assert_eq!(view.selected[ExtensionsTab::Mcp.index()], 1);
2326 assert!(
2327 matches!(view.activate_selected(), ViewAction::Emit(ViewEvent::ExecutePanelCommand {
2328 command, pager_title: Some(_)
2329 }) if command == "/mcp recommendations")
2330 );
2331 }
2332
2333 #[test]
2334 fn workbench_extension_hover_preserves_selection_and_small_resize_clears_targets() {
2335 let mut view = view_on_item(ExtensionAction::Command {
2336 label: "enable".into(),
2337 command: "/plugin enable demo".into(),
2338 disposition: RowActionDisposition::InPlace,
2339 });
2340 let area = Rect::new(0, 0, 80, 24);
2341 let mut buf = Buffer::empty(area);
2342 view.render(area, &mut buf);
2343 let (hit, row) = view.hits.borrow().rows[0];
2344 let selected = view.selected;
2345 view.handle_mouse(MouseEvent {
2346 kind: MouseEventKind::Moved,
2347 column: hit.x,
2348 row: hit.y,
2349 modifiers: KeyModifiers::NONE,
2350 });
2351 assert_eq!(view.hovered_row, Some(row));
2352 assert_eq!(view.selected, selected);
2353 view.render(area, &mut buf);
2354 assert_eq!(buf[(hit.right() - 1, hit.y)].bg, palette::SURFACE_ELEVATED);
2355 let tiny = Rect::new(0, 0, 20, 4);
2356 view.render(tiny, &mut Buffer::empty(tiny));
2357 assert!(view.hits.borrow().rows.is_empty());
2358 assert!(view.hits.borrow().tabs.is_empty());
2359 }
2360
2361 #[test]
2362 fn a_plugin_contributed_server_is_not_mutable_from_this_panel() {
2363 // The row's name is synthesized and never appears in the config file
2364 // `/mcp remove` resolves against, so the gesture could only ever 404.
2365 let owned = BTreeSet::from(["github".to_string(), "playwright".to_string()]);
2366 assert!(mcp_row_is_mutable(Some(&owned), "github"));
2367 assert!(!mcp_row_is_mutable(
2368 Some(&owned),
2369 "plugin-25-codewhale-account-plugins-codewhale-plugins"
2370 ));
2371 }
2372
2373 #[test]
2374 fn an_unreadable_config_keeps_the_gestures_rather_than_withdrawing_them() {
2375 assert!(mcp_row_is_mutable(None, "anything"));
2376 }
2377
2378 #[test]
2379 fn marketplace_shipped_bundle_uses_local_metadata_and_review_action() {
2380 let _env = crate::test_support::lock_test_env();
2381 let root = tempfile::tempdir().unwrap();
2382 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
2383 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
2384 .registry_for_workspace(root.path());
2385 let app = App::new_with_plugin_registry(
2386 crate::test_support::test_tui_options(root.path()),
2387 &crate::config::Config::default(),
2388 registry,
2389 );
2390 let model = marketplace_model(&app, Locale::En);
2391 let group = model
2392 .groups
2393 .iter()
2394 .find(|group| group.id == "codewhale")
2395 .unwrap();
2396 let row = group
2397 .items
2398 .iter()
2399 .find(|row| row.label == "computer-use")
2400 .unwrap();
2401 let builtin = app.plugin_registry.get("computer-use").unwrap();
2402 assert_eq!(
2403 row.description,
2404 builtin
2405 .manifest
2406 .plugin
2407 .description
2408 .clone()
2409 .unwrap_or_default()
2410 );
2411 assert_eq!(
2412 row.state,
2413 tr(Locale::En, MessageId::ExtensionsStateFirstParty)
2414 );
2415 assert!(
2416 matches!(&row.action, Some(ExtensionAction::Command { command, disposition: RowActionDisposition::LeavePanel, .. }) if command == "/plugin trust computer-use")
2417 );
2418 assert!(!builtin.trusted());
2419 assert!(!builtin.enabled);
2420 assert_eq!(group.items.iter().filter(|row| matches!(&row.action, Some(ExtensionAction::Command { command, .. }) if command.starts_with("/plugin marketplace install "))).count(), 4);
2421 }
2422
2423 #[test]
2424 fn mcp_item_action_for_stale_oauth_is_login() {
2425 let recovery =
2426 crate::mcp::mcp_recovery_kind(true, true, false, Some("401 Unauthorized"), true)
2427 .expect("stale oauth needs recovery");
2428 assert_eq!(recovery, McpRecoveryKind::Reauth);
2429 assert_eq!(recovery.slash_command("github"), "/mcp login github");
2430 assert_eq!(tr(Locale::En, recovery.label_key()).as_ref(), "re-auth");
2431 }
2432
2433 #[test]
2434 fn a_healthy_server_offers_no_recovery_action() {
2435 // Founder live-test: "even the ones that are connected say diagnose
2436 // lol". Enabled, inspected, connected and erroring on nothing is not
2437 // a state anything repairs.
2438 assert_eq!(
2439 crate::mcp::mcp_recovery_kind(true, true, true, None, false),
2440 None
2441 );
2442 }
2443
2444 #[test]
2445 fn mcp_item_action_for_disconnected_server_is_reconnect() {
2446 let recovery = crate::mcp::mcp_recovery_kind(true, true, false, None, false)
2447 .expect("a disconnected server needs recovery");
2448 assert_eq!(recovery, McpRecoveryKind::Reconnect);
2449 // The row names one server, so the command it runs must name it too:
2450 // reloading all of them leaves the row the user aimed at still pending
2451 // when the list returns, which reads as the key doing nothing.
2452 assert_eq!(
2453 recovery.slash_command("playwright"),
2454 "/mcp retry playwright"
2455 );
2456 assert_eq!(tr(Locale::En, recovery.label_key()).as_ref(), "reconnect");
2457 }
2458
2459 /// Four distinct tones, four distinct inks, and none of them read out of
2460 /// a localized string — a screen that only colours correctly in English
2461 /// is not coloured.
2462 #[test]
2463 fn every_tone_paints_a_distinct_ink() {
2464 use codewhale_palette::ChromeInk;
2465 let theme = codewhale_palette::ThemeId::Whale.ui_theme();
2466 let inks: Vec<ChromeInk> = [
2467 ExtensionTone::Ready,
2468 ExtensionTone::Attention,
2469 ExtensionTone::Failure,
2470 ExtensionTone::Idle,
2471 ]
2472 .into_iter()
2473 .map(ExtensionTone::ink)
2474 .collect();
2475 let colors: std::collections::BTreeSet<String> = inks
2476 .iter()
2477 .map(|ink| format!("{:?}", ink.color(&theme)))
2478 .collect();
2479 assert_eq!(
2480 colors.len(),
2481 4,
2482 "each tone must be visually separable: {inks:?}"
2483 );
2484 assert_eq!(ExtensionTone::default(), ExtensionTone::Idle);
2485 }
2486
2487 /// The grokbuild grammar: Tab / Shift+Tab move across the tab bar, even
2488 /// mid-search, and the query rides along to the new tab.
2489 #[test]
2490 fn tab_switches_tabs_and_keeps_the_search_query() {
2491 use crate::tui::views::ModalView;
2492 let mut view = ExtensionsView::from_snapshot_with_locale(
2493 ExtensionsSnapshot::default(),
2494 ExtensionsTab::Plugins,
2495 Locale::En,
2496 );
2497 let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
2498 view.handle_key(key(KeyCode::Char('/')));
2499 view.handle_key(key(KeyCode::Char('g')));
2500 assert_eq!(view.focus, ExtensionsFocus::Search);
2501
2502 view.handle_key(key(KeyCode::Tab));
2503 assert_eq!(view.active_tab, ExtensionsTab::Marketplace);
2504 assert_eq!(view.query, "g", "the query carries over to the new tab");
2505
2506 view.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
2507 assert_eq!(view.active_tab, ExtensionsTab::Plugins);
2508 view.handle_key(key(KeyCode::BackTab));
2509 assert_eq!(view.active_tab, ExtensionsTab::Hooks);
2510
2511 // Wraps: the last tab's next is the first.
2512 view.set_tab(ExtensionsTab::Mcp);
2513 view.handle_key(key(KeyCode::Tab));
2514 assert_eq!(view.active_tab, ExtensionsTab::Hooks);
2515 }
2516
2517 fn mcp_row(name: &str, state: &str, detail: &str, action: ExtensionAction) -> ExtensionItem {
2518 ExtensionItem {
2519 id: name.into(),
2520 label: name.into(),
2521 description: String::new(),
2522 state: state.into(),
2523 tone: ExtensionTone::Attention,
2524 detail: detail.into(),
2525 action: Some(action),
2526 toggle: None,
2527 remove: None,
2528 }
2529 }
2530
2531 fn login_row(name: &str) -> ExtensionItem {
2532 mcp_row(
2533 name,
2534 &crate::tui::session_boot::mcp_auth_required_state_label(),
2535 "401 Unauthorized: the session is no longer accepted",
2536 ExtensionAction::Command {
2537 label: "re-auth".into(),
2538 command: McpRecoveryKind::Reauth.slash_command(name),
2539 disposition: RowActionDisposition::LeavePanel,
2540 },
2541 )
2542 }
2543
2544 /// The founder's receipt (#5926): seven OAuth servers whose login
2545 /// expired and one that really failed. The expired logins lead in their
2546 /// own group with the login command on the row; the real failure keeps
2547 /// its reason; the connected server sorts last.
2548 #[test]
2549 fn mcp_rows_list_expired_logins_first_then_failures_with_their_reason() {
2550 let rows = vec![
2551 mcp_row(
2552 "alpha",
2553 "connected",
2554 "3 tools",
2555 ExtensionAction::Status {
2556 label: "connected".into(),
2557 },
2558 ),
2559 mcp_row(
2560 "supabase",
2561 "error",
2562 "OAuth token refresh failed: Failed to parse server response",
2563 ExtensionAction::Command {
2564 label: "diagnose".into(),
2565 command: McpRecoveryKind::Diagnose.slash_command("supabase"),
2566 disposition: RowActionDisposition::InPlace,
2567 },
2568 ),
2569 login_row("slack"),
2570 login_row("stripe"),
2571 ];
2572 let groups = mcp_groups(Locale::En, rows);
2573 let shape: Vec<(&str, Vec<&str>)> = groups
2574 .iter()
2575 .map(|group| {
2576 (
2577 group.id.as_str(),
2578 group.items.iter().map(|item| item.label.as_str()).collect(),
2579 )
2580 })
2581 .collect();
2582 assert_eq!(
2583 shape,
2584 vec![
2585 ("login", vec!["slack", "stripe"]),
2586 ("attention", vec!["supabase"]),
2587 ("servers", vec!["alpha"]),
2588 ]
2589 );
2590 assert_eq!(groups[0].label, "Needs login");
2591 assert_eq!(groups[1].label, "Needs attention");
2592 let slack = &groups[0].items[0];
2593 assert_eq!(
2594 slack.action.as_ref().and_then(ExtensionAction::command),
2595 Some("/mcp login slack")
2596 );
2597 assert!(!slack.state.contains("failed"), "{}", slack.state);
2598 assert_eq!(
2599 groups[1].items[0].detail,
2600 "OAuth token refresh failed: Failed to parse server response"
2601 );
2602 }
2603
2604 /// Opening `/mcp` lands on the first server that needs a login, so Enter
2605 /// is the login key, not a fold of the group heading. A tab without a
2606 /// login group but no items keeps the empty-state selection.
2607 #[test]
2608 fn mcp_tab_opens_on_the_first_login_row() {
2609 let mut snapshot = ExtensionsSnapshot::default();
2610 snapshot.tabs[ExtensionsTab::Mcp.index()] = ExtensionsTabModel {
2611 groups: mcp_groups(Locale::En, vec![login_row("slack"), login_row("stripe")]),
2612 problem: None,
2613 };
2614 let view = ExtensionsView::from_snapshot_with_locale(
2615 snapshot.clone(),
2616 ExtensionsTab::Mcp,
2617 Locale::En,
2618 );
2619 assert_eq!(view.selected[ExtensionsTab::Mcp.index()], 1);
2620 let entries = view.visible_entries();
2621 match entries[1] {
2622 VisibleEntry::Item(group, item) => {
2623 assert_eq!(group.id, MCP_LOGIN_GROUP_ID);
2624 assert_eq!(item.label, "slack");
2625 assert_eq!(
2626 item.action.as_ref().and_then(ExtensionAction::command),
2627 Some("/mcp login slack")
2628 );
2629 }
2630 other => panic!("expected the first login row, got {other:?}"),
2631 }
2632
2633 let plain = ExtensionsView::from_snapshot_with_locale(
2634 ExtensionsSnapshot::default(),
2635 ExtensionsTab::Mcp,
2636 Locale::En,
2637 );
2638 assert_eq!(plain.selected[ExtensionsTab::Mcp.index()], 0);
2639 }
2640
2641 fn item_with_action(action: ExtensionAction) -> ExtensionItem {
2642 ExtensionItem {
2643 id: "row".into(),
2644 label: "row".into(),
2645 description: String::new(),
2646 state: "state".into(),
2647 tone: ExtensionTone::Idle,
2648 detail: "detail".into(),
2649 action: Some(action),
2650 toggle: None,
2651 remove: None,
2652 }
2653 }
2654
2655 fn view_on_item(action: ExtensionAction) -> ExtensionsView {
2656 let mut snapshot = ExtensionsSnapshot::default();
2657 snapshot.tabs[ExtensionsTab::Plugins.index()] = ExtensionsTabModel {
2658 groups: vec![ExtensionGroup {
2659 id: "g".into(),
2660 label: "g".into(),
2661 items: vec![item_with_action(action)],
2662 }],
2663 problem: None,
2664 };
2665 let mut view =
2666 ExtensionsView::from_snapshot_with_locale(snapshot, ExtensionsTab::Plugins, Locale::En);
2667 // Land on the item, not its group heading.
2668 view.selected[ExtensionsTab::Plugins.index()] = 1;
2669 view
2670 }
2671
2672 /// The defect: every row closed the panel and dropped its command into
2673 /// the transcript. A mutation runs in place — the event carries the
2674 /// command, and `Emit` (not `EmitAndClose`) is what keeps the panel.
2675 #[test]
2676 fn in_place_row_action_emits_without_closing() {
2677 let mut view = view_on_item(ExtensionAction::Command {
2678 label: "enable".into(),
2679 command: "/plugin enable demo".into(),
2680 disposition: RowActionDisposition::InPlace,
2681 });
2682 match view.activate_selected() {
2683 ViewAction::Emit(ViewEvent::ExecutePanelCommand {
2684 command,
2685 pager_title,
2686 }) => {
2687 assert_eq!(command, "/plugin enable demo");
2688 assert_eq!(pager_title, None);
2689 }
2690 other => panic!("expected an in-place command, got {other:?}"),
2691 }
2692 }
2693
2694 /// An inspect row keeps the panel open and asks for its text output in a
2695 /// pager stacked on the panel — the detail belongs to the row, not to a
2696 /// transcript dump behind the modal.
2697 #[test]
2698 fn inspect_row_action_pages_its_output_in_place() {
2699 let mut view = view_on_item(ExtensionAction::Command {
2700 label: "open".into(),
2701 command: "/plugin show demo".into(),
2702 disposition: RowActionDisposition::InPlacePager,
2703 });
2704 match view.activate_selected() {
2705 ViewAction::Emit(ViewEvent::ExecutePanelCommand {
2706 command,
2707 pager_title,
2708 }) => {
2709 assert_eq!(command, "/plugin show demo");
2710 assert_eq!(pager_title.as_deref(), Some("row"));
2711 }
2712 other => panic!("expected a paged inspect, got {other:?}"),
2713 }
2714 }
2715
2716 /// A flow that owns another surface — a login, an editor, the composer's
2717 /// trust token — still yields the panel.
2718 #[test]
2719 fn leave_panel_row_action_still_closes() {
2720 let mut view = view_on_item(ExtensionAction::Command {
2721 label: "re-auth".into(),
2722 command: "/mcp login github".into(),
2723 disposition: RowActionDisposition::LeavePanel,
2724 });
2725 match view.activate_selected() {
2726 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
2727 action: CommandPaletteAction::ExecuteCommand { command },
2728 }) => assert_eq!(command, "/mcp login github"),
2729 other => panic!("expected the panel to yield, got {other:?}"),
2730 }
2731 }
2732
2733 /// A refresh swaps the read model without disturbing the session: tab,
2734 /// query, selection, and folds all survive, and the new MCP generation
2735 /// the poll compares against rides along.
2736 #[test]
2737 fn refresh_preserves_view_state_and_tracks_generation() {
2738 let mut view = view_on_item(ExtensionAction::Command {
2739 label: "enable".into(),
2740 command: "/plugin enable demo".into(),
2741 disposition: RowActionDisposition::InPlace,
2742 });
2743 view.query = "de".into();
2744 let mut fresh = ExtensionsSnapshot {
2745 mcp_generation: 7,
2746 mcp_initializing: true,
2747 ..ExtensionsSnapshot::default()
2748 };
2749 fresh.tabs[ExtensionsTab::Plugins.index()] = ExtensionsTabModel {
2750 groups: vec![ExtensionGroup {
2751 id: "g".into(),
2752 label: "g".into(),
2753 items: vec![item_with_action(ExtensionAction::Status {
2754 label: "enabled".into(),
2755 })],
2756 }],
2757 problem: None,
2758 };
2759 view.refresh_snapshot(fresh);
2760 assert_eq!(view.active_tab, ExtensionsTab::Plugins);
2761 assert_eq!(view.query, "de");
2762 assert_eq!(view.snapshot.mcp_generation, 7);
2763 assert!(view.snapshot.mcp_initializing);
2764 // The refreshed row's action is the new model's, not the stale one.
2765 let entries = view.visible_entries();
2766 match entries[view.selected[ExtensionsTab::Plugins.index()]] {
2767 VisibleEntry::Item(_, item) => {
2768 assert!(matches!(item.action, Some(ExtensionAction::Status { .. })));
2769 }
2770 other => panic!("expected the refreshed item, got {other:?}"),
2771 }
2772 }
2773 }
2774
2774 lines RUST