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