app.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. use std::io::{self, Write};
  2. use std::path::PathBuf;
  3. use crate::args::{OutputFormat, PermissionMode};
  4. use crate::input::{LineEditor, ReadOutcome};
  5. use crate::render::{Spinner, TerminalRenderer};
  6. use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
  7. #[derive(Debug, Clone, PartialEq, Eq)]
  8. pub struct SessionConfig {
  9. pub model: String,
  10. pub permission_mode: PermissionMode,
  11. pub config: Option<PathBuf>,
  12. pub output_format: OutputFormat,
  13. }
  14. #[derive(Debug, Clone, PartialEq, Eq)]
  15. pub struct SessionState {
  16. pub turns: usize,
  17. pub compacted_messages: usize,
  18. pub last_model: String,
  19. pub last_usage: UsageSummary,
  20. }
  21. impl SessionState {
  22. #[must_use]
  23. pub fn new(model: impl Into<String>) -> Self {
  24. Self {
  25. turns: 0,
  26. compacted_messages: 0,
  27. last_model: model.into(),
  28. last_usage: UsageSummary::default(),
  29. }
  30. }
  31. }
  32. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  33. pub enum CommandResult {
  34. Continue,
  35. }
  36. #[derive(Debug, Clone, PartialEq, Eq)]
  37. pub enum SlashCommand {
  38. Help,
  39. Status,
  40. Compact,
  41. Model { model: Option<String> },
  42. Permissions { mode: Option<String> },
  43. Config { section: Option<String> },
  44. Memory,
  45. Clear { confirm: bool },
  46. Unknown(String),
  47. }
  48. impl SlashCommand {
  49. #[must_use]
  50. pub fn parse(input: &str) -> Option<Self> {
  51. let trimmed = input.trim();
  52. if !trimmed.starts_with('/') {
  53. return None;
  54. }
  55. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  56. let command = parts.next().unwrap_or_default();
  57. Some(match command {
  58. "help" => Self::Help,
  59. "status" => Self::Status,
  60. "compact" => Self::Compact,
  61. "model" => Self::Model {
  62. model: parts.next().map(ToOwned::to_owned),
  63. },
  64. "permissions" => Self::Permissions {
  65. mode: parts.next().map(ToOwned::to_owned),
  66. },
  67. "config" => Self::Config {
  68. section: parts.next().map(ToOwned::to_owned),
  69. },
  70. "memory" => Self::Memory,
  71. "clear" => Self::Clear {
  72. confirm: parts.next() == Some("--confirm"),
  73. },
  74. other => Self::Unknown(other.to_string()),
  75. })
  76. }
  77. }
  78. struct SlashCommandHandler {
  79. command: SlashCommand,
  80. summary: &'static str,
  81. }
  82. const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
  83. SlashCommandHandler {
  84. command: SlashCommand::Help,
  85. summary: "Show command help",
  86. },
  87. SlashCommandHandler {
  88. command: SlashCommand::Status,
  89. summary: "Show current session status",
  90. },
  91. SlashCommandHandler {
  92. command: SlashCommand::Compact,
  93. summary: "Compact local session history",
  94. },
  95. SlashCommandHandler {
  96. command: SlashCommand::Model { model: None },
  97. summary: "Show or switch the active model",
  98. },
  99. SlashCommandHandler {
  100. command: SlashCommand::Permissions { mode: None },
  101. summary: "Show or switch the active permission mode",
  102. },
  103. SlashCommandHandler {
  104. command: SlashCommand::Config { section: None },
  105. summary: "Inspect current config path or section",
  106. },
  107. SlashCommandHandler {
  108. command: SlashCommand::Memory,
  109. summary: "Inspect loaded memory/instruction files",
  110. },
  111. SlashCommandHandler {
  112. command: SlashCommand::Clear { confirm: false },
  113. summary: "Start a fresh local session",
  114. },
  115. ];
  116. pub struct CliApp {
  117. config: SessionConfig,
  118. renderer: TerminalRenderer,
  119. state: SessionState,
  120. conversation_client: ConversationClient,
  121. conversation_history: Vec<ConversationMessage>,
  122. }
  123. impl CliApp {
  124. pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
  125. let state = SessionState::new(config.model.clone());
  126. let conversation_client = ConversationClient::from_env(config.model.clone())?;
  127. Ok(Self {
  128. config,
  129. renderer: TerminalRenderer::new(),
  130. state,
  131. conversation_client,
  132. conversation_history: Vec::new(),
  133. })
  134. }
  135. pub fn run_repl(&mut self) -> io::Result<()> {
  136. let mut editor = LineEditor::new("› ", Vec::new());
  137. println!("Rusty Claude CLI interactive mode");
  138. println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
  139. loop {
  140. match editor.read_line()? {
  141. ReadOutcome::Submit(input) => {
  142. if input.trim().is_empty() {
  143. continue;
  144. }
  145. self.handle_submission(&input, &mut io::stdout())?;
  146. }
  147. ReadOutcome::Cancel => continue,
  148. ReadOutcome::Exit => break,
  149. }
  150. }
  151. Ok(())
  152. }
  153. pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> {
  154. self.render_response(prompt, out)
  155. }
  156. pub fn handle_submission(
  157. &mut self,
  158. input: &str,
  159. out: &mut impl Write,
  160. ) -> io::Result<CommandResult> {
  161. if let Some(command) = SlashCommand::parse(input) {
  162. return self.dispatch_slash_command(command, out);
  163. }
  164. self.state.turns += 1;
  165. self.render_response(input, out)?;
  166. Ok(CommandResult::Continue)
  167. }
  168. fn dispatch_slash_command(
  169. &mut self,
  170. command: SlashCommand,
  171. out: &mut impl Write,
  172. ) -> io::Result<CommandResult> {
  173. match command {
  174. SlashCommand::Help => Self::handle_help(out),
  175. SlashCommand::Status => self.handle_status(out),
  176. SlashCommand::Compact => self.handle_compact(out),
  177. SlashCommand::Model { model } => self.handle_model(model.as_deref(), out),
  178. SlashCommand::Permissions { mode } => self.handle_permissions(mode.as_deref(), out),
  179. SlashCommand::Config { section } => self.handle_config(section.as_deref(), out),
  180. SlashCommand::Memory => self.handle_memory(out),
  181. SlashCommand::Clear { confirm } => self.handle_clear(confirm, out),
  182. SlashCommand::Unknown(name) => {
  183. writeln!(out, "Unknown slash command: /{name}")?;
  184. Ok(CommandResult::Continue)
  185. }
  186. }
  187. }
  188. fn handle_help(out: &mut impl Write) -> io::Result<CommandResult> {
  189. writeln!(out, "Available commands:")?;
  190. for handler in SLASH_COMMAND_HANDLERS {
  191. let name = match handler.command {
  192. SlashCommand::Help => "/help",
  193. SlashCommand::Status => "/status",
  194. SlashCommand::Compact => "/compact",
  195. SlashCommand::Model { .. } => "/model [model]",
  196. SlashCommand::Permissions { .. } => "/permissions [mode]",
  197. SlashCommand::Config { .. } => "/config [section]",
  198. SlashCommand::Memory => "/memory",
  199. SlashCommand::Clear { .. } => "/clear [--confirm]",
  200. SlashCommand::Unknown(_) => continue,
  201. };
  202. writeln!(out, " {name:<9} {}", handler.summary)?;
  203. }
  204. Ok(CommandResult::Continue)
  205. }
  206. fn handle_status(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
  207. writeln!(
  208. out,
  209. "status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
  210. self.state.turns,
  211. self.state.last_model,
  212. self.config.permission_mode,
  213. self.config.output_format,
  214. self.state.last_usage.input_tokens,
  215. self.state.last_usage.output_tokens,
  216. self.config
  217. .config
  218. .as_ref()
  219. .map_or_else(|| String::from("<none>"), |path| path.display().to_string())
  220. )?;
  221. Ok(CommandResult::Continue)
  222. }
  223. fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
  224. self.state.compacted_messages += self.state.turns;
  225. self.state.turns = 0;
  226. self.conversation_history.clear();
  227. writeln!(
  228. out,
  229. "Compacted session history into a local summary ({} messages total compacted).",
  230. self.state.compacted_messages
  231. )?;
  232. Ok(CommandResult::Continue)
  233. }
  234. fn handle_model(
  235. &mut self,
  236. model: Option<&str>,
  237. out: &mut impl Write,
  238. ) -> io::Result<CommandResult> {
  239. match model {
  240. Some(model) => {
  241. self.config.model = model.to_string();
  242. self.state.last_model = model.to_string();
  243. writeln!(out, "Active model set to {model}")?;
  244. }
  245. None => {
  246. writeln!(out, "Active model: {}", self.config.model)?;
  247. }
  248. }
  249. Ok(CommandResult::Continue)
  250. }
  251. fn handle_permissions(
  252. &mut self,
  253. mode: Option<&str>,
  254. out: &mut impl Write,
  255. ) -> io::Result<CommandResult> {
  256. match mode {
  257. None => writeln!(out, "Permission mode: {:?}", self.config.permission_mode)?,
  258. Some("read-only") => {
  259. self.config.permission_mode = PermissionMode::ReadOnly;
  260. writeln!(out, "Permission mode set to read-only")?;
  261. }
  262. Some("workspace-write") => {
  263. self.config.permission_mode = PermissionMode::WorkspaceWrite;
  264. writeln!(out, "Permission mode set to workspace-write")?;
  265. }
  266. Some("danger-full-access") => {
  267. self.config.permission_mode = PermissionMode::DangerFullAccess;
  268. writeln!(out, "Permission mode set to danger-full-access")?;
  269. }
  270. Some(other) => {
  271. writeln!(out, "Unknown permission mode: {other}")?;
  272. }
  273. }
  274. Ok(CommandResult::Continue)
  275. }
  276. fn handle_config(
  277. &mut self,
  278. section: Option<&str>,
  279. out: &mut impl Write,
  280. ) -> io::Result<CommandResult> {
  281. match section {
  282. None => writeln!(
  283. out,
  284. "Config path: {}",
  285. self.config
  286. .config
  287. .as_ref()
  288. .map_or_else(|| String::from("<none>"), |path| path.display().to_string())
  289. )?,
  290. Some(section) => writeln!(
  291. out,
  292. "Config section `{section}` is not fully implemented yet; current config path is {}",
  293. self.config
  294. .config
  295. .as_ref()
  296. .map_or_else(|| String::from("<none>"), |path| path.display().to_string())
  297. )?,
  298. }
  299. Ok(CommandResult::Continue)
  300. }
  301. fn handle_memory(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
  302. writeln!(
  303. out,
  304. "Loaded memory/config file: {}",
  305. self.config
  306. .config
  307. .as_ref()
  308. .map_or_else(|| String::from("<none>"), |path| path.display().to_string())
  309. )?;
  310. Ok(CommandResult::Continue)
  311. }
  312. fn handle_clear(&mut self, confirm: bool, out: &mut impl Write) -> io::Result<CommandResult> {
  313. if !confirm {
  314. writeln!(out, "Refusing to clear without confirmation. Re-run as /clear --confirm")?;
  315. return Ok(CommandResult::Continue);
  316. }
  317. self.state.turns = 0;
  318. self.state.compacted_messages = 0;
  319. self.state.last_usage = UsageSummary::default();
  320. self.conversation_history.clear();
  321. writeln!(out, "Started a fresh local session.")?;
  322. Ok(CommandResult::Continue)
  323. }
  324. fn handle_stream_event(
  325. renderer: &TerminalRenderer,
  326. event: StreamEvent,
  327. stream_spinner: &mut Spinner,
  328. tool_spinner: &mut Spinner,
  329. saw_text: &mut bool,
  330. turn_usage: &mut UsageSummary,
  331. out: &mut impl Write,
  332. ) {
  333. match event {
  334. StreamEvent::TextDelta(delta) => {
  335. if !*saw_text {
  336. let _ =
  337. stream_spinner.finish("Streaming response", renderer.color_theme(), out);
  338. *saw_text = true;
  339. }
  340. let _ = write!(out, "{delta}");
  341. let _ = out.flush();
  342. }
  343. StreamEvent::ToolCallStart { name, input } => {
  344. if *saw_text {
  345. let _ = writeln!(out);
  346. }
  347. let _ = tool_spinner.tick(
  348. &format!("Running tool `{name}` with {input}"),
  349. renderer.color_theme(),
  350. out,
  351. );
  352. }
  353. StreamEvent::ToolCallResult {
  354. name,
  355. output,
  356. is_error,
  357. } => {
  358. let label = if is_error {
  359. format!("Tool `{name}` failed")
  360. } else {
  361. format!("Tool `{name}` completed")
  362. };
  363. let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
  364. let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
  365. let _ = renderer.stream_markdown(&rendered_output, out);
  366. }
  367. StreamEvent::Usage(usage) => {
  368. *turn_usage = usage;
  369. }
  370. }
  371. }
  372. fn write_turn_output(
  373. &self,
  374. summary: &runtime::TurnSummary,
  375. out: &mut impl Write,
  376. ) -> io::Result<()> {
  377. match self.config.output_format {
  378. OutputFormat::Text => {
  379. writeln!(
  380. out,
  381. "\nToken usage: {} input / {} output",
  382. self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
  383. )?;
  384. }
  385. OutputFormat::Json => {
  386. writeln!(
  387. out,
  388. "{}",
  389. serde_json::json!({
  390. "message": summary.assistant_text,
  391. "usage": {
  392. "input_tokens": self.state.last_usage.input_tokens,
  393. "output_tokens": self.state.last_usage.output_tokens,
  394. }
  395. })
  396. )?;
  397. }
  398. OutputFormat::Ndjson => {
  399. writeln!(
  400. out,
  401. "{}",
  402. serde_json::json!({
  403. "type": "message",
  404. "text": summary.assistant_text,
  405. "usage": {
  406. "input_tokens": self.state.last_usage.input_tokens,
  407. "output_tokens": self.state.last_usage.output_tokens,
  408. }
  409. })
  410. )?;
  411. }
  412. }
  413. Ok(())
  414. }
  415. fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
  416. let mut stream_spinner = Spinner::new();
  417. stream_spinner.tick(
  418. "Opening conversation stream",
  419. self.renderer.color_theme(),
  420. out,
  421. )?;
  422. let mut turn_usage = UsageSummary::default();
  423. let mut tool_spinner = Spinner::new();
  424. let mut saw_text = false;
  425. let renderer = &self.renderer;
  426. let result =
  427. self.conversation_client
  428. .run_turn(&mut self.conversation_history, input, |event| {
  429. Self::handle_stream_event(
  430. renderer,
  431. event,
  432. &mut stream_spinner,
  433. &mut tool_spinner,
  434. &mut saw_text,
  435. &mut turn_usage,
  436. out,
  437. );
  438. });
  439. let summary = match result {
  440. Ok(summary) => summary,
  441. Err(error) => {
  442. stream_spinner.fail(
  443. "Streaming response failed",
  444. self.renderer.color_theme(),
  445. out,
  446. )?;
  447. return Err(io::Error::other(error));
  448. }
  449. };
  450. self.state.last_usage = summary.usage.clone();
  451. if saw_text {
  452. writeln!(out)?;
  453. } else {
  454. stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
  455. }
  456. self.write_turn_output(&summary, out)?;
  457. let _ = turn_usage;
  458. Ok(())
  459. }
  460. }
  461. #[cfg(test)]
  462. mod tests {
  463. use std::path::PathBuf;
  464. use crate::args::{OutputFormat, PermissionMode};
  465. use super::{CommandResult, SessionConfig, SlashCommand};
  466. #[test]
  467. fn parses_required_slash_commands() {
  468. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  469. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  470. assert_eq!(
  471. SlashCommand::parse("/compact now"),
  472. Some(SlashCommand::Compact)
  473. );
  474. }
  475. #[test]
  476. fn help_output_lists_commands() {
  477. let mut out = Vec::new();
  478. let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
  479. assert_eq!(result, CommandResult::Continue);
  480. let output = String::from_utf8_lossy(&out);
  481. assert!(output.contains("/help"));
  482. assert!(output.contains("/status"));
  483. assert!(output.contains("/compact"));
  484. }
  485. #[test]
  486. fn session_state_tracks_config_values() {
  487. let config = SessionConfig {
  488. model: "claude".into(),
  489. permission_mode: PermissionMode::DangerFullAccess,
  490. config: Some(PathBuf::from("settings.toml")),
  491. output_format: OutputFormat::Text,
  492. };
  493. assert_eq!(config.model, "claude");
  494. assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess);
  495. assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
  496. }
  497. }