lib.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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. pub resume_supported: bool,
  33. }
  34. const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
  35. SlashCommandSpec {
  36. name: "help",
  37. summary: "Show available slash commands",
  38. argument_hint: None,
  39. resume_supported: true,
  40. },
  41. SlashCommandSpec {
  42. name: "status",
  43. summary: "Show current session status",
  44. argument_hint: None,
  45. resume_supported: true,
  46. },
  47. SlashCommandSpec {
  48. name: "compact",
  49. summary: "Compact local session history",
  50. argument_hint: None,
  51. resume_supported: true,
  52. },
  53. SlashCommandSpec {
  54. name: "model",
  55. summary: "Show or switch the active model",
  56. argument_hint: Some("[model]"),
  57. resume_supported: false,
  58. },
  59. SlashCommandSpec {
  60. name: "permissions",
  61. summary: "Show or switch the active permission mode",
  62. argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
  63. resume_supported: false,
  64. },
  65. SlashCommandSpec {
  66. name: "clear",
  67. summary: "Start a fresh local session",
  68. argument_hint: Some("[--confirm]"),
  69. resume_supported: true,
  70. },
  71. SlashCommandSpec {
  72. name: "cost",
  73. summary: "Show cumulative token usage for this session",
  74. argument_hint: None,
  75. resume_supported: true,
  76. },
  77. SlashCommandSpec {
  78. name: "resume",
  79. summary: "Load a saved session into the REPL",
  80. argument_hint: Some("<session-id-or-path>"),
  81. resume_supported: false,
  82. },
  83. SlashCommandSpec {
  84. name: "config",
  85. summary: "Inspect Claude config files or merged sections",
  86. argument_hint: Some("[env|hooks|model]"),
  87. resume_supported: true,
  88. },
  89. SlashCommandSpec {
  90. name: "memory",
  91. summary: "Inspect loaded Claude instruction memory files",
  92. argument_hint: None,
  93. resume_supported: true,
  94. },
  95. SlashCommandSpec {
  96. name: "init",
  97. summary: "Create a starter CLAUDE.md for this repo",
  98. argument_hint: None,
  99. resume_supported: true,
  100. },
  101. SlashCommandSpec {
  102. name: "diff",
  103. summary: "Show git diff for current workspace changes",
  104. argument_hint: None,
  105. resume_supported: true,
  106. },
  107. SlashCommandSpec {
  108. name: "version",
  109. summary: "Show CLI version and build information",
  110. argument_hint: None,
  111. resume_supported: true,
  112. },
  113. SlashCommandSpec {
  114. name: "export",
  115. summary: "Export the current conversation to a file",
  116. argument_hint: Some("[file]"),
  117. resume_supported: true,
  118. },
  119. SlashCommandSpec {
  120. name: "session",
  121. summary: "List or switch managed local sessions",
  122. argument_hint: Some("[list|switch <session-id>]"),
  123. resume_supported: false,
  124. },
  125. SlashCommandSpec {
  126. name: "sessions",
  127. summary: "List recent managed local sessions",
  128. argument_hint: None,
  129. resume_supported: false,
  130. },
  131. ];
  132. #[derive(Debug, Clone, PartialEq, Eq)]
  133. pub enum SlashCommand {
  134. Help,
  135. Status,
  136. Compact,
  137. Model {
  138. model: Option<String>,
  139. },
  140. Permissions {
  141. mode: Option<String>,
  142. },
  143. Clear {
  144. confirm: bool,
  145. },
  146. Cost,
  147. Resume {
  148. session_path: Option<String>,
  149. },
  150. Config {
  151. section: Option<String>,
  152. },
  153. Memory,
  154. Init,
  155. Diff,
  156. Version,
  157. Export {
  158. path: Option<String>,
  159. },
  160. Session {
  161. action: Option<String>,
  162. target: Option<String>,
  163. },
  164. Sessions,
  165. Unknown(String),
  166. }
  167. impl SlashCommand {
  168. #[must_use]
  169. pub fn parse(input: &str) -> Option<Self> {
  170. let trimmed = input.trim();
  171. if !trimmed.starts_with('/') {
  172. return None;
  173. }
  174. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  175. let command = parts.next().unwrap_or_default();
  176. Some(match command {
  177. "help" => Self::Help,
  178. "status" => Self::Status,
  179. "compact" => Self::Compact,
  180. "model" => Self::Model {
  181. model: parts.next().map(ToOwned::to_owned),
  182. },
  183. "permissions" => Self::Permissions {
  184. mode: parts.next().map(ToOwned::to_owned),
  185. },
  186. "clear" => Self::Clear {
  187. confirm: parts.next() == Some("--confirm"),
  188. },
  189. "cost" => Self::Cost,
  190. "resume" => Self::Resume {
  191. session_path: parts.next().map(ToOwned::to_owned),
  192. },
  193. "config" => Self::Config {
  194. section: parts.next().map(ToOwned::to_owned),
  195. },
  196. "memory" => Self::Memory,
  197. "init" => Self::Init,
  198. "diff" => Self::Diff,
  199. "version" => Self::Version,
  200. "export" => Self::Export {
  201. path: parts.next().map(ToOwned::to_owned),
  202. },
  203. "session" => Self::Session {
  204. action: parts.next().map(ToOwned::to_owned),
  205. target: parts.next().map(ToOwned::to_owned),
  206. },
  207. "sessions" => Self::Sessions,
  208. other => Self::Unknown(other.to_string()),
  209. })
  210. }
  211. }
  212. #[must_use]
  213. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  214. SLASH_COMMAND_SPECS
  215. }
  216. #[must_use]
  217. pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
  218. slash_command_specs()
  219. .iter()
  220. .filter(|spec| spec.resume_supported)
  221. .collect()
  222. }
  223. #[must_use]
  224. pub fn render_slash_command_help() -> String {
  225. let mut lines = vec![
  226. "Slash commands".to_string(),
  227. " [resume] means the command also works with --resume SESSION.json".to_string(),
  228. ];
  229. for spec in slash_command_specs() {
  230. let name = match spec.argument_hint {
  231. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  232. None => format!("/{}", spec.name),
  233. };
  234. let resume = if spec.resume_supported {
  235. " [resume]"
  236. } else {
  237. ""
  238. };
  239. lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
  240. }
  241. lines.join("\n")
  242. }
  243. #[derive(Debug, Clone, PartialEq, Eq)]
  244. pub struct SlashCommandResult {
  245. pub message: String,
  246. pub session: Session,
  247. }
  248. #[must_use]
  249. pub fn handle_slash_command(
  250. input: &str,
  251. session: &Session,
  252. compaction: CompactionConfig,
  253. ) -> Option<SlashCommandResult> {
  254. match SlashCommand::parse(input)? {
  255. SlashCommand::Compact => {
  256. let result = compact_session(session, compaction);
  257. let message = if result.removed_message_count == 0 {
  258. "Compaction skipped: session is below the compaction threshold.".to_string()
  259. } else {
  260. format!(
  261. "Compacted {} messages into a resumable system summary.",
  262. result.removed_message_count
  263. )
  264. };
  265. Some(SlashCommandResult {
  266. message,
  267. session: result.compacted_session,
  268. })
  269. }
  270. SlashCommand::Help => Some(SlashCommandResult {
  271. message: render_slash_command_help(),
  272. session: session.clone(),
  273. }),
  274. SlashCommand::Status
  275. | SlashCommand::Model { .. }
  276. | SlashCommand::Permissions { .. }
  277. | SlashCommand::Clear { .. }
  278. | SlashCommand::Cost
  279. | SlashCommand::Resume { .. }
  280. | SlashCommand::Config { .. }
  281. | SlashCommand::Memory
  282. | SlashCommand::Init
  283. | SlashCommand::Diff
  284. | SlashCommand::Version
  285. | SlashCommand::Export { .. }
  286. | SlashCommand::Session { .. }
  287. | SlashCommand::Sessions
  288. | SlashCommand::Unknown(_) => None,
  289. }
  290. }
  291. #[cfg(test)]
  292. mod tests {
  293. use super::{
  294. handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
  295. slash_command_specs, SlashCommand,
  296. };
  297. use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
  298. #[test]
  299. fn parses_supported_slash_commands() {
  300. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  301. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  302. assert_eq!(
  303. SlashCommand::parse("/model claude-opus"),
  304. Some(SlashCommand::Model {
  305. model: Some("claude-opus".to_string()),
  306. })
  307. );
  308. assert_eq!(
  309. SlashCommand::parse("/model"),
  310. Some(SlashCommand::Model { model: None })
  311. );
  312. assert_eq!(
  313. SlashCommand::parse("/permissions read-only"),
  314. Some(SlashCommand::Permissions {
  315. mode: Some("read-only".to_string()),
  316. })
  317. );
  318. assert_eq!(
  319. SlashCommand::parse("/clear"),
  320. Some(SlashCommand::Clear { confirm: false })
  321. );
  322. assert_eq!(
  323. SlashCommand::parse("/clear --confirm"),
  324. Some(SlashCommand::Clear { confirm: true })
  325. );
  326. assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
  327. assert_eq!(
  328. SlashCommand::parse("/resume session.json"),
  329. Some(SlashCommand::Resume {
  330. session_path: Some("session.json".to_string()),
  331. })
  332. );
  333. assert_eq!(
  334. SlashCommand::parse("/config"),
  335. Some(SlashCommand::Config { section: None })
  336. );
  337. assert_eq!(
  338. SlashCommand::parse("/config env"),
  339. Some(SlashCommand::Config {
  340. section: Some("env".to_string())
  341. })
  342. );
  343. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  344. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  345. assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
  346. assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
  347. assert_eq!(
  348. SlashCommand::parse("/export notes.txt"),
  349. Some(SlashCommand::Export {
  350. path: Some("notes.txt".to_string())
  351. })
  352. );
  353. assert_eq!(
  354. SlashCommand::parse("/session switch abc123"),
  355. Some(SlashCommand::Session {
  356. action: Some("switch".to_string()),
  357. target: Some("abc123".to_string())
  358. })
  359. );
  360. assert_eq!(
  361. SlashCommand::parse("/sessions"),
  362. Some(SlashCommand::Sessions)
  363. );
  364. }
  365. #[test]
  366. fn renders_help_from_shared_specs() {
  367. let help = render_slash_command_help();
  368. assert!(help.contains("works with --resume SESSION.json"));
  369. assert!(help.contains("/help"));
  370. assert!(help.contains("/status"));
  371. assert!(help.contains("/compact"));
  372. assert!(help.contains("/model [model]"));
  373. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  374. assert!(help.contains("/clear [--confirm]"));
  375. assert!(help.contains("/cost"));
  376. assert!(help.contains("/resume <session-id-or-path>"));
  377. assert!(help.contains("/config [env|hooks|model]"));
  378. assert!(help.contains("/memory"));
  379. assert!(help.contains("/init"));
  380. assert!(help.contains("/diff"));
  381. assert!(help.contains("/version"));
  382. assert!(help.contains("/export [file]"));
  383. assert!(help.contains("/session [list|switch <session-id>]"));
  384. assert!(help.contains("/sessions"));
  385. assert_eq!(slash_command_specs().len(), 16);
  386. assert_eq!(resume_supported_slash_commands().len(), 11);
  387. }
  388. #[test]
  389. fn compacts_sessions_via_slash_command() {
  390. let session = Session {
  391. version: 1,
  392. messages: vec![
  393. ConversationMessage::user_text("a ".repeat(200)),
  394. ConversationMessage::assistant(vec![ContentBlock::Text {
  395. text: "b ".repeat(200),
  396. }]),
  397. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  398. ConversationMessage::assistant(vec![ContentBlock::Text {
  399. text: "recent".to_string(),
  400. }]),
  401. ],
  402. metadata: None,
  403. };
  404. let result = handle_slash_command(
  405. "/compact",
  406. &session,
  407. CompactionConfig {
  408. preserve_recent_messages: 2,
  409. max_estimated_tokens: 1,
  410. },
  411. )
  412. .expect("slash command should be handled");
  413. assert!(result.message.contains("Compacted 2 messages"));
  414. assert_eq!(result.session.messages[0].role, MessageRole::System);
  415. }
  416. #[test]
  417. fn help_command_is_non_mutating() {
  418. let session = Session::new();
  419. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  420. .expect("help command should be handled");
  421. assert_eq!(result.session, session);
  422. assert!(result.message.contains("Slash commands"));
  423. }
  424. #[test]
  425. fn ignores_unknown_or_runtime_bound_slash_commands() {
  426. let session = Session::new();
  427. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  428. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  429. assert!(
  430. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  431. );
  432. assert!(handle_slash_command(
  433. "/permissions read-only",
  434. &session,
  435. CompactionConfig::default()
  436. )
  437. .is_none());
  438. assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
  439. assert!(
  440. handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
  441. .is_none()
  442. );
  443. assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
  444. assert!(handle_slash_command(
  445. "/resume session.json",
  446. &session,
  447. CompactionConfig::default()
  448. )
  449. .is_none());
  450. assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
  451. assert!(
  452. handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
  453. );
  454. assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
  455. assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
  456. assert!(
  457. handle_slash_command("/export note.txt", &session, CompactionConfig::default())
  458. .is_none()
  459. );
  460. assert!(
  461. handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
  462. );
  463. assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none());
  464. }
  465. }