diff --git a/Cargo.lock b/Cargo.lock index 47fb0e49c..2acfe57d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "coolor" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e93977247fb916abeee1ff8c6594c9b421fd9c26c9b720a3944acb2a7de27b" +dependencies = [ + "crossterm 0.27.0", +] + [[package]] name = "core-foundation" version = "0.10.0" @@ -507,6 +516,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crokey" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2975c131b96cb7cee37732dc5f0ededaedd053ce8a20b7999ba239ec2d1536e" +dependencies = [ + "crokey-proc_macros", + "crossterm 0.27.0", + "once_cell", + "serde", + "strict", +] + +[[package]] +name = "crokey-proc_macros" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397d3c009d8df93c4b063ddaa44a81ee7098feb056f99b00896c36e2cee9a9f7" +dependencies = [ + "crossterm 0.27.0", + "proc-macro2", + "quote", + "strict", + "syn 1.0.109", +] + [[package]] name = "croner" version = "3.0.1" @@ -518,6 +553,28 @@ dependencies = [ "strum 0.27.2", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -537,6 +594,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1872,6 +1938,29 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy-regex" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.93", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1956,6 +2045,15 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimad" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c5d708226d186590a7b6d4a9780e2bdda5f689e0d58cd17012a298efd745d2" +dependencies = [ + "once_cell", +] + [[package]] name = "miniz_oxide" version = "0.8.2" @@ -2450,6 +2548,7 @@ dependencies = [ "synchronized-writer", "tar", "tempfile", + "termimad", "textwrap", "thiserror 2.0.9", "tokio", @@ -2582,9 +2681,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2594,9 +2693,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3165,6 +3264,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" + [[package]] name = "strsim" version = "0.11.1" @@ -3324,6 +3429,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termimad" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b043fcd6def04c40b0ccce109af4107ab7949d2a6824cb5c1971dbfdac4bce20" +dependencies = [ + "coolor", + "crokey", + "crossbeam", + "lazy-regex", + "minimad", + "serde", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + [[package]] name = "textwrap" version = "0.16.1" diff --git a/Cargo.toml b/Cargo.toml index 605ef247e..ef6b09a5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ ratatui = "0.29" scopeguard = "1.2" schemars = "0.8" rmcp = { version = "0.16", features = ["transport-io"] } +termimad = "0.28" [profile.release] lto = "fat" diff --git a/src/commands/agent.rs b/src/commands/agent.rs index 367b26717..df00efd01 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -1,5 +1,6 @@ use std::io::Write; +use colored::ColoredString; use is_terminal::IsTerminal; use rand::Rng; use serde::Serialize; @@ -25,7 +26,7 @@ use crate::{ service::get_or_prompt_service, }, interact_or, - util::progress::{create_spinner, fail_spinner, success_spinner}, + util::progress::create_spinner, }; use super::*; @@ -137,31 +138,12 @@ async fn run_single_shot( is_tty: bool, ) -> Result<()> { if json { - let mut response = JsonResponse::default(); - - stream_chat(client, url, request, |event| { - accumulate_json_event(event, &mut response); - }) - .await?; - + let response = stream_json(client, url, request).await?; println!("{}", serde_json::to_string_pretty(&response).unwrap()); - Ok(()) } else { - let mut spinner: Option = None; - let mut has_printed_text = false; - - // Show a thinking spinner while waiting for the first event - if is_tty { - let msg = THINKING_MESSAGES[rand::thread_rng().gen_range(0..THINKING_MESSAGES.len())]; - println!(); - spinner = Some(create_spinner(msg.dimmed().to_string())); - } - - stream_chat(client, url, request, |event| { - handle_event_human(event, &mut spinner, &mut has_printed_text, is_tty); - }) - .await + stream_human(client, url, request, is_tty).await?; } + Ok(()) } async fn run_repl( @@ -213,41 +195,13 @@ async fn run_repl( }; if json { - let mut response = JsonResponse::default(); - - stream_chat(client, url, &request, |event| { - if let ChatEvent::Metadata { - thread_id: ref tid, .. - } = event - { - thread_id = Some(tid.clone()); - } - accumulate_json_event(event, &mut response); - }) - .await?; - - println!("{}", serde_json::to_string_pretty(&response).unwrap()); - } else { - let mut spinner: Option = None; - let mut has_printed_text = false; - - if is_tty { - let msg = - THINKING_MESSAGES[rand::thread_rng().gen_range(0..THINKING_MESSAGES.len())]; - println!(); - spinner = Some(create_spinner(msg.dimmed().to_string())); + let response = stream_json(client, url, &request).await?; + if let Some(tid) = response.thread_id.clone() { + thread_id = Some(tid); } - - stream_chat(client, url, &request, |event| { - if let ChatEvent::Metadata { - thread_id: ref tid, .. - } = event - { - thread_id = Some(tid.clone()); - } - handle_event_human(event, &mut spinner, &mut has_printed_text, is_tty); - }) - .await?; + println!("{}", serde_json::to_string_pretty(&response).unwrap()); + } else if let Some(new_tid) = stream_human(client, url, &request, is_tty).await? { + thread_id = Some(new_tid); } println!(); @@ -256,87 +210,166 @@ async fn run_repl( Ok(()) } -fn handle_event_human( - event: ChatEvent, - spinner: &mut Option, - has_printed_text: &mut bool, +async fn stream_human( + client: &reqwest::Client, + url: &str, + request: &ChatRequest, is_tty: bool, -) { - match event { - ChatEvent::Chunk { text } => { - if let Some(s) = spinner.take() { - s.finish_and_clear(); - } - if !*has_printed_text { - println!(); - print!("{} ", "Railway Agent:".purple().bold()); - *has_printed_text = true; - } - print!("{}", text); - let _ = std::io::stdout().flush(); +) -> Result> { + let mut renderer = HumanRenderer::new(is_tty); + let mut thread_id = None; + renderer.start_thinking(); + + stream_chat(client, url, request, |event| { + if let ChatEvent::Metadata { + thread_id: ref tid, .. + } = event + { + thread_id = Some(tid.clone()); } - ChatEvent::ToolCallReady { tool_name, .. } => { - if is_tty { - if let Some(s) = spinner.take() { - s.finish_and_clear(); + renderer.handle(event); + }) + .await?; + + Ok(thread_id) +} + +async fn stream_json( + client: &reqwest::Client, + url: &str, + request: &ChatRequest, +) -> Result { + let mut response = JsonResponse::default(); + stream_chat(client, url, request, |event| { + accumulate_json_event(event, &mut response); + }) + .await?; + Ok(response) +} + +struct HumanRenderer { + spinner: Option, + has_printed_text: bool, + pending_markdown: String, + block_start_pos: Option<(u16, u16)>, + is_tty: bool, +} + +impl HumanRenderer { + fn new(is_tty: bool) -> Self { + Self { + spinner: None, + has_printed_text: false, + pending_markdown: String::new(), + block_start_pos: None, + is_tty, + } + } + + fn start_thinking(&mut self) { + if self.is_tty { + let msg = THINKING_MESSAGES[rand::thread_rng().gen_range(0..THINKING_MESSAGES.len())]; + println!(); + self.spinner = Some(create_spinner(msg.dimmed().to_string())); + } + } + + fn clear_spinner(&mut self) -> bool { + self.spinner.take().map(|s| s.finish_and_clear()).is_some() + } + + fn handle(&mut self, event: ChatEvent) { + match event { + ChatEvent::Chunk { text } => { + let cleared = self.clear_spinner(); + if !self.has_printed_text { + if !cleared { + // Two newlines to guarantee one visible blank line between + // this response and whatever came before. + print!("\n\n"); + } + if self.is_tty { + let _ = std::io::stdout().flush(); + self.block_start_pos = crossterm::cursor::position().ok(); + } + print!("{} ", "Railway Agent:".purple().bold()); + self.has_printed_text = true; + self.pending_markdown.clear(); } - *has_printed_text = false; - println!(); - *spinner = Some(create_spinner(format!( + self.pending_markdown.push_str(&text); + print!("{}", text); + let _ = std::io::stdout().flush(); + } + ChatEvent::ToolCallReady { tool_name, .. } => { + if !self.is_tty { + return; + } + self.flush_pending(); + self.clear_spinner(); + self.has_printed_text = false; + self.spinner = Some(create_spinner(format!( "{} {}", "╰─".dimmed(), - format!(" Agent Tool: {tool_name} ") - .truecolor(255, 255, 255) - .on_truecolor(68, 68, 68) + tool_badge(&format!(" Agent Tool: {tool_name} ")) ))); } - } - ChatEvent::ToolExecutionComplete { is_error, .. } => { - if let Some(s) = spinner { - if is_error { - fail_spinner( - s, - format!( - "{}", - " Tool failed " - .truecolor(255, 255, 255) - .on_truecolor(68, 68, 68) - ), - ); - } else { - success_spinner( - s, - format!( - "{}", - " Done ".truecolor(255, 255, 255).on_truecolor(68, 68, 68) - ), - ); + ChatEvent::ToolExecutionComplete { is_error, .. } => { + self.clear_spinner(); + if self.is_tty { + let label = if is_error { + " ✗ Tool failed " + } else { + " ✓ Done " + }; + println!("{}", tool_badge(label)); } } - *spinner = None; - } - ChatEvent::Error { message } => { - if let Some(s) = spinner.take() { - s.finish_and_clear(); + ChatEvent::Error { message } => { + self.clear_spinner(); + eprintln!("{}: {}", "Error".red().bold(), message); } - eprintln!("{}: {}", "Error".red().bold(), message); - } - ChatEvent::Aborted { reason } => { - if let Some(s) = spinner.take() { - s.finish_and_clear(); + ChatEvent::Aborted { reason } => { + self.clear_spinner(); + let msg = reason.unwrap_or_else(|| "Request was aborted".to_string()); + eprintln!("{}: {}", "Aborted".yellow().bold(), msg); } - let msg = reason.unwrap_or_else(|| "Request was aborted".to_string()); - eprintln!("{}: {}", "Aborted".yellow().bold(), msg); + ChatEvent::WorkflowCompleted { .. } => { + if !self.flush_pending() { + println!(); + } + } + ChatEvent::Metadata { .. } => {} } - ChatEvent::WorkflowCompleted { .. } => { - println!(); + } + + fn flush_pending(&mut self) -> bool { + if self.pending_markdown.is_empty() { + return false; } - ChatEvent::Metadata { .. } => { - // Thread ID captured by caller; no output + + if let Some((col, row)) = self.block_start_pos.take() { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::cursor::MoveTo(col, row), + crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) + ); + } else { + println!(); } + + println!("{}", "Railway Agent:".purple().bold()); + termimad::MadSkin::default().print_text(&self.pending_markdown); + + self.pending_markdown.clear(); + let _ = std::io::stdout().flush(); + true } } +fn tool_badge(text: &str) -> ColoredString { + text.truecolor(255, 255, 255).on_truecolor(68, 68, 68) +} + fn accumulate_json_event(event: ChatEvent, response: &mut JsonResponse) { match event { ChatEvent::Metadata { thread_id, .. } => { diff --git a/src/controllers/chat.rs b/src/controllers/chat.rs index 6c221a918..665e4222c 100644 --- a/src/controllers/chat.rs +++ b/src/controllers/chat.rs @@ -64,7 +64,7 @@ pub enum ChatEvent { } pub fn get_chat_url(configs: &Configs) -> String { - format!("https://backboard.{}/api/v1/chat", configs.get_host()) + format!("https://backboard.{}/api/v1/agent", configs.get_host()) } /// Build an HTTP client for the chat API.