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:
@ -93,6 +93,16 @@ impl ChatBridge {
|
|||||||
self.inner.scrollback(session)
|
self.inner.scrollback(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears only the retained conversation scrollback for a live session,
|
||||||
|
/// keeping any currently attached channel in place.
|
||||||
|
///
|
||||||
|
/// Returns the number of chunks removed. This powers `/clean`: the frontend
|
||||||
|
/// clears its mounted view from the command response, and future re-attaches
|
||||||
|
/// no longer replay the old conversation.
|
||||||
|
pub fn clear_scrollback(&self, session: &SessionId) -> usize {
|
||||||
|
self.inner.clear_scrollback(session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes a session's transport state **and** its retained scrollback
|
/// Removes a session's transport state **and** its retained scrollback
|
||||||
/// unconditionally (chat cell explicitly closed). Twin of
|
/// unconditionally (chat cell explicitly closed). Twin of
|
||||||
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
||||||
|
|||||||
@ -15,21 +15,23 @@ use application::{
|
|||||||
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
|
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
|
||||||
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
|
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
|
||||||
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
|
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
|
||||||
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
|
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, ExecuteSlashCommandInput,
|
||||||
GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput,
|
GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput,
|
||||||
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput,
|
||||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput,
|
GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput,
|
||||||
ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions,
|
ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput,
|
||||||
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
|
ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput,
|
||||||
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
OpenPluginLayoutWindowInput, OpenProjectInput, ReadAgentContextInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
|
||||||
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput,
|
||||||
|
RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
RotateConversationLogInput, SetActiveLayoutInput, SlashCommandEffect,
|
||||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
|
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||||
|
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use backend::stream::OutputSink;
|
use backend::stream::OutputSink;
|
||||||
@ -51,7 +53,8 @@ use crate::dto::{
|
|||||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto,
|
||||||
|
ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
||||||
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
||||||
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||||
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
||||||
@ -68,12 +71,12 @@ use crate::dto::{
|
|||||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
WriteTerminalRequestDto,
|
WriteTerminalRequestDto,
|
||||||
@ -105,6 +108,69 @@ pub fn health(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `list_slash_commands` — list/filter the unified slash-command registry.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_slash_commands(
|
||||||
|
request: Option<crate::dto::ListSlashCommandsRequestDto>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> SlashCommandListDto {
|
||||||
|
let request = request.unwrap_or_default();
|
||||||
|
state
|
||||||
|
.list_slash_commands
|
||||||
|
.execute(ListSlashCommandsInput {
|
||||||
|
prefix: request.prefix,
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `execute_slash_command` — execute a selected slash command.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for unavailable commands/missing context,
|
||||||
|
/// `NOT_FOUND` for unknown commands or closed sessions).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn execute_slash_command(
|
||||||
|
request: ExecuteSlashCommandRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ExecuteSlashCommandResponseDto, ErrorDto> {
|
||||||
|
let session_id = request
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_session_id)
|
||||||
|
.transpose()?;
|
||||||
|
let output = state
|
||||||
|
.execute_slash_command
|
||||||
|
.execute(ExecuteSlashCommandInput {
|
||||||
|
name: request.name,
|
||||||
|
session_id,
|
||||||
|
})
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
|
||||||
|
let effect = match output.effect {
|
||||||
|
SlashCommandEffect::Help { commands } => SlashCommandEffectDto::Help { commands },
|
||||||
|
SlashCommandEffect::CleanConversation { session_id } => {
|
||||||
|
if state.structured_sessions.session(&session_id).is_none() {
|
||||||
|
return Err(ErrorDto::from(AppError::NotFound(format!(
|
||||||
|
"structured session {session_id}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
let cleared_chunks = state.chat_bridge.clear_scrollback(&session_id);
|
||||||
|
SlashCommandEffectDto::CleanConversation {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
cleared_chunks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SlashCommandEffect::ProfileSwitch { session_id } => SlashCommandEffectDto::ProfileSwitch {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ExecuteSlashCommandResponseDto {
|
||||||
|
command: output.command,
|
||||||
|
effect,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `get_server_exposure_settings` — read persisted embedded-server exposure settings.
|
/// `get_server_exposure_settings` — read persisted embedded-server exposure settings.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -248,6 +248,8 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::health,
|
commands::health,
|
||||||
|
commands::list_slash_commands,
|
||||||
|
commands::execute_slash_command,
|
||||||
commands::create_project,
|
commands::create_project,
|
||||||
commands::open_project,
|
commands::open_project,
|
||||||
commands::close_project,
|
commands::close_project,
|
||||||
|
|||||||
@ -3,13 +3,19 @@
|
|||||||
//! its tagged, camelCase JSON shape.
|
//! its tagged, camelCase JSON shape.
|
||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
parse_node_id, parse_session_id, ErrorDto, ExecuteSlashCommandRequestDto,
|
||||||
|
ExecuteSlashCommandResponseDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
||||||
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
|
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
|
||||||
TerminalClosedDto, WriteTerminalRequestDto,
|
SlashCommandEffectDto, SlashCommandListDto, TerminalClosedDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
||||||
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
use application::{
|
||||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView};
|
CloseTerminalOutput, ExecuteSlashCommandOutput, LayoutOperation, ListSlashCommandsOutput,
|
||||||
|
LoadLayoutOutput, OpenTerminalInput, SlashCommandEffect,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
native_slash_commands, Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView,
|
||||||
|
};
|
||||||
|
|
||||||
use application::{AppError, HealthInput};
|
use application::{AppError, HealthInput};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
@ -52,6 +58,73 @@ fn health_request_deserialises_camel_case_and_defaults() {
|
|||||||
assert_eq!(HealthInput::from(empty).note, None);
|
assert_eq!(HealthInput::from(empty).note, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_command_list_dto_serialises_unified_metadata() {
|
||||||
|
let dto = SlashCommandListDto::from(ListSlashCommandsOutput {
|
||||||
|
commands: native_slash_commands(),
|
||||||
|
});
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["commands"][0]["name"], "/help");
|
||||||
|
assert_eq!(v["commands"][0]["source"], "native");
|
||||||
|
assert_eq!(v["commands"][0]["native"], "help");
|
||||||
|
assert_eq!(
|
||||||
|
v["commands"][0]["availability"],
|
||||||
|
json!({ "status": "available" })
|
||||||
|
);
|
||||||
|
assert_eq!(v["commands"][1]["name"], "/clean");
|
||||||
|
assert!(v["commands"][2]["requiresConfirmation"].as_bool().unwrap());
|
||||||
|
assert_eq!(v["commands"][2]["availability"]["status"], "unavailable");
|
||||||
|
assert!(v.get("requires_confirmation").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_command_execute_request_deserialises_camelcase_session() {
|
||||||
|
let session_id = Uuid::from_u128(123).to_string();
|
||||||
|
let dto: ExecuteSlashCommandRequestDto =
|
||||||
|
serde_json::from_value(json!({ "name": "clean", "sessionId": session_id })).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(dto.name, "clean");
|
||||||
|
assert_eq!(dto.session_id.as_deref(), Some(session_id.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_command_execute_response_serialises_effect_shape() {
|
||||||
|
let session_id = SessionId::from_uuid(Uuid::from_u128(456));
|
||||||
|
let command = native_slash_commands()
|
||||||
|
.into_iter()
|
||||||
|
.find(|command| command.name == "/clean")
|
||||||
|
.unwrap();
|
||||||
|
let dto = ExecuteSlashCommandResponseDto::from(ExecuteSlashCommandOutput {
|
||||||
|
command,
|
||||||
|
effect: SlashCommandEffect::CleanConversation { session_id },
|
||||||
|
});
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["command"]["name"], "/clean");
|
||||||
|
assert_eq!(
|
||||||
|
v["effect"],
|
||||||
|
json!({
|
||||||
|
"kind": "cleanConversation",
|
||||||
|
"sessionId": session_id.to_string(),
|
||||||
|
"clearedChunks": 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(v["effect"].get("cleared_chunks").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_command_help_effect_carries_command_metadata() {
|
||||||
|
let dto = SlashCommandEffectDto::Help {
|
||||||
|
commands: native_slash_commands(),
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["kind"], "help");
|
||||||
|
assert_eq!(v["commands"][0]["name"], "/help");
|
||||||
|
assert_eq!(v["commands"][1]["name"], "/clean");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn error_dto_carries_stable_code_and_message() {
|
fn error_dto_carries_stable_code_and_message() {
|
||||||
let dto = ErrorDto::from(AppError::NotFound("project".into()));
|
let dto = ErrorDto::from(AppError::NotFound("project".into()));
|
||||||
|
|||||||
@ -32,6 +32,7 @@ pub mod plugin;
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
pub mod slash_command;
|
||||||
pub mod sprints;
|
pub mod sprints;
|
||||||
pub mod system_permissions;
|
pub mod system_permissions;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
@ -194,6 +195,10 @@ pub use skill::{
|
|||||||
ResolveAgentCapabilitiesInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput,
|
ResolveAgentCapabilitiesInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput,
|
||||||
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
|
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
|
||||||
};
|
};
|
||||||
|
pub use slash_command::{
|
||||||
|
ExecuteSlashCommand, ExecuteSlashCommandInput, ExecuteSlashCommandOutput, ListSlashCommands,
|
||||||
|
ListSlashCommandsInput, ListSlashCommandsOutput, SlashCommandEffect, SlashCommandRegistry,
|
||||||
|
};
|
||||||
pub use sprints::{
|
pub use sprints::{
|
||||||
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
|
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
|
||||||
AssignTicketToSprintOutput, CreateSprint, CreateSprintInput, CreateSprintOutput, DeleteSprint,
|
AssignTicketToSprintOutput, CreateSprint, CreateSprintInput, CreateSprintOutput, DeleteSprint,
|
||||||
|
|||||||
256
crates/application/src/slash_command.rs
Normal file
256
crates/application/src/slash_command.rs
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
//! Slash-command registry and native execution planning.
|
||||||
|
//!
|
||||||
|
//! The registry is transport-neutral and can later receive plugin-provided
|
||||||
|
//! commands without changing the frontend-facing list/filter contract.
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
filter_slash_commands, native_slash_commands, NativeSlashCommand, SessionId, SlashCommand,
|
||||||
|
SlashCommandAvailability,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::AppError;
|
||||||
|
|
||||||
|
/// Lists slash commands.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ListSlashCommandsInput {
|
||||||
|
/// Optional typed prefix, with or without the leading `/`.
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slash-command list output.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListSlashCommandsOutput {
|
||||||
|
/// Filtered commands in deterministic UI order.
|
||||||
|
pub commands: Vec<SlashCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes a native slash command.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExecuteSlashCommandInput {
|
||||||
|
/// Command name, with or without the leading `/`.
|
||||||
|
pub name: String,
|
||||||
|
/// Current structured chat session, required by conversation-local commands.
|
||||||
|
pub session_id: Option<SessionId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execution output: application decides intent; adapters perform transport-only
|
||||||
|
/// side effects when needed.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ExecuteSlashCommandOutput {
|
||||||
|
/// Canonical command name.
|
||||||
|
pub command: SlashCommand,
|
||||||
|
/// Planned effect.
|
||||||
|
pub effect: SlashCommandEffect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native command effects understood by driving adapters and the UI.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SlashCommandEffect {
|
||||||
|
/// Return the unified command list.
|
||||||
|
Help {
|
||||||
|
/// Commands to display.
|
||||||
|
commands: Vec<SlashCommand>,
|
||||||
|
},
|
||||||
|
/// Clear one live structured conversation.
|
||||||
|
CleanConversation {
|
||||||
|
/// Session whose retained conversation view must be cleared.
|
||||||
|
session_id: SessionId,
|
||||||
|
},
|
||||||
|
/// Placeholder for ticket #164.
|
||||||
|
ProfileSwitch {
|
||||||
|
/// Session whose agent/profile flow is targeted.
|
||||||
|
session_id: SessionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory slash-command registry.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SlashCommandRegistry {
|
||||||
|
plugin_commands: Vec<SlashCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlashCommandRegistry {
|
||||||
|
/// Creates an empty registry with IdeA native commands.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
plugin_commands: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all commands from all sources in deterministic order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn all(&self) -> Vec<SlashCommand> {
|
||||||
|
let mut commands = native_slash_commands();
|
||||||
|
commands.extend(self.plugin_commands.clone());
|
||||||
|
commands
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns commands filtered by prefix.
|
||||||
|
#[must_use]
|
||||||
|
pub fn list(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput {
|
||||||
|
ListSlashCommandsOutput {
|
||||||
|
commands: filter_slash_commands(self.all(), input.prefix.as_deref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plans execution for a command.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::NotFound`] for an unknown command, [`AppError::Invalid`]
|
||||||
|
/// for unavailable commands or missing command-local context.
|
||||||
|
pub fn execute(
|
||||||
|
&self,
|
||||||
|
input: ExecuteSlashCommandInput,
|
||||||
|
) -> Result<ExecuteSlashCommandOutput, AppError> {
|
||||||
|
let name = normalize_command_name(&input.name)?;
|
||||||
|
let command = self
|
||||||
|
.all()
|
||||||
|
.into_iter()
|
||||||
|
.find(|command| command.name == name)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("slash command {name}")))?;
|
||||||
|
if let SlashCommandAvailability::Unavailable { reason } = &command.availability {
|
||||||
|
return Err(AppError::Invalid(format!(
|
||||||
|
"slash command {name} unavailable: {reason}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(native) = command.native else {
|
||||||
|
return Err(AppError::Invalid(format!(
|
||||||
|
"slash command {name} has no executable adapter"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let effect = match native {
|
||||||
|
NativeSlashCommand::Help => SlashCommandEffect::Help {
|
||||||
|
commands: self.all(),
|
||||||
|
},
|
||||||
|
NativeSlashCommand::Clean => SlashCommandEffect::CleanConversation {
|
||||||
|
session_id: input.session_id.ok_or_else(|| {
|
||||||
|
AppError::Invalid("/clean requires a current session id".to_owned())
|
||||||
|
})?,
|
||||||
|
},
|
||||||
|
NativeSlashCommand::Profile => SlashCommandEffect::ProfileSwitch {
|
||||||
|
session_id: input.session_id.ok_or_else(|| {
|
||||||
|
AppError::Invalid("/profile requires a current session id".to_owned())
|
||||||
|
})?,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ExecuteSlashCommandOutput { command, effect })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_command_name(name: &str) -> Result<String, AppError> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::Invalid("slash command name is empty".to_owned()));
|
||||||
|
}
|
||||||
|
Ok(if trimmed.starts_with('/') {
|
||||||
|
trimmed.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("/{trimmed}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case wrapper for list/filter.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ListSlashCommands {
|
||||||
|
registry: SlashCommandRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListSlashCommands {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(registry: SlashCommandRegistry) -> Self {
|
||||||
|
Self { registry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn execute(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput {
|
||||||
|
self.registry.list(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case wrapper for execution planning.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ExecuteSlashCommand {
|
||||||
|
registry: SlashCommandRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecuteSlashCommand {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(registry: SlashCommandRegistry) -> Self {
|
||||||
|
Self { registry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the use case.
|
||||||
|
pub fn execute(
|
||||||
|
&self,
|
||||||
|
input: ExecuteSlashCommandInput,
|
||||||
|
) -> Result<ExecuteSlashCommandOutput, AppError> {
|
||||||
|
self.registry.execute(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn session() -> SessionId {
|
||||||
|
SessionId::from_uuid(Uuid::from_u128(42))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_filters_native_commands_by_prefix() {
|
||||||
|
let out =
|
||||||
|
ListSlashCommands::new(SlashCommandRegistry::new()).execute(ListSlashCommandsInput {
|
||||||
|
prefix: Some("he".to_owned()),
|
||||||
|
});
|
||||||
|
assert_eq!(out.commands.len(), 1);
|
||||||
|
assert_eq!(out.commands[0].name, "/help");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_returns_unified_command_list() {
|
||||||
|
let out = ExecuteSlashCommand::new(SlashCommandRegistry::new())
|
||||||
|
.execute(ExecuteSlashCommandInput {
|
||||||
|
name: "help".to_owned(),
|
||||||
|
session_id: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.command.name, "/help");
|
||||||
|
let SlashCommandEffect::Help { commands } = out.effect else {
|
||||||
|
panic!("help effect expected");
|
||||||
|
};
|
||||||
|
assert!(commands.iter().any(|command| command.name == "/clean"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_requires_and_returns_current_session() {
|
||||||
|
let sid = session();
|
||||||
|
let out = ExecuteSlashCommand::new(SlashCommandRegistry::new())
|
||||||
|
.execute(ExecuteSlashCommandInput {
|
||||||
|
name: "/clean".to_owned(),
|
||||||
|
session_id: Some(sid),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out.effect,
|
||||||
|
SlashCommandEffect::CleanConversation { session_id: sid }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_is_known_but_not_executable_yet() {
|
||||||
|
let err = ExecuteSlashCommand::new(SlashCommandRegistry::new())
|
||||||
|
.execute(ExecuteSlashCommandInput {
|
||||||
|
name: "/profile".to_owned(),
|
||||||
|
session_id: Some(session()),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Invalid(message) if message.contains("unavailable")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,14 +13,15 @@ use application::{
|
|||||||
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
|
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
|
||||||
AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel,
|
AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel,
|
||||||
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
|
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
|
||||||
CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind,
|
CreateProjectInput, CreateProjectOutput, ExecuteSlashCommandOutput, GitGraphOutput,
|
||||||
ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
|
HealthInput, HealthReport, LayoutKind, ListProjectsOutput, ListSlashCommandsOutput,
|
||||||
|
LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, SlashCommandEffect,
|
||||||
StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
|
StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
|
||||||
};
|
};
|
||||||
use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
|
use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions,
|
AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions,
|
||||||
ResolvedAgentSystemPermissions, SystemPermissionSet, TurnRole,
|
ResolvedAgentSystemPermissions, SlashCommand, SystemPermissionSet, TurnRole,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use crate::ticket_dto::*;
|
pub use crate::ticket_dto::*;
|
||||||
@ -764,6 +765,106 @@ impl From<HealthReport> for HealthResponseDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request DTO for slash-command list/filter.
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListSlashCommandsRequestDto {
|
||||||
|
/// Optional typed prefix, with or without the leading `/`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-facing slash-command metadata DTO.
|
||||||
|
pub type SlashCommandDto = SlashCommand;
|
||||||
|
|
||||||
|
/// Slash-command list response DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SlashCommandListDto {
|
||||||
|
/// Commands in deterministic UI order.
|
||||||
|
pub commands: Vec<SlashCommandDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ListSlashCommandsOutput> for SlashCommandListDto {
|
||||||
|
fn from(value: ListSlashCommandsOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
commands: value.commands,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for slash-command execution.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExecuteSlashCommandRequestDto {
|
||||||
|
/// Command name, with or without the leading `/`.
|
||||||
|
pub name: String,
|
||||||
|
/// Current structured chat session id, required by `/clean` and future
|
||||||
|
/// conversation-local commands.
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slash-command execution response DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExecuteSlashCommandResponseDto {
|
||||||
|
/// Executed command metadata.
|
||||||
|
pub command: SlashCommandDto,
|
||||||
|
/// Effect handled by the UI/adapter.
|
||||||
|
pub effect: SlashCommandEffectDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ExecuteSlashCommandOutput> for ExecuteSlashCommandResponseDto {
|
||||||
|
fn from(value: ExecuteSlashCommandOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
command: value.command,
|
||||||
|
effect: value.effect.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slash-command execution effect DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||||
|
pub enum SlashCommandEffectDto {
|
||||||
|
/// Display command help.
|
||||||
|
Help {
|
||||||
|
/// Commands to display.
|
||||||
|
commands: Vec<SlashCommandDto>,
|
||||||
|
},
|
||||||
|
/// The current conversation was cleared.
|
||||||
|
CleanConversation {
|
||||||
|
/// Session id that was cleared.
|
||||||
|
#[serde(rename = "sessionId")]
|
||||||
|
session_id: String,
|
||||||
|
/// Number of retained chunks removed by the backend bridge.
|
||||||
|
#[serde(rename = "clearedChunks")]
|
||||||
|
cleared_chunks: usize,
|
||||||
|
},
|
||||||
|
/// Placeholder for ticket #164.
|
||||||
|
ProfileSwitch {
|
||||||
|
/// Session id targeted by the profile flow.
|
||||||
|
#[serde(rename = "sessionId")]
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SlashCommandEffect> for SlashCommandEffectDto {
|
||||||
|
fn from(value: SlashCommandEffect) -> Self {
|
||||||
|
match value {
|
||||||
|
SlashCommandEffect::Help { commands } => Self::Help { commands },
|
||||||
|
SlashCommandEffect::CleanConversation { session_id } => Self::CleanConversation {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
cleared_chunks: 0,
|
||||||
|
},
|
||||||
|
SlashCommandEffect::ProfileSwitch { session_id } => Self::ProfileSwitch {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Error DTO returned to the frontend in the `Err` arm of every command.
|
/// Error DTO returned to the frontend in the `Err` arm of every command.
|
||||||
///
|
///
|
||||||
/// `code` is a stable machine-readable string (see [`AppError::code`]); the
|
/// `code` is a stable machine-readable string (see [`AppError::code`]); the
|
||||||
|
|||||||
@ -23,18 +23,19 @@ use application::{
|
|||||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||||
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
|
||||||
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
||||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
|
||||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments,
|
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
||||||
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
HealthUseCase, ImportChatAttachments, InspectConversation, InstallPluginFromArchive,
|
||||||
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||||
ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles,
|
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices,
|
||||||
ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
|
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||||
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
|
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||||
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates,
|
||||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized,
|
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
||||||
McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime,
|
||||||
|
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
||||||
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
||||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||||
@ -48,15 +49,15 @@ use application::{
|
|||||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
||||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
||||||
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||||
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
||||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||||
AGENT_MEMORY_RECALL_BUDGET,
|
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -877,6 +878,10 @@ impl AgentResumer for AppAgentResumer {
|
|||||||
pub struct BackendCore {
|
pub struct BackendCore {
|
||||||
/// Trivial health use case validating the end-to-end wiring.
|
/// Trivial health use case validating the end-to-end wiring.
|
||||||
pub health: Arc<HealthUseCase>,
|
pub health: Arc<HealthUseCase>,
|
||||||
|
/// List/filter the unified slash-command registry.
|
||||||
|
pub list_slash_commands: Arc<ListSlashCommands>,
|
||||||
|
/// Plan execution of a slash command.
|
||||||
|
pub execute_slash_command: Arc<ExecuteSlashCommand>,
|
||||||
/// Pair a persistent device session.
|
/// Pair a persistent device session.
|
||||||
pub pair_device: Arc<PairDevice>,
|
pub pair_device: Arc<PairDevice>,
|
||||||
/// Limits failed pairing attempts.
|
/// Limits failed pairing attempts.
|
||||||
@ -1448,6 +1453,9 @@ impl BackendCore {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
|
let slash_command_registry = SlashCommandRegistry::new();
|
||||||
|
let list_slash_commands = Arc::new(ListSlashCommands::new(slash_command_registry.clone()));
|
||||||
|
let execute_slash_command = Arc::new(ExecuteSlashCommand::new(slash_command_registry));
|
||||||
let pair_device = Arc::new(PairDevice::new(
|
let pair_device = Arc::new(PairDevice::new(
|
||||||
Arc::clone(&device_session_port),
|
Arc::clone(&device_session_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
@ -2924,6 +2932,8 @@ impl BackendCore {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
health,
|
health,
|
||||||
|
list_slash_commands,
|
||||||
|
execute_slash_command,
|
||||||
pair_device,
|
pair_device,
|
||||||
pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port),
|
pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port),
|
||||||
authenticate_session,
|
authenticate_session,
|
||||||
|
|||||||
@ -156,6 +156,20 @@ where
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear_scrollback(&self, key: &K) -> usize {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut m| {
|
||||||
|
m.get_mut(key).map(|entry| {
|
||||||
|
let cleared = entry.scrollback.len();
|
||||||
|
entry.scrollback.clear();
|
||||||
|
cleared
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unregister(&self, key: &K) {
|
pub fn unregister(&self, key: &K) {
|
||||||
if let Ok(mut map) = self.entries.lock() {
|
if let Ok(mut map) = self.entries.lock() {
|
||||||
map.remove(key);
|
map.remove(key);
|
||||||
|
|||||||
@ -65,6 +65,7 @@ pub mod remote;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod session_limit;
|
pub mod session_limit;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
pub mod slash_command;
|
||||||
pub mod sprint;
|
pub mod sprint;
|
||||||
pub mod system_permissions;
|
pub mod system_permissions;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
@ -108,6 +109,11 @@ pub use chat_attachment::{
|
|||||||
|
|
||||||
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
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 template::{AgentTemplate, TemplateVersion};
|
||||||
|
|
||||||
pub use profile::{
|
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