feat(plugins): list and reset suggestion dismissals (policy rule 9)

Plugin offering policy rule 9 says dismissal is reversible: "Don't suggest
again" persisted a name forever, and there was no way to see or undo it, nor
the session-only Esc dismissals.

- /plugin dismissals lists the plugins proactive suggestions skip, split
  into "Don't suggest again (kept across sessions)" and "This session only".
- /plugin dismissals reset [<name>] clears one name (case-insensitive) or
  all, from the saved settings and from this session's set, and names what
  it cleared. Manual /plugin commands are unaffected either way.
- CommandPluginContext gains suggestion_dismissals and
  reset_suggestion_dismissals (portable PluginSuggestionDismissals), so the
  handler stays on the command contract; the TUI adapter reads and writes
  Settings.dismissed_plugin_suggestions through Settings::transact_opt and
  the live App.plugin_cta.dismissed set.

docs/PLUGINS.md names both commands under reversible dismissal.

No-Issue: 0.10.1 addendum plugin policy rule 9 (PLG-12)

Checks (targeted, local, shared dirty tree):
- cargo test -p codewhale-tui --lib -- plugin_dismissals_list: 1 passed, 0 failed
- cargo test -p codewhale-command-contract: 59 passed, 0 failed
- cargo test -p codewhale-tui --lib -- commands::groups::plugins (within the 448-test touched-module run): 0 failed

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 18:40:51 -07:00
1 parent b90590369a
commit 735fb509a8
6 files changed
+182 -2

No files matched your search

+16
View File
@@ -581,6 +581,16 @@ pub struct PluginSuggestion {
pub next_step: String,
}
/// Plugins hidden from proactive suggestions (plugin policy rule 9). Names
/// are lowercase; each list is sorted.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginSuggestionDismissals {
/// "Don't suggest again": kept across sessions until reset.
pub persisted: Vec<String>,
/// Hidden for this session only (Esc, or a review the user already opened).
pub session: Vec<String>,
}
/// Host plugin data for the plugin command group (FEAT-020 D1).
///
/// One object-safe, synchronous facet exposing the exact-minimum typed
@@ -671,6 +681,12 @@ pub trait CommandPluginContext {
catalog: &str,
candidate: &str,
) -> Result<PluginMutationReceipt, String>;
/// Read-only: which plugins proactive suggestions currently skip.
fn suggestion_dismissals(&self) -> Result<PluginSuggestionDismissals, String>;
/// Mutation: let suggestions offer `name` again (every dismissed plugin
/// when `None`), in this session and future ones. Returns the names
/// cleared, sorted.
fn reset_suggestion_dismissals(&mut self, name: Option<&str>) -> Result<Vec<String>, String>;
}
// ---------------------------------------------------------------------------
+8
View File
@@ -1185,6 +1185,14 @@ impl CommandPluginContext for FakePlugin {
outcome: PluginMutationOutcome::Installed,
})
}
fn suggestion_dismissals(&self) -> Result<PluginSuggestionDismissals, String> {
Ok(PluginSuggestionDismissals::default())
}
fn reset_suggestion_dismissals(&mut self, _name: Option<&str>) -> Result<Vec<String>, String> {
Ok(Vec::new())
}
}
#[test]
+49
View File
@@ -4198,6 +4198,55 @@ impl CommandPluginContext for PluginAdapter<'_> {
drop(app);
self.install(&spec, None)
}
fn suggestion_dismissals(
&self,
) -> Result<codewhale_command_contract::facets::PluginSuggestionDismissals, String> {
let persisted = crate::settings::Settings::load()
.map_err(|err| format!("could not read saved plugin dismissals: {err}"))?
.dismissed_plugin_suggestions;
let app = self.host.app.borrow();
let session = app
.plugin_cta
.dismissed
.iter()
.filter(|name| !persisted.contains(*name))
.cloned()
.collect();
Ok(
codewhale_command_contract::facets::PluginSuggestionDismissals {
persisted: persisted.into_iter().collect(),
session,
},
)
}
fn reset_suggestion_dismissals(&mut self, name: Option<&str>) -> Result<Vec<String>, String> {
let target = name.map(str::to_ascii_lowercase);
let matches = |candidate: &String| target.as_ref().is_none_or(|target| candidate == target);
let mut cleared = std::collections::BTreeSet::new();
crate::settings::Settings::transact_opt(|settings| {
let before = settings.dismissed_plugin_suggestions.len();
settings.dismissed_plugin_suggestions.retain(|candidate| {
let reset = matches(candidate);
if reset {
cleared.insert(candidate.clone());
}
!reset
});
Ok((settings.dismissed_plugin_suggestions.len() != before).then_some(()))
})
.map_err(|err| format!("could not save plugin dismissals: {err}"))?;
let mut app = self.host.app.borrow_mut();
app.plugin_cta.dismissed.retain(|candidate| {
let reset = matches(candidate);
if reset {
cleared.insert(candidate.clone());
}
!reset
});
Ok(cleared.into_iter().collect())
}
}
/// Resolve the default Codewhale tools directory (mirrors the legacy handler).
+56 -1
View File
@@ -65,7 +65,7 @@ impl CommandGroup for PluginsCommands {
pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo {
name: "plugin",
aliases: &["plugins", "extensions"],
usage: "/plugin [list|show|suggest|validate|export|install|import|update|uninstall|trust|enable|disable|revoke|reload|tools|marketplace]",
usage: "/plugin [list|show|suggest|validate|export|install|import|update|uninstall|trust|enable|disable|revoke|reload|tools|marketplace|dismissals]",
description_key: "cmd_plugin_description",
};
@@ -184,6 +184,10 @@ pub(super) fn plugins(
["disable", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Disable),
["revoke", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Revoke),
["reload"] => reload(presentation, plugin),
["dismissals"] => list_dismissals(plugin),
["dismissals", "reset"] => reset_dismissals(plugin, None),
["dismissals", "reset", name] => reset_dismissals(plugin, Some(name)),
["dismissals", ..] => CommandResult::error("Usage: /plugin dismissals [reset [<name>]]"),
["tools"] => legacy_tools(presentation, plugin, None),
["tools", name] => legacy_tools(presentation, plugin, Some(name)),
[selector] => {
@@ -199,6 +203,57 @@ pub(super) fn plugins(
}
}
/// `/plugin dismissals`: which plugins suggestions skip, and for how long
/// (plugin policy rule 9: dismissal is reversible).
fn list_dismissals(plugin: &dyn CommandPluginContext) -> CommandResult {
let dismissals = match plugin.suggestion_dismissals() {
Ok(dismissals) => dismissals,
Err(error) => return CommandResult::error(error),
};
if dismissals.persisted.is_empty() && dismissals.session.is_empty() {
return CommandResult::message("No plugins are hidden from suggestions.".to_string());
}
let mut output = String::from("Plugins hidden from suggestions:\n");
if !dismissals.persisted.is_empty() {
output.push_str(" Don't suggest again (kept across sessions):\n");
for name in &dismissals.persisted {
let _ = writeln!(output, " {}", escape_review_text(name));
}
}
if !dismissals.session.is_empty() {
output.push_str(" This session only:\n");
for name in &dismissals.session {
let _ = writeln!(output, " {}", escape_review_text(name));
}
}
output.push_str(
"\nReset with /plugin dismissals reset [<name>]. Manual /plugin commands work either way.",
);
CommandResult::message(output)
}
/// `/plugin dismissals reset [<name>]`: let suggestions offer a plugin again.
fn reset_dismissals(plugin: &mut dyn CommandPluginContext, name: Option<&str>) -> CommandResult {
match plugin.reset_suggestion_dismissals(name) {
Ok(cleared) if cleared.is_empty() => CommandResult::message(match name {
Some(name) => format!(
"`{}` was not hidden from suggestions.",
escape_review_text(name)
),
None => "No plugins were hidden from suggestions.".to_string(),
}),
Ok(cleared) => CommandResult::message(format!(
"Suggestions may offer {} again.",
cleared
.iter()
.map(|name| escape_review_text(name))
.collect::<Vec<_>>()
.join(", ")
)),
Err(error) => CommandResult::error(error),
}
}
/// Translate one stable plugin key through the presentation facet.
fn translate(presentation: &mut dyn CommandPresentationContext, key: &str) -> String {
presentation.translate(key, &[]).unwrap_or_default()
@@ -701,3 +701,54 @@ fn export_verb_writes_agent_plugins_bundle() {
.exists()
);
}
#[test]
fn plugin_dismissals_list_and_reset_both_kinds() {
let _lock = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let codewhale_home = root.path().join("home");
fs::create_dir_all(&codewhale_home).unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
let (mut app, _temp) = create_test_app(root.path());
crate::settings::Settings::transact_opt(|settings| {
Ok(settings
.dismissed_plugin_suggestions
.insert("keptaway".to_string())
.then_some(()))
})
.unwrap();
app.plugin_cta.dismissed.insert("keptaway".to_string());
app.plugin_cta.dismissed.insert("esconce".to_string());
let listed = plugins_with_kimi_home_override(&mut app, Some("dismissals"), None)
.message
.expect("dismissal list");
let kept = listed
.find("keptaway")
.unwrap_or_else(|| panic!("{listed}"));
let session = listed.find("esconce").unwrap_or_else(|| panic!("{listed}"));
assert!(listed.contains("Don't suggest again"), "{listed}");
assert!(listed.contains("This session only"), "{listed}");
assert!(kept < session, "{listed}");
let reset = plugins_with_kimi_home_override(&mut app, Some("dismissals reset KeptAway"), None)
.message
.expect("reset receipt");
assert!(reset.contains("keptaway"), "{reset}");
assert!(
crate::settings::Settings::load()
.unwrap()
.dismissed_plugin_suggestions
.is_empty(),
"reset must reach the saved choice"
);
assert!(!app.plugin_cta.dismissed.contains("keptaway"));
assert!(app.plugin_cta.dismissed.contains("esconce"));
plugins_with_kimi_home_override(&mut app, Some("dismissals reset"), None);
assert!(app.plugin_cta.dismissed.is_empty());
let empty = plugins_with_kimi_home_override(&mut app, Some("dismissals"), None)
.message
.expect("empty list");
assert!(empty.contains("No plugins are hidden"), "{empty}");
}
+2 -1
View File
@@ -44,7 +44,8 @@ Codewhale is helpful about plugins, not pushy. The rules:
it opens `/plugin show <name>`; it never installs, trusts, or enables.
- **Reversible dismissal.** Esc clears a non-empty draft first, then hides the
row for this session only. "Don't suggest again" is the explicit,
persisted choice.
persisted choice. `/plugin dismissals` lists both kinds, and
`/plugin dismissals reset [<name>]` lets suggestions offer a plugin again.
- **Discovery is passive.** Find new plugins in these docs,
`/plugin marketplace list`, Extensions, and the browser guide below.