lib.rs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. use runtime::{compact_session, CompactionConfig, Session};
  2. #[derive(Debug, Clone, PartialEq, Eq)]
  3. pub struct CommandManifestEntry {
  4. pub name: String,
  5. pub source: CommandSource,
  6. }
  7. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  8. pub enum CommandSource {
  9. Builtin,
  10. InternalOnly,
  11. FeatureGated,
  12. }
  13. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  14. pub struct CommandRegistry {
  15. entries: Vec<CommandManifestEntry>,
  16. }
  17. impl CommandRegistry {
  18. #[must_use]
  19. pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
  20. Self { entries }
  21. }
  22. #[must_use]
  23. pub fn entries(&self) -> &[CommandManifestEntry] {
  24. &self.entries
  25. }
  26. }
  27. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  28. pub struct SlashCommandSpec {
  29. pub name: &'static str,
  30. pub summary: &'static str,
  31. pub argument_hint: Option<&'static str>,
  32. }
  33. const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
  34. SlashCommandSpec {
  35. name: "help",
  36. summary: "Show available slash commands",
  37. argument_hint: None,
  38. },
  39. SlashCommandSpec {
  40. name: "status",
  41. summary: "Show current session status",
  42. argument_hint: None,
  43. },
  44. SlashCommandSpec {
  45. name: "compact",
  46. summary: "Compact local session history",
  47. argument_hint: None,
  48. },
  49. SlashCommandSpec {
  50. name: "model",
  51. summary: "Show or switch the active model",
  52. argument_hint: Some("[model]"),
  53. },
  54. ];
  55. #[derive(Debug, Clone, PartialEq, Eq)]
  56. pub enum SlashCommand {
  57. Help,
  58. Status,
  59. Compact,
  60. Model { model: Option<String> },
  61. Unknown(String),
  62. }
  63. impl SlashCommand {
  64. #[must_use]
  65. pub fn parse(input: &str) -> Option<Self> {
  66. let trimmed = input.trim();
  67. if !trimmed.starts_with('/') {
  68. return None;
  69. }
  70. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  71. let command = parts.next().unwrap_or_default();
  72. Some(match command {
  73. "help" => Self::Help,
  74. "status" => Self::Status,
  75. "compact" => Self::Compact,
  76. "model" => Self::Model {
  77. model: parts.next().map(ToOwned::to_owned),
  78. },
  79. other => Self::Unknown(other.to_string()),
  80. })
  81. }
  82. }
  83. #[must_use]
  84. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  85. SLASH_COMMAND_SPECS
  86. }
  87. #[must_use]
  88. pub fn render_slash_command_help() -> String {
  89. let mut lines = vec!["Available commands:".to_string()];
  90. for spec in slash_command_specs() {
  91. let name = match spec.argument_hint {
  92. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  93. None => format!("/{}", spec.name),
  94. };
  95. lines.push(format!(" {name:<20} {}", spec.summary));
  96. }
  97. lines.join("\n")
  98. }
  99. #[derive(Debug, Clone, PartialEq, Eq)]
  100. pub struct SlashCommandResult {
  101. pub message: String,
  102. pub session: Session,
  103. }
  104. #[must_use]
  105. pub fn handle_slash_command(
  106. input: &str,
  107. session: &Session,
  108. compaction: CompactionConfig,
  109. ) -> Option<SlashCommandResult> {
  110. match SlashCommand::parse(input)? {
  111. SlashCommand::Compact => {
  112. let result = compact_session(session, compaction);
  113. let message = if result.removed_message_count == 0 {
  114. "Compaction skipped: session is below the compaction threshold.".to_string()
  115. } else {
  116. format!(
  117. "Compacted {} messages into a resumable system summary.",
  118. result.removed_message_count
  119. )
  120. };
  121. Some(SlashCommandResult {
  122. message,
  123. session: result.compacted_session,
  124. })
  125. }
  126. SlashCommand::Help => Some(SlashCommandResult {
  127. message: render_slash_command_help(),
  128. session: session.clone(),
  129. }),
  130. SlashCommand::Status | SlashCommand::Model { .. } | SlashCommand::Unknown(_) => None,
  131. }
  132. }
  133. #[cfg(test)]
  134. mod tests {
  135. use super::{
  136. handle_slash_command, render_slash_command_help, slash_command_specs, SlashCommand,
  137. };
  138. use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
  139. #[test]
  140. fn parses_supported_slash_commands() {
  141. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  142. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  143. assert_eq!(
  144. SlashCommand::parse("/model claude-opus"),
  145. Some(SlashCommand::Model {
  146. model: Some("claude-opus".to_string()),
  147. })
  148. );
  149. assert_eq!(
  150. SlashCommand::parse("/model"),
  151. Some(SlashCommand::Model { model: None })
  152. );
  153. }
  154. #[test]
  155. fn renders_help_from_shared_specs() {
  156. let help = render_slash_command_help();
  157. assert!(help.contains("/help"));
  158. assert!(help.contains("/status"));
  159. assert!(help.contains("/compact"));
  160. assert!(help.contains("/model [model]"));
  161. assert_eq!(slash_command_specs().len(), 4);
  162. }
  163. #[test]
  164. fn compacts_sessions_via_slash_command() {
  165. let session = Session {
  166. version: 1,
  167. messages: vec![
  168. ConversationMessage::user_text("a ".repeat(200)),
  169. ConversationMessage::assistant(vec![ContentBlock::Text {
  170. text: "b ".repeat(200),
  171. }]),
  172. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  173. ConversationMessage::assistant(vec![ContentBlock::Text {
  174. text: "recent".to_string(),
  175. }]),
  176. ],
  177. };
  178. let result = handle_slash_command(
  179. "/compact",
  180. &session,
  181. CompactionConfig {
  182. preserve_recent_messages: 2,
  183. max_estimated_tokens: 1,
  184. },
  185. )
  186. .expect("slash command should be handled");
  187. assert!(result.message.contains("Compacted 2 messages"));
  188. assert_eq!(result.session.messages[0].role, MessageRole::System);
  189. }
  190. #[test]
  191. fn help_command_is_non_mutating() {
  192. let session = Session::new();
  193. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  194. .expect("help command should be handled");
  195. assert_eq!(result.session, session);
  196. assert!(result.message.contains("Available commands:"));
  197. }
  198. #[test]
  199. fn ignores_unknown_or_runtime_bound_slash_commands() {
  200. let session = Session::new();
  201. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  202. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  203. assert!(
  204. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  205. );
  206. }
  207. }