lib.rs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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. SlashCommandSpec {
  55. name: "permissions",
  56. summary: "Show or switch the active permission mode",
  57. argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
  58. },
  59. SlashCommandSpec {
  60. name: "clear",
  61. summary: "Start a fresh local session",
  62. argument_hint: None,
  63. },
  64. SlashCommandSpec {
  65. name: "cost",
  66. summary: "Show cumulative token usage for this session",
  67. argument_hint: None,
  68. },
  69. SlashCommandSpec {
  70. name: "resume",
  71. summary: "Load a saved session into the REPL",
  72. argument_hint: Some("<session-path>"),
  73. },
  74. SlashCommandSpec {
  75. name: "config",
  76. summary: "Inspect discovered Claude config files",
  77. argument_hint: None,
  78. },
  79. SlashCommandSpec {
  80. name: "memory",
  81. summary: "Inspect loaded Claude instruction memory files",
  82. argument_hint: None,
  83. },
  84. SlashCommandSpec {
  85. name: "init",
  86. summary: "Create a starter CLAUDE.md for this repo",
  87. argument_hint: None,
  88. },
  89. ];
  90. #[derive(Debug, Clone, PartialEq, Eq)]
  91. pub enum SlashCommand {
  92. Help,
  93. Status,
  94. Compact,
  95. Model { model: Option<String> },
  96. Permissions { mode: Option<String> },
  97. Clear,
  98. Cost,
  99. Resume { session_path: Option<String> },
  100. Config,
  101. Memory,
  102. Init,
  103. Unknown(String),
  104. }
  105. impl SlashCommand {
  106. #[must_use]
  107. pub fn parse(input: &str) -> Option<Self> {
  108. let trimmed = input.trim();
  109. if !trimmed.starts_with('/') {
  110. return None;
  111. }
  112. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  113. let command = parts.next().unwrap_or_default();
  114. Some(match command {
  115. "help" => Self::Help,
  116. "status" => Self::Status,
  117. "compact" => Self::Compact,
  118. "model" => Self::Model {
  119. model: parts.next().map(ToOwned::to_owned),
  120. },
  121. "permissions" => Self::Permissions {
  122. mode: parts.next().map(ToOwned::to_owned),
  123. },
  124. "clear" => Self::Clear,
  125. "cost" => Self::Cost,
  126. "resume" => Self::Resume {
  127. session_path: parts.next().map(ToOwned::to_owned),
  128. },
  129. "config" => Self::Config,
  130. "memory" => Self::Memory,
  131. "init" => Self::Init,
  132. other => Self::Unknown(other.to_string()),
  133. })
  134. }
  135. }
  136. #[must_use]
  137. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  138. SLASH_COMMAND_SPECS
  139. }
  140. #[must_use]
  141. pub fn render_slash_command_help() -> String {
  142. let mut lines = vec!["Available commands:".to_string()];
  143. for spec in slash_command_specs() {
  144. let name = match spec.argument_hint {
  145. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  146. None => format!("/{}", spec.name),
  147. };
  148. lines.push(format!(" {name:<20} {}", spec.summary));
  149. }
  150. lines.join("\n")
  151. }
  152. #[derive(Debug, Clone, PartialEq, Eq)]
  153. pub struct SlashCommandResult {
  154. pub message: String,
  155. pub session: Session,
  156. }
  157. #[must_use]
  158. pub fn handle_slash_command(
  159. input: &str,
  160. session: &Session,
  161. compaction: CompactionConfig,
  162. ) -> Option<SlashCommandResult> {
  163. match SlashCommand::parse(input)? {
  164. SlashCommand::Compact => {
  165. let result = compact_session(session, compaction);
  166. let message = if result.removed_message_count == 0 {
  167. "Compaction skipped: session is below the compaction threshold.".to_string()
  168. } else {
  169. format!(
  170. "Compacted {} messages into a resumable system summary.",
  171. result.removed_message_count
  172. )
  173. };
  174. Some(SlashCommandResult {
  175. message,
  176. session: result.compacted_session,
  177. })
  178. }
  179. SlashCommand::Help => Some(SlashCommandResult {
  180. message: render_slash_command_help(),
  181. session: session.clone(),
  182. }),
  183. SlashCommand::Status
  184. | SlashCommand::Model { .. }
  185. | SlashCommand::Permissions { .. }
  186. | SlashCommand::Clear
  187. | SlashCommand::Cost
  188. | SlashCommand::Resume { .. }
  189. | SlashCommand::Config
  190. | SlashCommand::Memory
  191. | SlashCommand::Init
  192. | SlashCommand::Unknown(_) => None,
  193. }
  194. }
  195. #[cfg(test)]
  196. mod tests {
  197. use super::{
  198. handle_slash_command, render_slash_command_help, slash_command_specs, SlashCommand,
  199. };
  200. use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
  201. #[test]
  202. fn parses_supported_slash_commands() {
  203. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  204. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  205. assert_eq!(
  206. SlashCommand::parse("/model claude-opus"),
  207. Some(SlashCommand::Model {
  208. model: Some("claude-opus".to_string()),
  209. })
  210. );
  211. assert_eq!(
  212. SlashCommand::parse("/model"),
  213. Some(SlashCommand::Model { model: None })
  214. );
  215. assert_eq!(
  216. SlashCommand::parse("/permissions read-only"),
  217. Some(SlashCommand::Permissions {
  218. mode: Some("read-only".to_string()),
  219. })
  220. );
  221. assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear));
  222. assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
  223. assert_eq!(
  224. SlashCommand::parse("/resume session.json"),
  225. Some(SlashCommand::Resume {
  226. session_path: Some("session.json".to_string()),
  227. })
  228. );
  229. assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
  230. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  231. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  232. }
  233. #[test]
  234. fn renders_help_from_shared_specs() {
  235. let help = render_slash_command_help();
  236. assert!(help.contains("/help"));
  237. assert!(help.contains("/status"));
  238. assert!(help.contains("/compact"));
  239. assert!(help.contains("/model [model]"));
  240. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  241. assert!(help.contains("/clear"));
  242. assert!(help.contains("/cost"));
  243. assert!(help.contains("/resume <session-path>"));
  244. assert!(help.contains("/config"));
  245. assert!(help.contains("/memory"));
  246. assert!(help.contains("/init"));
  247. assert_eq!(slash_command_specs().len(), 11);
  248. }
  249. #[test]
  250. fn compacts_sessions_via_slash_command() {
  251. let session = Session {
  252. version: 1,
  253. messages: vec![
  254. ConversationMessage::user_text("a ".repeat(200)),
  255. ConversationMessage::assistant(vec![ContentBlock::Text {
  256. text: "b ".repeat(200),
  257. }]),
  258. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  259. ConversationMessage::assistant(vec![ContentBlock::Text {
  260. text: "recent".to_string(),
  261. }]),
  262. ],
  263. };
  264. let result = handle_slash_command(
  265. "/compact",
  266. &session,
  267. CompactionConfig {
  268. preserve_recent_messages: 2,
  269. max_estimated_tokens: 1,
  270. },
  271. )
  272. .expect("slash command should be handled");
  273. assert!(result.message.contains("Compacted 2 messages"));
  274. assert_eq!(result.session.messages[0].role, MessageRole::System);
  275. }
  276. #[test]
  277. fn help_command_is_non_mutating() {
  278. let session = Session::new();
  279. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  280. .expect("help command should be handled");
  281. assert_eq!(result.session, session);
  282. assert!(result.message.contains("Available commands:"));
  283. }
  284. #[test]
  285. fn ignores_unknown_or_runtime_bound_slash_commands() {
  286. let session = Session::new();
  287. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  288. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  289. assert!(
  290. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  291. );
  292. assert!(handle_slash_command(
  293. "/permissions read-only",
  294. &session,
  295. CompactionConfig::default()
  296. )
  297. .is_none());
  298. assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
  299. assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
  300. assert!(handle_slash_command(
  301. "/resume session.json",
  302. &session,
  303. CompactionConfig::default()
  304. )
  305. .is_none());
  306. assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
  307. }
  308. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。