返回 CodeWhale
plugin_suggestions.rs
根目录 / crates / tui / src / tui / plugin_suggestions.rs
1 //! In-context plugin reminders: the send-time toast, the model-requested
2 //! review row, and idle catalog polling.
3 //!
4 //! 0.10.1 plugin offering policy ("helpful, not pushy"):
5 //! - The only unprompted surface is the send-time toast. There is no live
6 //! as-you-type matching.
7 //! - The review row appears only when the model calls `request_plugin_install`
8 //! (once per session), and only while contextual tips are on and the shared
9 //! per-session guidance budget has room.
10 //! - The row offers Review. Only that button opens the existing Extensions
11 //! inventory: installed bundles on Plugins, other suggestions on Marketplace.
12 //! Install, exact-content trust, and enable remain explicit row actions.
13 //! - Esc hides the row for this session only. "Don't suggest again" is the
14 //! explicit, persisted dismissal.
15
16 use std::collections::BTreeSet;
17 use std::time::{Duration, Instant};
18
19 use ratatui::buffer::Buffer;
20 use ratatui::layout::Rect;
21 use ratatui::style::Style;
22 use ratatui::text::{Line, Span};
23 use ratatui::widgets::{Block, Widget};
24 use unicode_width::UnicodeWidthStr;
25
26 use crate::plugins::recommend::{
27 PluginNextStep, load_marketplace_candidates, match_plugin_for_draft,
28 };
29 use crate::tui::app::{App, StatusToast, StatusToastKind, StatusToastLevel};
30 use codewhale_localization::{MessageId, tr};
31
32 const CATALOG_POLL_INTERVAL: Duration = Duration::from_secs(2);
33
34 #[derive(Debug, Clone, PartialEq, Eq)]
35 pub enum PluginCtaPhase {
36 Hidden,
37 Matched { name: String },
38 }
39
40 impl PluginCtaPhase {
41 #[must_use]
42 pub fn is_visible(&self) -> bool {
43 matches!(self, Self::Matched { .. })
44 }
45
46 #[must_use]
47 pub fn matched_name(&self) -> Option<&str> {
48 match self {
49 Self::Hidden => None,
50 Self::Matched { name, .. } => Some(name.as_str()),
51 }
52 }
53 }
54
55 #[derive(Debug, Clone)]
56 pub struct PluginCtaState {
57 pub phase: PluginCtaPhase,
58 /// Lowercased names hidden from every proactive path: persisted "Don't
59 /// suggest again" choices plus this session's Esc dismissals.
60 pub dismissed: BTreeSet<String>,
61 }
62
63 impl Default for PluginCtaState {
64 fn default() -> Self {
65 Self {
66 phase: PluginCtaPhase::Hidden,
67 dismissed: BTreeSet::new(),
68 }
69 }
70 }
71
72 impl PluginCtaState {
73 pub(crate) fn from_settings(settings: &crate::settings::Settings) -> Self {
74 Self {
75 dismissed: settings
76 .dismissed_plugin_suggestions
77 .iter()
78 .map(|name| name.to_ascii_lowercase())
79 .collect(),
80 ..Self::default()
81 }
82 }
83 }
84
85 impl App {
86 /// When the user sends a task that matches an installed-but-idle plugin
87 /// or a locally added marketplace candidate, toast the next review step
88 /// once. Never installs, trusts, or enables anything.
89 pub fn maybe_nudge_plugin_for_prompt(&mut self, input: &str) -> bool {
90 if !self.behavioral_tips.guidance_available() {
91 return false;
92 }
93 let marketplace = load_marketplace_candidates(self.plugin_registry.state_path());
94 let Some(recommendation) = match_plugin_for_draft(
95 input,
96 self.plugin_registry.as_ref(),
97 &marketplace,
98 &self.plugin_cta.dismissed,
99 ) else {
100 return false;
101 };
102 let message_id = match recommendation.next_step {
103 PluginNextStep::Trust => MessageId::PluginPromptSuggestTrust,
104 PluginNextStep::Enable => MessageId::PluginPromptSuggestEnable,
105 PluginNextStep::MarketplaceInstall { .. } => MessageId::PluginPromptSuggestMarketplace,
106 PluginNextStep::AlreadyActive
107 | PluginNextStep::Inspect
108 | PluginNextStep::SourceInstall { .. } => return false,
109 };
110 let mut message = tr(self.ui_locale, message_id).replace("{name}", &recommendation.name);
111 if let PluginNextStep::MarketplaceInstall { catalog_id } = &recommendation.next_step {
112 message = message.replace("{catalog}", catalog_id);
113 }
114 if let Some(term) = recommendation.matched_term {
115 message.push_str(" · ");
116 message.push_str(
117 &tr(self.ui_locale, MessageId::PluginSuggestionReason).replace("{trigger}", &term),
118 );
119 }
120 self.behavioral_tips.record_guidance_impression();
121 let mut toast = StatusToast::new(message, StatusToastLevel::Info, Some(8_000));
122 toast.kind = StatusToastKind::PluginSuggestion;
123 self.push_status_toast_record(toast);
124 true
125 }
126
127 /// Cheap idle poll so on-disk plugin changes can surface between turns,
128 /// not only on send. Fingerprints directories; never auto-reloads.
129 pub fn maybe_poll_plugin_catalog_idle(&mut self) {
130 let now = Instant::now();
131 if self
132 .last_plugin_catalog_poll
133 .is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
134 {
135 return;
136 }
137 self.last_plugin_catalog_poll = Some(now);
138 if let Some(message) = crate::plugins::plugin_reload_nudge(
139 self.plugin_registry.as_ref(),
140 &mut self.plugin_reload_nudge_stamp,
141 ) {
142 self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
143 self.needs_redraw = true;
144 }
145 }
146
147 #[must_use]
148 pub fn plugin_cta_row_height(&self) -> u16 {
149 u16::from(self.plugin_cta.phase.is_visible())
150 }
151
152 /// Esc: hide the row and skip this plugin for the rest of the session.
153 /// Persists nothing, so the next session may offer it again.
154 pub fn dismiss_plugin_cta_for_session(&mut self) -> bool {
155 let Some(name) = self
156 .plugin_cta
157 .phase
158 .matched_name()
159 .map(str::to_ascii_lowercase)
160 else {
161 return false;
162 };
163 self.plugin_cta.dismissed.insert(name);
164 self.plugin_cta.phase = PluginCtaPhase::Hidden;
165 self.needs_redraw = true;
166 true
167 }
168
169 /// "Don't suggest again": the explicit, persisted dismissal. Also hides
170 /// the row immediately for this session, even if saving fails.
171 pub fn dismiss_plugin_cta(&mut self) -> bool {
172 let Some(name) = self.plugin_cta.phase.matched_name().map(str::to_string) else {
173 return false;
174 };
175 let name = name.to_ascii_lowercase();
176 self.plugin_cta.dismissed.insert(name.clone());
177 self.plugin_cta.phase = PluginCtaPhase::Hidden;
178 self.needs_redraw = true;
179 if let Err(error) = crate::settings::Settings::transact_opt(|settings| {
180 Ok(settings
181 .dismissed_plugin_suggestions
182 .insert(name)
183 .then_some(()))
184 }) {
185 tracing::warn!(%error, "could not persist plugin suggestion dismissal");
186 self.push_status_toast(
187 tr(self.ui_locale, MessageId::PluginCtaDismissSaveFailed).into_owned(),
188 StatusToastLevel::Warning,
189 Some(8_000),
190 );
191 }
192 true
193 }
194
195 /// Human-initiated navigation to the existing inventory. Resolve the tab
196 /// from current installed facts, never from the model-returned command.
197 /// The inventory retains its own explicit install/review/enable actions.
198 #[must_use]
199 pub fn accept_plugin_cta_review(
200 &mut self,
201 ) -> Option<crate::tui::views::extensions::ExtensionsTab> {
202 let name = match &self.plugin_cta.phase {
203 PluginCtaPhase::Matched { name } => name.clone(),
204 PluginCtaPhase::Hidden => return None,
205 };
206 let tab = if self.plugin_registry.get(&name).is_some() {
207 crate::tui::views::extensions::ExtensionsTab::Plugins
208 } else {
209 crate::tui::views::extensions::ExtensionsTab::Marketplace
210 };
211 self.plugin_cta.dismissed.insert(name.to_ascii_lowercase());
212 self.plugin_cta.phase = PluginCtaPhase::Hidden;
213 self.needs_redraw = true;
214 Some(tab)
215 }
216
217 /// Model-requested review: show the review row and a toast naming the
218 /// command. Does not run it, so nothing is installed, trusted, or
219 /// enabled. Obeys the tips switch and draws from the shared per-session
220 /// guidance budget like every other proactive offer.
221 pub fn surface_plugin_review_request(&mut self, name: &str, command: &str) {
222 if name.trim().is_empty()
223 || command.trim().is_empty()
224 || !self.behavioral_tips.guidance_available()
225 || self
226 .plugin_cta
227 .dismissed
228 .contains(&name.to_ascii_lowercase())
229 {
230 return;
231 }
232 self.behavioral_tips.record_guidance_impression();
233 self.plugin_cta.phase = PluginCtaPhase::Matched {
234 name: name.to_string(),
235 };
236 let mut toast = StatusToast::new(command.to_string(), StatusToastLevel::Info, Some(8_000));
237 toast.kind = StatusToastKind::PluginSuggestion;
238 self.push_status_toast_record(toast);
239 self.needs_redraw = true;
240 }
241 }
242
243 /// Draw the one-line review row above the composer. No-op when hidden.
244 pub fn draw_plugin_cta(app: &mut App, area: Rect, buf: &mut Buffer) {
245 app.viewport.last_plugin_cta_area = None;
246 app.viewport.last_plugin_cta_review_area = None;
247 app.viewport.last_plugin_cta_dismiss_area = None;
248 let PluginCtaPhase::Matched { name } = &app.plugin_cta.phase else {
249 return;
250 };
251 let name = name.clone();
252 if area.height == 0 || area.width == 0 {
253 return;
254 }
255 let prompt = tr(app.ui_locale, MessageId::PluginCtaInstallPrompt).replace("{name}", &name);
256 let review = tr(app.ui_locale, MessageId::PluginCtaReview);
257 let dismiss = tr(app.ui_locale, MessageId::PluginCtaDismiss);
258 let review_label = format!("[{review}]");
259 let dismiss_label = format!("[{dismiss}]");
260 let review_w = review_label.width() as u16;
261 let dismiss_w = dismiss_label.width() as u16;
262 let gap = 1u16;
263 let right_w = review_w.saturating_add(gap).saturating_add(dismiss_w);
264 let bg = Style::default().bg(app.ui_theme.composer_bg);
265 Block::default().style(bg).render(area, buf);
266 let left_budget = if area.width > right_w.saturating_add(1) {
267 area.width - right_w - 1
268 } else {
269 area.width
270 };
271 let left = Line::from(vec![Span::styled(
272 prompt,
273 Style::default().fg(app.ui_theme.text_hint),
274 )]);
275 buf.set_line(area.x, area.y, &left, left_budget);
276 if area.width <= right_w {
277 app.viewport.last_plugin_cta_area = Some(area);
278 return;
279 }
280 let review_x = area.x + area.width - right_w;
281 let dismiss_x = review_x + review_w + gap;
282 buf.set_stringn(
283 review_x,
284 area.y,
285 &review_label,
286 usize::from(review_w),
287 Style::default().fg(app.ui_theme.accent_action),
288 );
289 buf.set_stringn(
290 dismiss_x,
291 area.y,
292 &dismiss_label,
293 usize::from(dismiss_w),
294 Style::default().fg(app.ui_theme.text_hint),
295 );
296 app.viewport.last_plugin_cta_area = Some(area);
297 app.viewport.last_plugin_cta_review_area = Some(Rect::new(review_x, area.y, review_w, 1));
298 app.viewport.last_plugin_cta_dismiss_area = Some(Rect::new(dismiss_x, area.y, dismiss_w, 1));
299 }
300
301 #[cfg(test)]
302 mod tests {
303 use super::*;
304 use crate::config::Config;
305 use crate::tui::app::TuiOptions;
306 use codewhale_localization::Locale;
307 use std::fs;
308 use tempfile::TempDir;
309
310 fn app_with_supabase_plugin() -> (App, TempDir, crate::test_support::EnvVarGuard) {
311 let root = TempDir::new().unwrap();
312 let home =
313 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
314 let bundle = root.path().join(".codewhale/plugins/supabase");
315 fs::create_dir_all(&bundle).unwrap();
316 fs::write(
317 bundle.join("plugin.toml"),
318 "schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\"]\n",
319 )
320 .unwrap();
321 let temp = TempDir::new().unwrap();
322 let options = TuiOptions {
323 config_path: Some(temp.path().join("config.toml")),
324 skills_dir: temp.path().join("skills"),
325 memory_path: temp.path().join("memory.md"),
326 notes_path: temp.path().join("notes.txt"),
327 mcp_config_path: temp.path().join("mcp.json"),
328 ..crate::test_support::test_tui_options(root.path())
329 };
330 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
331 let registry = discovery.registry_for_workspace(root.path());
332 let mut app = App::new_with_plugin_registry(options, &Config::default(), registry);
333 app.ui_locale = Locale::En;
334 (app, root, home)
335 }
336
337 #[test]
338 fn sending_a_supabase_prompt_toasts_trust_for_an_installed_idle_plugin() {
339 let _lock = crate::test_support::lock_test_env();
340 let (mut app, _root, _home) = app_with_supabase_plugin();
341
342 assert!(app.maybe_nudge_plugin_for_prompt("add supabase auth to login"));
343 assert_eq!(app.status_toasts.len(), 1);
344 assert!(
345 app.status_toasts[0].text.contains("/plugin trust supabase"),
346 "{}",
347 app.status_toasts[0].text
348 );
349 assert!(!app.maybe_nudge_plugin_for_prompt("add supabase auth to login"));
350 }
351
352 #[test]
353 fn optional_plugin_and_behavioral_guidance_share_one_session_budget() {
354 use crate::tui::behavioral_tips::BehavioralTip;
355 let _lock = crate::test_support::lock_test_env();
356 for plugin_first in [true, false] {
357 let (mut app, _root, _home) = app_with_supabase_plugin();
358 if plugin_first {
359 assert!(app.maybe_nudge_plugin_for_prompt("add supabase auth"));
360 assert!(!app.maybe_show_behavioral_tip(BehavioralTip::McpValidation));
361 } else {
362 assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation));
363 assert!(!app.maybe_nudge_plugin_for_prompt("add supabase auth"));
364 }
365 assert_eq!(app.status_toasts.len(), 1);
366 }
367 }
368
369 #[test]
370 fn tips_off_removes_every_plugin_offer_but_preserves_required_notices() {
371 let _lock = crate::test_support::lock_test_env();
372 let (mut app, _root, _home) = app_with_supabase_plugin();
373 app.set_contextual_tips_enabled(false);
374 assert!(!app.maybe_nudge_plugin_for_prompt("add supabase auth"));
375 app.surface_plugin_review_request("supabase", "/plugin trust supabase");
376 assert!(
377 !app.plugin_cta.phase.is_visible(),
378 "tips off: no review row, even when the model asks"
379 );
380 assert_eq!(app.plugin_cta_row_height(), 0);
381 assert!(app.status_toasts.is_empty());
382
383 app.set_contextual_tips_enabled(true);
384 app.surface_plugin_review_request("supabase", "/plugin trust supabase");
385 assert!(app.plugin_cta.phase.is_visible());
386 app.push_status_toast_record(
387 StatusToast::new("Review required", StatusToastLevel::Warning, None).for_action("a"),
388 );
389 app.push_status_toast("Keep this error", StatusToastLevel::Error, None);
390 app.set_contextual_tips_enabled(false);
391 assert!(
392 !app.plugin_cta.phase.is_visible(),
393 "turning tips off hides the row"
394 );
395 assert_eq!(app.status_toasts.len(), 2);
396 assert!(
397 app.status_toasts
398 .iter()
399 .all(|toast| toast.kind != StatusToastKind::PluginSuggestion)
400 );
401 app.set_contextual_tips_enabled(true);
402 assert!(
403 !app.maybe_nudge_plugin_for_prompt("add supabase auth"),
404 "reenabling must not reset the shared cap"
405 );
406 }
407
408 #[test]
409 fn model_requested_review_draws_from_the_shared_budget() {
410 let _lock = crate::test_support::lock_test_env();
411 let (mut app, _root, _home) = app_with_supabase_plugin();
412 assert!(app.maybe_nudge_plugin_for_prompt("add supabase auth"));
413 app.surface_plugin_review_request("supabase", "/plugin trust supabase");
414 assert!(
415 !app.plugin_cta.phase.is_visible(),
416 "the send-time toast already spent this session's budget"
417 );
418 }
419
420 #[test]
421 fn typing_a_matching_draft_never_shows_a_row() {
422 let _lock = crate::test_support::lock_test_env();
423 let (mut app, _root, _home) = app_with_supabase_plugin();
424 app.input = "add supabase auth to login".to_string();
425 app.maybe_poll_plugin_catalog_idle();
426 assert!(!app.plugin_cta.phase.is_visible());
427 assert_eq!(app.plugin_cta_row_height(), 0);
428 }
429
430 #[test]
431 fn review_row_opens_installed_inventory_without_running_suggested_commands() {
432 let _lock = crate::test_support::lock_test_env();
433 for command in [
434 "/plugin trust supabase",
435 "/plugin enable supabase",
436 "/plugin marketplace install official supabase",
437 ] {
438 let (mut app, _root, _home) = app_with_supabase_plugin();
439 let before = app.plugin_registry.get("supabase").unwrap().clone();
440 app.surface_plugin_review_request("supabase", command);
441 assert_eq!(
442 app.plugin_cta.phase,
443 PluginCtaPhase::Matched {
444 name: "supabase".into(),
445 }
446 );
447 let area = Rect::new(0, 0, 140, 1);
448 let mut buffer = Buffer::empty(area);
449 draw_plugin_cta(&mut app, area, &mut buffer);
450 let row = buffer
451 .content
452 .iter()
453 .map(|cell| cell.symbol())
454 .collect::<String>();
455 assert!(row.contains("supabase"), "{row}");
456 assert!(row.contains("[Review]"), "{row}");
457 assert!(row.contains("[Don't suggest again]"), "{row}");
458 assert_eq!(
459 app.accept_plugin_cta_review(),
460 Some(crate::tui::views::extensions::ExtensionsTab::Plugins),
461 "accepting opens the installed inventory, never {command}"
462 );
463 let after = app.plugin_registry.get("supabase").unwrap();
464 assert_eq!(after.trust_status, before.trust_status);
465 assert_eq!(after.enabled, before.enabled);
466 assert_eq!(after.active(), before.active());
467 assert!(!app.plugin_cta.phase.is_visible());
468 }
469 }
470
471 #[test]
472 fn uninstalled_review_opens_marketplace_without_installing_or_running_commands() {
473 let _lock = crate::test_support::lock_test_env();
474 let (mut app, _root, _home) = app_with_supabase_plugin();
475 let before = app.plugin_registry.list().len();
476 app.surface_plugin_review_request("catalog-only", "/plugin enable supabase");
477 assert!(app.plugin_registry.get("catalog-only").is_none());
478 assert_eq!(
479 app.accept_plugin_cta_review(),
480 Some(crate::tui::views::extensions::ExtensionsTab::Marketplace)
481 );
482 assert!(app.plugin_registry.get("catalog-only").is_none());
483 assert_eq!(app.plugin_registry.list().len(), before);
484 assert!(!app.plugin_registry.get("supabase").unwrap().enabled);
485 assert_eq!(app.accept_plugin_cta_review(), None);
486 }
487
488 #[test]
489 fn esc_clears_a_draft_first_then_dismisses_for_the_session_only() {
490 use crate::settings::Settings;
491 use crate::tui::composer_ui::{EscapeAction, next_escape_action};
492 let _lock = crate::test_support::lock_test_env();
493 let (mut app, root, _home) = app_with_supabase_plugin();
494 app.surface_plugin_review_request("supabase", "/plugin trust supabase");
495 app.input = "half-written draft".into();
496 assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);
497 app.input.clear();
498 assert_eq!(
499 next_escape_action(&app, false),
500 EscapeAction::DismissPluginCta
501 );
502
503 assert!(app.dismiss_plugin_cta_for_session());
504 assert!(!app.plugin_cta.phase.is_visible());
505 assert!(!app.maybe_nudge_plugin_for_prompt("add supabase auth"));
506 let saved = Settings::load_read_only().unwrap_or_default();
507 assert!(
508 saved.dismissed_plugin_suggestions.is_empty(),
509 "Esc persists nothing"
510 );
511 let restarted = App::new_with_plugin_registry(
512 crate::test_support::test_tui_options(root.path()),
513 &Config::default(),
514 app.plugin_registry.clone(),
515 );
516 assert!(!restarted.plugin_cta.dismissed.contains("supabase"));
517 }
518
519 #[test]
520 fn dismissal_survives_restart_and_all_proactive_paths_preserving_settings() {
521 use crate::settings::Settings;
522 let _lock = crate::test_support::lock_test_env();
523 let (mut app, root, _home) = app_with_supabase_plugin();
524 Settings::transact(|settings| settings.set("max_history", "321")).unwrap();
525 app.surface_plugin_review_request("supabase", "/plugin trust supabase");
526 assert!(app.dismiss_plugin_cta());
527 let saved = Settings::load_read_only().unwrap();
528 assert_eq!(saved.max_input_history, 321);
529 assert!(saved.dismissed_plugin_suggestions.contains("supabase"));
530 // A freshly initialized App must hydrate the persisted preference.
531 let mut restarted = App::new_with_plugin_registry(
532 crate::test_support::test_tui_options(root.path()),
533 &Config::default(),
534 app.plugin_registry.clone(),
535 );
536 assert!(!restarted.maybe_nudge_plugin_for_prompt("add supabase auth to login"));
537 restarted.surface_plugin_review_request("supabase", "/plugin trust supabase");
538 assert!(!restarted.plugin_cta.phase.is_visible());
539 assert!(
540 crate::plugins::recommend::lookup_reviewable_plugin(
541 "supabase",
542 restarted.plugin_registry.as_ref(),
543 &[],
544 )
545 .is_some(),
546 "manual plugin commands remain available"
547 );
548 }
549
550 #[test]
551 fn failed_dismissal_save_preserves_malformed_preferences_and_hides_this_session() {
552 let _lock = crate::test_support::lock_test_env();
553 let (mut app, root, _home) = app_with_supabase_plugin();
554 app.surface_plugin_review_request("supabase", "/plugin trust supabase");
555 let path = crate::settings::Settings::path().unwrap();
556 assert!(path.starts_with(root.path()));
557 fs::create_dir_all(path.parent().unwrap()).unwrap();
558 let malformed = "theme = [private_fixture_payload\n";
559 fs::write(&path, malformed).unwrap();
560 assert!(app.dismiss_plugin_cta());
561 assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
562 assert!(!app.plugin_cta.phase.is_visible());
563 let toast = app.status_toasts.back().expect("save failure receipt");
564 assert!(toast.text.contains("could not save"));
565 assert!(!toast.text.contains("private_fixture_payload"));
566 }
567 }
568
568 lines RUST