mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 10:36:05 -10:00
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:
1 parent
b90590369a
commit
735fb509a8
6 files changed
+182
-2
No files matched your search
@@ -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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user