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:
2026-08-06 14:52:36 +02:00
parent 4e57161e1d
commit 6997138a71
11 changed files with 793 additions and 49 deletions

View File

@ -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).

View File

@ -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<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.
///
/// # Errors

View File

@ -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,

View File

@ -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()));