feat(slash): fondation du système de commandes slash pour la CLI custom — #162 (QA verte)
Introduit le contrat unifié des commandes slash consommable par le frontend, indépendant de la source (native, puis plugin plus tard) : - domain: modèle pur SlashCommand / SlashCommandSource / SlashCommandAvailability, métadonnées UI (nom, description, disponibilité, confirmation requise) et validation des noms/préfixes. - application: registry + list/filter par préfixe + plan d'exécution natif ; commandes natives /help et /clean (/clean = nettoyage de la vue de session courante ; pas de /reset distinct tant qu'aucune utilité produit ne le justifie). - backend + app-tauri: exposition du contrat via DTO/transport + tests DTO. Source neutre : les commandes contribuées par plugins (#165) transiteront par le même registry sans changer le contrat frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -65,6 +65,7 @@ pub mod remote;
|
||||
pub mod sandbox;
|
||||
pub mod session_limit;
|
||||
pub mod skill;
|
||||
pub mod slash_command;
|
||||
pub mod sprint;
|
||||
pub mod system_permissions;
|
||||
pub mod template;
|
||||
@ -108,6 +109,11 @@ pub use chat_attachment::{
|
||||
|
||||
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||
|
||||
pub use slash_command::{
|
||||
filter_slash_commands, native_slash_commands, NativeSlashCommand, SlashCommand,
|
||||
SlashCommandAvailability, SlashCommandSource,
|
||||
};
|
||||
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
|
||||
201
crates/domain/src/slash_command.rs
Normal file
201
crates/domain/src/slash_command.rs
Normal file
@ -0,0 +1,201 @@
|
||||
//! Slash-command model for the custom structured CLI surface.
|
||||
//!
|
||||
//! This module is intentionally pure: it declares the stable command metadata and
|
||||
//! validates names/prefixes, but it does not execute side effects.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// A slash-command source. Native commands are built into IdeA; plugin commands
|
||||
/// will be contributed through the same registry contract later.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SlashCommandSource {
|
||||
/// Built into IdeA.
|
||||
Native,
|
||||
/// Contributed by a plugin.
|
||||
Plugin {
|
||||
/// Opaque plugin id.
|
||||
plugin_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether a command can currently be executed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "status")]
|
||||
pub enum SlashCommandAvailability {
|
||||
/// The command can be executed now.
|
||||
Available,
|
||||
/// The command is known but cannot be executed in the current product state.
|
||||
Unavailable {
|
||||
/// Human-readable reason.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SlashCommandAvailability {
|
||||
/// Returns whether this availability allows execution.
|
||||
#[must_use]
|
||||
pub const fn is_available(&self) -> bool {
|
||||
matches!(self, Self::Available)
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in command behaviour known to IdeA.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NativeSlashCommand {
|
||||
/// Lists commands and help text.
|
||||
Help,
|
||||
/// Clears the current structured conversation view.
|
||||
Clean,
|
||||
/// Opens the profile switch flow. Implemented by ticket #164.
|
||||
Profile,
|
||||
}
|
||||
|
||||
/// UI-facing slash-command metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SlashCommand {
|
||||
/// Canonical slash name, including the leading `/`.
|
||||
pub name: String,
|
||||
/// Short UI label/description.
|
||||
pub short_description: String,
|
||||
/// Whether the UI must ask for confirmation before execution.
|
||||
pub requires_confirmation: bool,
|
||||
/// Current command availability.
|
||||
pub availability: SlashCommandAvailability,
|
||||
/// Unified native/plugin source.
|
||||
pub source: SlashCommandSource,
|
||||
/// Built-in command discriminator when `source == native`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub native: Option<NativeSlashCommand>,
|
||||
}
|
||||
|
||||
impl SlashCommand {
|
||||
/// Builds validated command metadata.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::EmptyField`] for an empty/blank name or description,
|
||||
/// and [`DomainError::Invariant`] when the name does not start with `/`.
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
short_description: impl Into<String>,
|
||||
requires_confirmation: bool,
|
||||
availability: SlashCommandAvailability,
|
||||
source: SlashCommandSource,
|
||||
native: Option<NativeSlashCommand>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
let short_description = short_description.into();
|
||||
crate::validation::non_empty(&name, "slash_command.name")?;
|
||||
crate::validation::non_empty(&short_description, "slash_command.short_description")?;
|
||||
if !name.starts_with('/') {
|
||||
return Err(DomainError::Invariant(
|
||||
"slash command name must start with '/'".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
name,
|
||||
short_description,
|
||||
requires_confirmation,
|
||||
availability,
|
||||
source,
|
||||
native,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the native IdeA slash commands in deterministic UI order.
|
||||
#[must_use]
|
||||
pub fn native_slash_commands() -> Vec<SlashCommand> {
|
||||
vec![
|
||||
SlashCommand::new(
|
||||
"/help",
|
||||
"Afficher les commandes disponibles",
|
||||
false,
|
||||
SlashCommandAvailability::Available,
|
||||
SlashCommandSource::Native,
|
||||
Some(NativeSlashCommand::Help),
|
||||
)
|
||||
.expect("valid native slash command"),
|
||||
SlashCommand::new(
|
||||
"/clean",
|
||||
"Nettoyer la conversation courante",
|
||||
false,
|
||||
SlashCommandAvailability::Available,
|
||||
SlashCommandSource::Native,
|
||||
Some(NativeSlashCommand::Clean),
|
||||
)
|
||||
.expect("valid native slash command"),
|
||||
SlashCommand::new(
|
||||
"/profile",
|
||||
"Changer le profil de l'agent",
|
||||
true,
|
||||
SlashCommandAvailability::Unavailable {
|
||||
reason: "La selection de profil est livree par le ticket #164".to_owned(),
|
||||
},
|
||||
SlashCommandSource::Native,
|
||||
Some(NativeSlashCommand::Profile),
|
||||
)
|
||||
.expect("valid native slash command"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Filters commands by a user-entered slash prefix.
|
||||
///
|
||||
/// Blank prefixes return every command. Prefixes without a leading `/` are
|
||||
/// interpreted as if the slash was present, matching composer input.
|
||||
#[must_use]
|
||||
pub fn filter_slash_commands(
|
||||
commands: impl IntoIterator<Item = SlashCommand>,
|
||||
prefix: Option<&str>,
|
||||
) -> Vec<SlashCommand> {
|
||||
let prefix = prefix.map(str::trim).filter(|p| !p.is_empty()).map(|p| {
|
||||
if p.starts_with('/') {
|
||||
p.to_owned()
|
||||
} else {
|
||||
format!("/{p}")
|
||||
}
|
||||
});
|
||||
commands
|
||||
.into_iter()
|
||||
.filter(|command| {
|
||||
prefix
|
||||
.as_ref()
|
||||
.is_none_or(|prefix| command.name.starts_with(prefix))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_registry_exposes_help_clean_and_profile_placeholder() {
|
||||
let commands = native_slash_commands();
|
||||
let names = commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["/help", "/clean", "/profile"]);
|
||||
assert!(commands[0].availability.is_available());
|
||||
assert!(commands[1].availability.is_available());
|
||||
assert!(!commands[2].availability.is_available());
|
||||
assert!(commands[2].requires_confirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_filter_accepts_with_or_without_leading_slash() {
|
||||
let names = filter_slash_commands(native_slash_commands(), Some("cl"))
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["/clean"]);
|
||||
|
||||
let names = filter_slash_commands(native_slash_commands(), Some("/pro"))
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["/profile"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user