#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
#[cfg(test)]
mod bpdl_contract_tests;
pub mod completion;
use bcode_model::ReasoningEffort;
use bcode_session_models::SessionId;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const COMMAND_INTERFACE_ID: &str = "bcode.command/v1";
pub const OP_LIST_COMMANDS: &str = "list";
pub const OP_INVOKE_COMMAND: &str = "invoke";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandInfo {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub requires_args: bool,
#[serde(default)]
pub category: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandList {
pub commands: Vec<CommandInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandInvocationContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<SessionId>,
pub working_directory: std::path::PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvokeCommandRequest {
pub command_id: String,
#[serde(default)]
pub args: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<CommandInvocationContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvokeCommandResponse {
pub success: bool,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub updated_model: Option<String>,
#[serde(default)]
pub updated_provider: Option<String>,
#[serde(default)]
pub updated_thinking: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<CommandEffect>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandTextFormat {
#[default]
PlainText,
Markdown,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CommandEffect {
CopyText {
text: String,
},
Status {
message: String,
},
AppendText {
text: String,
#[serde(default, skip_serializing_if = "is_plain_text_format")]
format: CommandTextFormat,
},
ToggleSurface {
surface_id: String,
},
OpenPluginSurface {
surface_kind: String,
instance_id: String,
#[serde(default)]
options: serde_json::Value,
},
OpenSession {
session_id: SessionId,
#[serde(default)]
focus: SessionOpenFocus,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionOpenFocus {
#[default]
Default,
Composer,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_plain_text_format(format: &CommandTextFormat) -> bool {
*format == CommandTextFormat::PlainText
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandOwner {
Host,
Plugin {
plugin_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandAction {
Host {
route: String,
},
Plugin {
plugin_id: String,
command_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandSurface {
Palette,
Slash,
Custom(String),
}
impl CommandSurface {
#[must_use]
pub fn parse(value: &str) -> Self {
match value {
"palette" => Self::Palette,
"slash" => Self::Slash,
other => Self::Custom(other.to_owned()),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandExecution {
#[default]
Normal,
Immediate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlashCommandContribution {
pub name: String,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub aliases: BTreeSet<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandSessionRequirement {
#[default]
Optional,
Required,
Absent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandSessionRefusal {
SessionRequired,
SessionMustBeAbsent,
}
impl CommandSessionRefusal {
#[must_use]
pub const fn message(self) -> &'static str {
match self {
Self::SessionRequired => "this command requires an active session",
Self::SessionMustBeAbsent => "this command only applies without an active session",
}
}
}
impl std::fmt::Display for CommandSessionRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.message())
}
}
impl CommandSessionRequirement {
#[must_use]
pub const fn refusal(self, has_active_session: bool) -> Option<CommandSessionRefusal> {
match self {
Self::Optional => None,
Self::Required => {
if has_active_session {
None
} else {
Some(CommandSessionRefusal::SessionRequired)
}
}
Self::Absent => {
if has_active_session {
Some(CommandSessionRefusal::SessionMustBeAbsent)
} else {
None
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandArgumentKind {
String,
Boolean,
Integer,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandArgumentContribution {
pub name: String,
pub kind: CommandArgumentKind,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandContribution {
pub id: String,
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub surfaces: BTreeSet<CommandSurface>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slash: Option<SlashCommandContribution>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub arguments: Vec<CommandArgumentContribution>,
#[serde(default)]
pub session: CommandSessionRequirement,
#[serde(default)]
pub execution: CommandExecution,
pub owner: CommandOwner,
pub action: CommandAction,
}
impl CommandContribution {
#[must_use]
pub fn host_palette(id: &str, title: &str, description: &str, category: &str) -> Self {
Self {
id: id.to_owned(),
title: title.to_owned(),
description: Some(description.to_owned()),
category: Some(category.to_owned()),
surfaces: BTreeSet::from([CommandSurface::Palette]),
slash: None,
arguments: Vec::new(),
session: CommandSessionRequirement::Optional,
execution: CommandExecution::Normal,
owner: CommandOwner::Host,
action: CommandAction::Host {
route: id.to_owned(),
},
}
}
#[must_use]
pub fn supports_surface(&self, surface: &CommandSurface) -> bool {
self.surfaces.contains(surface)
}
#[must_use]
pub fn slash_name(&self) -> Option<&str> {
self.supports_surface(&CommandSurface::Slash)
.then(|| self.slash.as_ref().map(|slash| slash.name.as_str()))
.flatten()
}
#[must_use]
pub fn matches_slash_name(&self, name: &str) -> bool {
self.slash_name().is_some_and(|primary| primary == name)
|| self
.slash
.as_ref()
.is_some_and(|slash| slash.aliases.contains(name))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommandRegistry {
contributions: BTreeMap<String, CommandContribution>,
}
impl CommandRegistry {
#[must_use]
pub const fn new() -> Self {
Self {
contributions: BTreeMap::new(),
}
}
pub fn register(&mut self, contribution: CommandContribution) {
self.contributions
.insert(contribution.id.clone(), contribution);
}
pub fn extend(&mut self, contributions: impl IntoIterator<Item = CommandContribution>) {
for contribution in contributions {
self.register(contribution);
}
}
#[must_use]
pub fn commands_for_surface(&self, surface: &CommandSurface) -> Vec<CommandContribution> {
self.contributions
.values()
.filter(|contribution| contribution.supports_surface(surface))
.cloned()
.collect()
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&CommandContribution> {
self.contributions.get(id)
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn bundled_host_palette_commands() -> Vec<CommandContribution> {
vec![
CommandContribution::host_palette(
"session.new",
"New Session",
"Create a new chat session",
"session",
),
CommandContribution::host_palette(
"session.switch",
"Switch Session",
"Open the session picker",
"session",
),
CommandContribution::host_palette(
"session.search",
"Search Sessions",
"Search local session transcripts",
"session",
),
CommandContribution::host_palette(
"theme.select",
"Theme: Select",
"List themes and show preview/apply commands",
"theme",
),
CommandContribution::host_palette(
"streaming.configure",
"Streaming: Configure",
"Compare raw provider chunks and tune smoothed presentation",
"streaming",
),
CommandContribution::host_palette("help", "Help", "Show TUI help", "help"),
CommandContribution::host_palette(
"session.rename",
"Session: Rename",
"Rename an existing session",
"session",
),
CommandContribution::host_palette(
"session.delete",
"Session: Delete",
"Delete an existing session",
"session",
),
CommandContribution::host_palette(
"turn.cancel",
"Turn: Cancel",
"Cancel the active assistant turn",
"turn",
),
CommandContribution::host_palette(
"context.compact",
"Context: Compact",
"Compact the current conversation context",
"context",
),
]
}
#[derive(Debug, Clone, thiserror::Error, Serialize, Deserialize)]
pub enum CommandError {
#[error("unknown command: {0}")]
UnknownCommand(String),
#[error("invalid arguments: {0}")]
InvalidArguments(String),
#[error("execution failed: {0}")]
ExecutionFailed(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn optional_session_requirement_never_refuses_dispatch() {
assert_eq!(CommandSessionRequirement::Optional.refusal(true), None);
assert_eq!(CommandSessionRequirement::Optional.refusal(false), None);
}
#[test]
fn required_session_requirement_refuses_without_active_session() {
assert_eq!(CommandSessionRequirement::Required.refusal(true), None);
assert_eq!(
CommandSessionRequirement::Required.refusal(false),
Some(CommandSessionRefusal::SessionRequired)
);
}
#[test]
fn absent_session_requirement_refuses_with_active_session() {
assert_eq!(CommandSessionRequirement::Absent.refusal(false), None);
assert_eq!(
CommandSessionRequirement::Absent.refusal(true),
Some(CommandSessionRefusal::SessionMustBeAbsent)
);
}
#[test]
fn session_refusal_messages_are_distinguishable() {
assert_ne!(
CommandSessionRefusal::SessionRequired.message(),
CommandSessionRefusal::SessionMustBeAbsent.message()
);
}
#[test]
fn bundled_palette_exposes_session_search_route() {
assert!(bundled_host_palette_commands().iter().any(|contribution| {
contribution.id == "session.search"
&& contribution.action
== CommandAction::Host {
route: "session.search".to_owned(),
}
}));
}
#[test]
fn bundled_palette_exposes_theme_selection_route() {
assert!(bundled_host_palette_commands().iter().any(|contribution| {
contribution.id == "theme.select"
&& contribution.action
== CommandAction::Host {
route: "theme.select".to_owned(),
}
}));
}
#[test]
fn bundled_palette_exposes_streaming_configurator_route() {
assert!(bundled_host_palette_commands().iter().any(|contribution| {
contribution.id == "streaming.configure"
&& contribution.action
== CommandAction::Host {
route: "streaming.configure".to_owned(),
}
}));
}
#[test]
fn append_text_defaults_legacy_payloads_to_plain_text() {
let effect = serde_json::from_value::<CommandEffect>(serde_json::json!({
"type": "append_text",
"text": "* literal"
}))
.expect("legacy append text");
assert_eq!(
effect,
CommandEffect::AppendText {
text: "* literal".to_owned(),
format: CommandTextFormat::PlainText,
}
);
assert_eq!(
serde_json::to_value(effect).expect("serialize legacy-compatible effect"),
serde_json::json!({"type": "append_text", "text": "* literal"})
);
}
#[test]
fn append_text_serializes_explicit_markdown_format() {
let effect = CommandEffect::AppendText {
text: "* rendered".to_owned(),
format: CommandTextFormat::Markdown,
};
assert_eq!(
serde_json::to_value(&effect).expect("serialize markdown effect"),
serde_json::json!({
"type": "append_text",
"text": "* rendered",
"format": "markdown"
})
);
assert_eq!(
serde_json::from_value::<CommandEffect>(
serde_json::to_value(effect).expect("serialize markdown effect")
)
.expect("deserialize markdown effect"),
CommandEffect::AppendText {
text: "* rendered".to_owned(),
format: CommandTextFormat::Markdown,
}
);
}
#[test]
fn slash_metadata_decouples_user_facing_name_from_command_identity() {
let contribution = CommandContribution {
id: "bcode.example.open".to_owned(),
title: "Example".to_owned(),
description: None,
category: None,
surfaces: BTreeSet::from([CommandSurface::Slash]),
slash: Some(SlashCommandContribution {
name: "example".to_owned(),
aliases: BTreeSet::from(["ex".to_owned()]),
}),
arguments: Vec::new(),
session: CommandSessionRequirement::Optional,
execution: CommandExecution::Immediate,
owner: CommandOwner::Host,
action: CommandAction::Host {
route: "example".to_owned(),
},
};
assert_eq!(contribution.slash_name(), Some("example"));
assert!(contribution.matches_slash_name("example"));
assert!(contribution.matches_slash_name("ex"));
assert!(!contribution.matches_slash_name("bcode.example.open"));
}
#[test]
fn command_invocation_context_is_separate_from_user_arguments() {
let session_id = SessionId::new();
let request = InvokeCommandRequest {
command_id: "bcode.example.open".to_owned(),
args: BTreeMap::from([("target".to_owned(), "user input".to_owned())]),
context: Some(CommandInvocationContext {
session_id: Some(session_id),
working_directory: std::path::PathBuf::from("/workspace"),
}),
};
let value = serde_json::to_value(&request).expect("serialize request");
assert_eq!(value["args"]["target"], "user input");
assert_eq!(value["context"]["session_id"], session_id.to_string());
assert!(value["args"].get("session_id").is_none());
assert!(value["args"].get("cwd").is_none());
assert_eq!(
serde_json::from_value::<InvokeCommandRequest>(value).expect("deserialize request"),
request
);
}
#[test]
fn registry_filters_commands_by_surface() {
let mut registry = CommandRegistry::new();
registry.register(CommandContribution {
id: "example.palette".to_owned(),
title: "Palette".to_owned(),
description: None,
category: None,
surfaces: BTreeSet::from([CommandSurface::Palette]),
slash: None,
arguments: Vec::new(),
session: CommandSessionRequirement::Optional,
execution: CommandExecution::Normal,
owner: CommandOwner::Host,
action: CommandAction::Host {
route: "example.palette".to_owned(),
},
});
registry.register(CommandContribution {
id: "example.slash".to_owned(),
title: "Slash".to_owned(),
description: None,
category: None,
surfaces: BTreeSet::from([CommandSurface::Slash]),
slash: Some(SlashCommandContribution {
name: "example".to_owned(),
aliases: BTreeSet::new(),
}),
arguments: Vec::new(),
session: CommandSessionRequirement::Optional,
execution: CommandExecution::Normal,
owner: CommandOwner::Host,
action: CommandAction::Host {
route: "example.slash".to_owned(),
},
});
let palette = registry.commands_for_surface(&CommandSurface::Palette);
assert_eq!(palette.len(), 1);
assert_eq!(palette[0].id, "example.palette");
}
}