diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index 2eedfbe..84aa525 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -93,6 +93,16 @@ impl ChatBridge { 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 /// unconditionally (chat cell explicitly closed). Twin of /// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister). diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d0eb710..7b9a3da 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -15,21 +15,23 @@ use application::{ AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, - DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, - GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, - GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, - InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, - ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, - LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput, - ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, ExecuteSlashCommandInput, + GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, + GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, + GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, + ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, + ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, + OpenPluginLayoutWindowInput, OpenProjectInput, ReadAgentContextInput, + ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, + ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput, + RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, - StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, - UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, - UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, - UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, + RotateConversationLogInput, SetActiveLayoutInput, SlashCommandEffect, + SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, + UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput, + UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, + UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, }; use backend::stream::OutputSink; @@ -51,7 +53,8 @@ use crate::dto::{ CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, - EmbedderProfileListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, + EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto, + ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, @@ -68,12 +71,12 @@ use crate::dto::{ ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, - StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, - SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, - TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, - UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto, - UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, - UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, + UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, + UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, + UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, @@ -105,6 +108,69 @@ pub fn health( .map_err(ErrorDto::from) } +/// `list_slash_commands` — list/filter the unified slash-command registry. +#[tauri::command] +pub fn list_slash_commands( + request: Option, + 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 { + 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. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 2a0d2a2..ec6bdff 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -248,6 +248,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::health, + commands::list_slash_commands, + commands::execute_slash_command, commands::create_project, commands::open_project, commands::close_project, diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index 237b94a..dc419c2 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -3,13 +3,19 @@ //! its tagged, camelCase JSON shape. 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, - TerminalClosedDto, WriteTerminalRequestDto, + SlashCommandEffectDto, SlashCommandListDto, TerminalClosedDto, WriteTerminalRequestDto, }; use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT}; -use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; -use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView}; +use application::{ + CloseTerminalOutput, ExecuteSlashCommandOutput, LayoutOperation, ListSlashCommandsOutput, + LoadLayoutOutput, OpenTerminalInput, SlashCommandEffect, +}; +use domain::{ + native_slash_commands, Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView, +}; use application::{AppError, HealthInput}; use domain::events::DomainEvent; @@ -52,6 +58,73 @@ fn health_request_deserialises_camel_case_and_defaults() { 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] fn error_dto_carries_stable_code_and_message() { let dto = ErrorDto::from(AppError::NotFound("project".into())); diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index c733ccc..73e8084 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -32,6 +32,7 @@ pub mod plugin; pub mod project; pub mod remote; pub mod skill; +pub mod slash_command; pub mod sprints; pub mod system_permissions; pub mod template; @@ -194,6 +195,10 @@ pub use skill::{ ResolveAgentCapabilitiesInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, UpdateSkillOutput, }; +pub use slash_command::{ + ExecuteSlashCommand, ExecuteSlashCommandInput, ExecuteSlashCommandOutput, ListSlashCommands, + ListSlashCommandsInput, ListSlashCommandsOutput, SlashCommandEffect, SlashCommandRegistry, +}; pub use sprints::{ normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput, AssignTicketToSprintOutput, CreateSprint, CreateSprintInput, CreateSprintOutput, DeleteSprint, diff --git a/crates/application/src/slash_command.rs b/crates/application/src/slash_command.rs new file mode 100644 index 0000000..25a1380 --- /dev/null +++ b/crates/application/src/slash_command.rs @@ -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, +} + +/// Slash-command list output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListSlashCommandsOutput { + /// Filtered commands in deterministic UI order. + pub commands: Vec, +} + +/// 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, +} + +/// 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, + }, + /// 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, +} + +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 { + 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 { + 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 { + 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 { + 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"))); + } +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 98fbe4f..995ac22 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -13,14 +13,15 @@ use application::{ AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail, AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, - CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, - ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, + CreateProjectInput, CreateProjectOutput, ExecuteSlashCommandOutput, GitGraphOutput, + HealthInput, HealthReport, LayoutKind, ListProjectsOutput, ListSlashCommandsOutput, + LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, SlashCommandEffect, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, }; use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage}; use domain::{ AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions, - ResolvedAgentSystemPermissions, SystemPermissionSet, TurnRole, + ResolvedAgentSystemPermissions, SlashCommand, SystemPermissionSet, TurnRole, }; pub use crate::ticket_dto::*; @@ -764,6 +765,106 @@ impl From 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, +} + +/// 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, +} + +impl From 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, +} + +/// 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 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, + }, + /// 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 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. /// /// `code` is a stable machine-readable string (see [`AppError::code`]); the diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 1a47a2a..731eab5 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -23,18 +23,19 @@ use application::{ CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, - DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, - GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, - GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, - GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, - InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, - JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, - ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles, - ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, - ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, - ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, - LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, - McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, + DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState, + GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, + GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, + GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, + HealthUseCase, ImportChatAttachments, InspectConversation, InstallPluginFromArchive, + InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, + LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates, + LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, + LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, @@ -48,15 +49,15 @@ use application::{ ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, - SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort, - UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, - UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, - UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, - UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, + UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, + UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, + UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, + WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -877,6 +878,10 @@ impl AgentResumer for AppAgentResumer { pub struct BackendCore { /// Trivial health use case validating the end-to-end wiring. pub health: Arc, + /// List/filter the unified slash-command registry. + pub list_slash_commands: Arc, + /// Plan execution of a slash command. + pub execute_slash_command: Arc, /// Pair a persistent device session. pub pair_device: Arc, /// Limits failed pairing attempts. @@ -1448,6 +1453,9 @@ impl BackendCore { Arc::clone(&ids) as Arc, 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( Arc::clone(&device_session_port), Arc::clone(&ids) as Arc, @@ -2924,6 +2932,8 @@ impl BackendCore { Self { health, + list_slash_commands, + execute_slash_command, pair_device, pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port), authenticate_session, diff --git a/crates/backend/src/stream.rs b/crates/backend/src/stream.rs index 96a1e21..75a028b 100644 --- a/crates/backend/src/stream.rs +++ b/crates/backend/src/stream.rs @@ -156,6 +156,20 @@ where .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) { if let Ok(mut map) = self.entries.lock() { map.remove(key); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 95f4dbf..ff0e1df 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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::{ diff --git a/crates/domain/src/slash_command.rs b/crates/domain/src/slash_command.rs new file mode 100644 index 0000000..7af9d21 --- /dev/null +++ b/crates/domain/src/slash_command.rs @@ -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, +} + +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, + short_description: impl Into, + requires_confirmation: bool, + availability: SlashCommandAvailability, + source: SlashCommandSource, + native: Option, + ) -> Result { + 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 { + 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, + prefix: Option<&str>, +) -> Vec { + 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::>(); + 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::>(); + assert_eq!(names, vec!["/clean"]); + + let names = filter_slash_commands(native_slash_commands(), Some("/pro")) + .into_iter() + .map(|c| c.name) + .collect::>(); + assert_eq!(names, vec!["/profile"]); + } +}