app.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  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. Unknown(String),
  42. }
  43. impl SlashCommand {
  44. #[must_use]
  45. pub fn parse(input: &str) -> Option<Self> {
  46. let trimmed = input.trim();
  47. if !trimmed.starts_with('/') {
  48. return None;
  49. }
  50. let command = trimmed
  51. .trim_start_matches('/')
  52. .split_whitespace()
  53. .next()
  54. .unwrap_or_default();
  55. Some(match command {
  56. "help" => Self::Help,
  57. "status" => Self::Status,
  58. "compact" => Self::Compact,
  59. other => Self::Unknown(other.to_string()),
  60. })
  61. }
  62. }
  63. struct SlashCommandHandler {
  64. command: SlashCommand,
  65. summary: &'static str,
  66. }
  67. const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
  68. SlashCommandHandler {
  69. command: SlashCommand::Help,
  70. summary: "Show command help",
  71. },
  72. SlashCommandHandler {
  73. command: SlashCommand::Status,
  74. summary: "Show current session status",
  75. },
  76. SlashCommandHandler {
  77. command: SlashCommand::Compact,
  78. summary: "Compact local session history",
  79. },
  80. ];
  81. pub struct CliApp {
  82. config: SessionConfig,
  83. renderer: TerminalRenderer,
  84. state: SessionState,
  85. conversation_client: ConversationClient,
  86. conversation_history: Vec<ConversationMessage>,
  87. }
  88. impl CliApp {
  89. pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
  90. let state = SessionState::new(config.model.clone());
  91. let conversation_client = ConversationClient::from_env(config.model.clone())?;
  92. Ok(Self {
  93. config,
  94. renderer: TerminalRenderer::new(),
  95. state,
  96. conversation_client,
  97. conversation_history: Vec::new(),
  98. })
  99. }
  100. pub fn run_repl(&mut self) -> io::Result<()> {
  101. let mut editor = LineEditor::new("› ", Vec::new());
  102. println!("Rusty Claude CLI interactive mode");
  103. println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
  104. loop {
  105. match editor.read_line()? {
  106. ReadOutcome::Submit(input) => {
  107. if input.trim().is_empty() {
  108. continue;
  109. }
  110. self.handle_submission(&input, &mut io::stdout())?;
  111. }
  112. ReadOutcome::Cancel => continue,
  113. ReadOutcome::Exit => break,
  114. }
  115. }
  116. Ok(())
  117. }
  118. pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> {
  119. self.render_response(prompt, out)
  120. }
  121. pub fn handle_submission(
  122. &mut self,
  123. input: &str,
  124. out: &mut impl Write,
  125. ) -> io::Result<CommandResult> {
  126. if let Some(command) = SlashCommand::parse(input) {
  127. return self.dispatch_slash_command(command, out);
  128. }
  129. self.state.turns += 1;
  130. self.render_response(input, out)?;
  131. Ok(CommandResult::Continue)
  132. }
  133. fn dispatch_slash_command(
  134. &mut self,
  135. command: SlashCommand,
  136. out: &mut impl Write,
  137. ) -> io::Result<CommandResult> {
  138. match command {
  139. SlashCommand::Help => Self::handle_help(out),
  140. SlashCommand::Status => self.handle_status(out),
  141. SlashCommand::Compact => self.handle_compact(out),
  142. SlashCommand::Unknown(name) => {
  143. writeln!(out, "Unknown slash command: /{name}")?;
  144. Ok(CommandResult::Continue)
  145. }
  146. }
  147. }
  148. fn handle_help(out: &mut impl Write) -> io::Result<CommandResult> {
  149. writeln!(out, "Available commands:")?;
  150. for handler in SLASH_COMMAND_HANDLERS {
  151. let name = match handler.command {
  152. SlashCommand::Help => "/help",
  153. SlashCommand::Status => "/status",
  154. SlashCommand::Compact => "/compact",
  155. SlashCommand::Unknown(_) => continue,
  156. };
  157. writeln!(out, " {name:<9} {}", handler.summary)?;
  158. }
  159. Ok(CommandResult::Continue)
  160. }
  161. fn handle_status(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
  162. writeln!(
  163. out,
  164. "status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
  165. self.state.turns,
  166. self.state.last_model,
  167. self.config.permission_mode,
  168. self.config.output_format,
  169. self.state.last_usage.input_tokens,
  170. self.state.last_usage.output_tokens,
  171. self.config
  172. .config
  173. .as_ref()
  174. .map_or_else(|| String::from("<none>"), |path| path.display().to_string())
  175. )?;
  176. Ok(CommandResult::Continue)
  177. }
  178. fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
  179. self.state.compacted_messages += self.state.turns;
  180. self.state.turns = 0;
  181. self.conversation_history.clear();
  182. writeln!(
  183. out,
  184. "Compacted session history into a local summary ({} messages total compacted).",
  185. self.state.compacted_messages
  186. )?;
  187. Ok(CommandResult::Continue)
  188. }
  189. fn handle_stream_event(
  190. renderer: &TerminalRenderer,
  191. event: StreamEvent,
  192. stream_spinner: &mut Spinner,
  193. tool_spinner: &mut Spinner,
  194. saw_text: &mut bool,
  195. turn_usage: &mut UsageSummary,
  196. out: &mut impl Write,
  197. ) {
  198. match event {
  199. StreamEvent::TextDelta(delta) => {
  200. if !*saw_text {
  201. let _ =
  202. stream_spinner.finish("Streaming response", renderer.color_theme(), out);
  203. *saw_text = true;
  204. }
  205. let _ = write!(out, "{delta}");
  206. let _ = out.flush();
  207. }
  208. StreamEvent::ToolCallStart { name, input } => {
  209. if *saw_text {
  210. let _ = writeln!(out);
  211. }
  212. let _ = tool_spinner.tick(
  213. &format!("Running tool `{name}` with {input}"),
  214. renderer.color_theme(),
  215. out,
  216. );
  217. }
  218. StreamEvent::ToolCallResult {
  219. name,
  220. output,
  221. is_error,
  222. } => {
  223. let label = if is_error {
  224. format!("Tool `{name}` failed")
  225. } else {
  226. format!("Tool `{name}` completed")
  227. };
  228. let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
  229. let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
  230. let _ = renderer.stream_markdown(&rendered_output, out);
  231. }
  232. StreamEvent::Usage(usage) => {
  233. *turn_usage = usage;
  234. }
  235. }
  236. }
  237. fn write_turn_output(
  238. &self,
  239. summary: &runtime::TurnSummary,
  240. out: &mut impl Write,
  241. ) -> io::Result<()> {
  242. match self.config.output_format {
  243. OutputFormat::Text => {
  244. writeln!(
  245. out,
  246. "\nToken usage: {} input / {} output",
  247. self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
  248. )?;
  249. }
  250. OutputFormat::Json => {
  251. writeln!(
  252. out,
  253. "{}",
  254. serde_json::json!({
  255. "message": summary.assistant_text,
  256. "usage": {
  257. "input_tokens": self.state.last_usage.input_tokens,
  258. "output_tokens": self.state.last_usage.output_tokens,
  259. }
  260. })
  261. )?;
  262. }
  263. OutputFormat::Ndjson => {
  264. writeln!(
  265. out,
  266. "{}",
  267. serde_json::json!({
  268. "type": "message",
  269. "text": summary.assistant_text,
  270. "usage": {
  271. "input_tokens": self.state.last_usage.input_tokens,
  272. "output_tokens": self.state.last_usage.output_tokens,
  273. }
  274. })
  275. )?;
  276. }
  277. }
  278. Ok(())
  279. }
  280. fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
  281. let mut stream_spinner = Spinner::new();
  282. stream_spinner.tick(
  283. "Opening conversation stream",
  284. self.renderer.color_theme(),
  285. out,
  286. )?;
  287. let mut turn_usage = UsageSummary::default();
  288. let mut tool_spinner = Spinner::new();
  289. let mut saw_text = false;
  290. let renderer = &self.renderer;
  291. let result =
  292. self.conversation_client
  293. .run_turn(&mut self.conversation_history, input, |event| {
  294. Self::handle_stream_event(
  295. renderer,
  296. event,
  297. &mut stream_spinner,
  298. &mut tool_spinner,
  299. &mut saw_text,
  300. &mut turn_usage,
  301. out,
  302. );
  303. });
  304. let summary = match result {
  305. Ok(summary) => summary,
  306. Err(error) => {
  307. stream_spinner.fail(
  308. "Streaming response failed",
  309. self.renderer.color_theme(),
  310. out,
  311. )?;
  312. return Err(io::Error::other(error));
  313. }
  314. };
  315. self.state.last_usage = summary.usage.clone();
  316. if saw_text {
  317. writeln!(out)?;
  318. } else {
  319. stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
  320. }
  321. self.write_turn_output(&summary, out)?;
  322. let _ = turn_usage;
  323. Ok(())
  324. }
  325. }
  326. #[cfg(test)]
  327. mod tests {
  328. use std::path::PathBuf;
  329. use crate::args::{OutputFormat, PermissionMode};
  330. use super::{CommandResult, SessionConfig, SlashCommand};
  331. #[test]
  332. fn parses_required_slash_commands() {
  333. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  334. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  335. assert_eq!(
  336. SlashCommand::parse("/compact now"),
  337. Some(SlashCommand::Compact)
  338. );
  339. }
  340. #[test]
  341. fn help_output_lists_commands() {
  342. let mut out = Vec::new();
  343. let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
  344. assert_eq!(result, CommandResult::Continue);
  345. let output = String::from_utf8_lossy(&out);
  346. assert!(output.contains("/help"));
  347. assert!(output.contains("/status"));
  348. assert!(output.contains("/compact"));
  349. }
  350. #[test]
  351. fn session_state_tracks_config_values() {
  352. let config = SessionConfig {
  353. model: "claude".into(),
  354. permission_mode: PermissionMode::WorkspaceWrite,
  355. config: Some(PathBuf::from("settings.toml")),
  356. output_format: OutputFormat::Text,
  357. };
  358. assert_eq!(config.model, "claude");
  359. assert_eq!(config.permission_mode, PermissionMode::WorkspaceWrite);
  360. assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
  361. }
  362. }