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