diff --git a/Cargo.lock b/Cargo.lock index fcfe9a0..bc0c35c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -115,6 +124,21 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -253,6 +277,24 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -358,6 +400,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -373,6 +425,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -380,6 +447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -388,6 +456,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -406,10 +502,16 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -451,12 +553,16 @@ dependencies = [ "console", "dialoguer", "dirs", + "futures", "indicatif", + "regex", "reqwest", "rustyline", "serde", "serde_json", + "syntect", "tokio", + "tokio-stream", "toml", ] @@ -781,6 +887,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -861,6 +973,12 @@ dependencies = [ "libc", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -897,6 +1015,28 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.9.1", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -944,6 +1084,25 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -959,6 +1118,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.97" @@ -968,6 +1133,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.40" @@ -1013,13 +1187,42 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "reqwest" version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", @@ -1045,10 +1248,12 @@ dependencies = [ "system-configuration", "tokio", "tokio-rustls", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", "winreg", @@ -1105,7 +1310,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -1152,6 +1357,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1314,6 +1528,28 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -1368,6 +1604,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1419,6 +1686,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -1557,6 +1835,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1652,6 +1940,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.77" @@ -1694,6 +1995,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2015,6 +2325,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 1f6a8c6..b10551a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ clap = { version = "4.0", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false } +reqwest = { version = "0.11", features = ["json", "rustls-tls", "stream"], default-features = false } anyhow = "1.0" chrono = { version = "0.4", features = ["serde"] } dialoguer = "0.11" @@ -19,3 +19,7 @@ indicatif = "0.17" dirs = "5.0" rustyline = "13.0" toml = "0.8" +syntect = "5.1" +regex = "1.0" +futures = "0.3" +tokio-stream = "0.1" diff --git a/src/cli.rs b/src/cli.rs index e5f7643..5203780 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,6 +6,8 @@ use crate::core::{ ChatClient, Session, }; use crate::utils::{Display, InputHandler, SessionAction}; +use std::future::Future; +use std::pin::Pin; pub struct ChatCLI { session: Session, @@ -82,8 +84,6 @@ impl ChatCLI { self.session.add_user_message(message.to_string()); self.session.save()?; - let spinner = self.display.show_spinner("Thinking"); - // Clone data needed for the API call before getting mutable client reference let model = self.session.model.clone(); let messages = self.session.messages.clone(); @@ -91,27 +91,75 @@ impl ChatCLI { let enable_reasoning_summary = self.session.enable_reasoning_summary; let reasoning_effort = self.session.reasoning_effort.clone(); - let client = self.get_client()?; + // Check if we should use streaming before getting client + let should_use_streaming = { + let client = self.get_client()?; + client.supports_streaming() + }; - match client - .chat_completion( - &model, - &messages, - enable_web_search, - enable_reasoning_summary, - &reasoning_effort, - ) - .await - { - Ok(response) => { - spinner.finish("Done"); - self.display.print_assistant_response(&response); - self.session.add_assistant_message(response); - self.session.save()?; + if should_use_streaming { + print!("{} ", console::style("🤖").magenta()); + use std::io::{self, Write}; + io::stdout().flush().ok(); + + let stream_callback = { + use crate::core::StreamCallback; + Box::new(move |chunk: &str| { + print!("{}", chunk); + use std::io::{self, Write}; + io::stdout().flush().ok(); + Box::pin(async move {}) as Pin + Send>> + }) as StreamCallback + }; + + let client = self.get_client()?; + match client + .chat_completion_stream( + &model, + &messages, + enable_web_search, + enable_reasoning_summary, + &reasoning_effort, + stream_callback, + ) + .await + { + Ok(response) => { + println!(); // Add newline after streaming + self.session.add_assistant_message(response); + self.session.save()?; + } + Err(e) => { + println!(); // Add newline after failed streaming + self.display.print_error(&format!("Streaming failed: {}", e)); + return Err(e); + } } - Err(e) => { - spinner.finish_with_error("Failed"); - return Err(e); + } else { + // Fallback to non-streaming + let spinner = self.display.show_spinner("Thinking"); + let client = self.get_client()?; + + match client + .chat_completion( + &model, + &messages, + enable_web_search, + enable_reasoning_summary, + &reasoning_effort, + ) + .await + { + Ok(response) => { + spinner.finish("Done"); + self.display.print_assistant_response(&response); + self.session.add_assistant_message(response); + self.session.save()?; + } + Err(e) => { + spinner.finish_with_error("Failed"); + return Err(e); + } } } @@ -150,6 +198,9 @@ impl ChatCLI { "/tools" => { self.tools_manager().await?; } + "/history" => { + self.handle_history_command(&parts)?; + } _ => { self.display.print_error(&format!("Unknown command: {} (see /help)", parts[0])); } @@ -391,4 +442,52 @@ impl ChatCLI { Ok(()) } + fn handle_history_command(&mut self, parts: &[&str]) -> Result<()> { + let mut filter_role: Option<&str> = None; + let mut limit: Option = None; + + // Parse parameters + for &part in parts.iter().skip(1) { + match part { + "user" | "assistant" => filter_role = Some(part), + _ => { + if let Ok(num) = part.parse::() { + limit = Some(num); + } else { + self.display.print_error(&format!("Invalid parameter: {}", part)); + self.display.print_info("Usage: /history [user|assistant] [number]"); + return Ok(()); + } + } + } + } + + // Filter messages (skip system prompt at index 0) + let mut messages: Vec<(usize, &crate::core::Message)> = self.session.messages + .iter() + .enumerate() + .skip(1) // Skip system prompt + .collect(); + + // Apply role filter + if let Some(role) = filter_role { + messages.retain(|(_, msg)| msg.role == role); + } + + // Apply limit + if let Some(limit_count) = limit { + let start_index = messages.len().saturating_sub(limit_count); + messages = messages[start_index..].to_vec(); + } + + if messages.is_empty() { + self.display.print_info("No messages to display"); + return Ok(()); + } + + // Format and display + self.display.print_conversation_history(&messages); + Ok(()) + } + } \ No newline at end of file diff --git a/src/core/client.rs b/src/core/client.rs index 0fc8bbf..cabe8ad 100644 --- a/src/core/client.rs +++ b/src/core/client.rs @@ -4,10 +4,15 @@ use serde::Deserialize; use serde_json::{json, Value}; use std::env; use std::time::Duration; +use futures::stream::StreamExt; +use std::future::Future; +use std::pin::Pin; use crate::config::Config; use super::{provider::Provider, session::Message}; +pub type StreamCallback = Box Pin + Send>> + Send + Sync>; + #[derive(Debug)] pub enum ChatClient { OpenAI(OpenAIClient), @@ -32,6 +37,30 @@ impl ChatClient { } } } + + pub async fn chat_completion_stream( + &self, + model: &str, + messages: &[Message], + enable_web_search: bool, + enable_reasoning_summary: bool, + reasoning_effort: &str, + stream_callback: StreamCallback, + ) -> Result { + match self { + ChatClient::OpenAI(client) => { + client.chat_completion_stream(model, messages, enable_web_search, enable_reasoning_summary, reasoning_effort, stream_callback).await + } + ChatClient::Anthropic(_) => { + // Fallback to non-streaming for Anthropic + self.chat_completion(model, messages, enable_web_search, enable_reasoning_summary, reasoning_effort).await + } + } + } + + pub fn supports_streaming(&self) -> bool { + matches!(self, ChatClient::OpenAI(_)) + } pub fn supports_feature(&self, feature: &str) -> bool { match self { @@ -96,6 +125,40 @@ struct FunctionCall { arguments: String, } +// Streaming response structures +#[derive(Deserialize, Debug)] +struct StreamingChoice { + delta: StreamingDelta, + #[allow(dead_code)] + finish_reason: Option, +} + +#[derive(Deserialize, Debug)] +struct StreamingDelta { + content: Option, + tool_calls: Option>, +} + +#[derive(Deserialize, Debug)] +struct StreamingToolCall { + index: usize, + id: Option, + #[serde(rename = "type")] + tool_type: Option, + function: Option, +} + +#[derive(Deserialize, Debug)] +struct StreamingFunction { + name: Option, + arguments: Option, +} + +#[derive(Deserialize, Debug)] +struct StreamingResponse { + choices: Vec, +} + // Responses API structures #[derive(Deserialize)] struct ResponsesApiResponse { @@ -428,6 +491,263 @@ impl OpenAIClient { Ok(final_content) } + pub async fn chat_completion_stream( + &self, + model: &str, + messages: &[Message], + enable_web_search: bool, + _enable_reasoning_summary: bool, + reasoning_effort: &str, + stream_callback: StreamCallback, + ) -> Result { + + // Use Responses API for GPT-5 with web search, otherwise use Chat Completions API + if enable_web_search && model.starts_with("gpt-5") { + return self.responses_api_stream(model, messages, reasoning_effort, stream_callback).await; + } + + let url = format!("{}/chat/completions", self.base_url); + + let mut payload = json!({ + "model": model, + "messages": Self::convert_messages(messages), + "stream": true + }); + + // Add tools if web search is enabled + if enable_web_search { + payload["tools"] = json!([{ + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web for current information on any topic", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to find relevant information" + } + }, + "required": ["query"] + } + } + }]); + payload["tool_choice"] = json!("auto"); + } + + // Add reasoning effort for GPT-5 models + if model.starts_with("gpt-5") && ["low", "medium", "high"].contains(&reasoning_effort) { + payload["reasoning_effort"] = json!(reasoning_effort); + } + + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .context("Failed to send request to OpenAI API")?; + + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!("OpenAI API error: {}", error_text)); + } + + let mut full_response = String::new(); + let mut tool_calls_buffer: std::collections::HashMap = std::collections::HashMap::new(); + let mut stream = response.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read chunk from stream")?; + let chunk_str = std::str::from_utf8(&chunk) + .context("Failed to parse chunk as UTF-8")?; + + // Temporary debug output + + // Parse server-sent events + for line in chunk_str.lines() { + if line.starts_with("data: ") { + let data = &line[6..]; + + if data == "[DONE]" { + break; + } + + // Skip empty data lines + if data.trim().is_empty() { + continue; + } + + match serde_json::from_str::(data) { + Ok(streaming_response) => { + if let Some(choice) = streaming_response.choices.first() { + // Handle streaming content + if let Some(content) = &choice.delta.content { + if !content.is_empty() { + full_response.push_str(content); + stream_callback(content).await; + } + } + + // Handle streaming tool calls + if let Some(tool_calls) = &choice.delta.tool_calls { + for tool_call in tool_calls { + let entry = tool_calls_buffer.entry(tool_call.index).or_insert((String::new(), String::new(), String::new())); + + if let Some(id) = &tool_call.id { + entry.0.push_str(id); + } + + if let Some(function) = &tool_call.function { + if let Some(name) = &function.name { + entry.1.push_str(name); + } + if let Some(args) = &function.arguments { + entry.2.push_str(args); + } + } + } + } + } + } + Err(_) => { + continue; + } + } + } else if !line.trim().is_empty() { + } + } + } + + // Process any tool calls that were collected + if !tool_calls_buffer.is_empty() && enable_web_search { + for (_, (_id, name, args)) in tool_calls_buffer { + if name == "web_search" { + if let Ok(parsed_args) = serde_json::from_str::(&args) { + if let Some(query) = parsed_args.get("query").and_then(|q| q.as_str()) { + let tool_message = format!( + "\n\n[Web Search Request: \"{}\"]\nNote: Web search functionality is not implemented in this CLI. The AI wanted to search for: {}", + query, query + ); + full_response.push_str(&tool_message); + stream_callback(&tool_message).await; + } + } + } + } + } + + Ok(full_response) + } + + async fn responses_api_stream( + &self, + model: &str, + messages: &[Message], + reasoning_effort: &str, + stream_callback: StreamCallback, + ) -> Result { + let url = format!("{}/responses", self.base_url); + + // Convert messages to input text (simple approach for now) + let input_text = messages + .iter() + .filter(|msg| msg.role != "system") + .map(|msg| msg.content.as_str()) + .collect::>() + .join("\n"); + + let mut payload = json!({ + "model": model, + "tools": [{"type": "web_search_preview"}], + "input": input_text, + "stream": true + }); + + // Add reasoning effort for GPT-5 models + if ["low", "medium", "high"].contains(&reasoning_effort) { + payload["reasoning"] = json!({ + "effort": reasoning_effort + }); + } + + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .context("Failed to send request to OpenAI Responses API")?; + + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!("OpenAI Responses API error: {}", error_text)); + } + + let mut full_response = String::new(); + let mut stream = response.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read chunk from Responses API stream")?; + let chunk_str = std::str::from_utf8(&chunk) + .context("Failed to parse Responses API chunk as UTF-8")?; + + + // Parse server-sent events for Responses API + for line in chunk_str.lines() { + if line.starts_with("data: ") { + let data = &line[6..]; + + if data == "[DONE]" { + break; + } + + // Skip empty data lines + if data.trim().is_empty() { + continue; + } + + // Try to parse streaming events + match serde_json::from_str::(data) { + Ok(response_data) => { + + // Check for streaming delta events + if let Some(event_type) = response_data.get("type").and_then(|t| t.as_str()) { + if event_type == "response.output_text.delta" { + if let Some(delta) = response_data.get("delta").and_then(|d| d.as_str()) { + if !delta.is_empty() { + full_response.push_str(delta); + stream_callback(delta).await; + } + } + } + } + } + Err(_) => { + continue; + } + } + } else if !line.trim().is_empty() { + } + } + } + + if full_response.is_empty() { + return Err(anyhow::anyhow!("No content found in Responses API stream response")); + } + + Ok(full_response) + } + pub fn supports_feature(&self, feature: &str) -> bool { match feature { "web_search" | "reasoning_summary" | "reasoning_effort" => true, diff --git a/src/core/mod.rs b/src/core/mod.rs index 6fce5db..bd3b91a 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,6 @@ pub mod session; pub mod client; pub mod provider; -pub use session::Session; -pub use client::{ChatClient, create_client}; +pub use session::{Session, Message}; +pub use client::{ChatClient, create_client, StreamCallback}; pub use provider::get_provider_for_model; \ No newline at end of file diff --git a/src/utils/display.rs b/src/utils/display.rs index fb458bc..09c060b 100644 --- a/src/utils/display.rs +++ b/src/utils/display.rs @@ -1,14 +1,23 @@ use console::{style, Term}; use std::io::{self, Write}; +use syntect::easy::HighlightLines; +use syntect::parsing::SyntaxSet; +use syntect::highlighting::{ThemeSet, Style}; +use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings}; +use regex::Regex; pub struct Display { term: Term, + syntax_set: SyntaxSet, + theme_set: ThemeSet, } impl Display { pub fn new() -> Self { Self { term: Term::stdout(), + syntax_set: SyntaxSet::load_defaults_newlines(), + theme_set: ThemeSet::load_defaults(), } } @@ -41,7 +50,115 @@ impl Display { } pub fn print_assistant_response(&self, content: &str) { - println!("{} {}", style("🤖").magenta(), content); + print!("{} ", style("🤖").magenta()); + self.print_formatted_content_with_pagination(content); + } + + fn print_formatted_content_with_pagination(&self, content: &str) { + let lines: Vec<&str> = content.lines().collect(); + let terminal_height = self.term.size().0 as usize; + let lines_per_page = terminal_height.saturating_sub(5); // Leave space for prompts + + if lines.len() <= lines_per_page { + // Short content, no pagination needed + self.print_formatted_content(content); + return; + } + + let mut current_line = 0; + + while current_line < lines.len() { + let end_line = (current_line + lines_per_page).min(lines.len()); + let page_content = lines[current_line..end_line].join("\n"); + + self.print_formatted_content(&page_content); + + if end_line < lines.len() { + print!("\n{} ", style("Press Enter to continue, 'q' to finish...").dim()); + io::stdout().flush().ok(); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_ok() { + if input.trim().to_lowercase() == "q" { + println!("{}", style("(response truncated)").dim()); + break; + } + } + } + + current_line = end_line; + } + } + + fn print_formatted_content(&self, content: &str) { + // Regex to match code blocks with optional language specifier + let code_block_regex = Regex::new(r"```(\w+)?\n([\s\S]*?)\n```").unwrap(); + + let mut last_end = 0; + + // Process code blocks + for captures in code_block_regex.captures_iter(content) { + let full_match = captures.get(0).unwrap(); + let lang = captures.get(1).map(|m| m.as_str()).unwrap_or("text"); + let code = captures.get(2).unwrap().as_str(); + + // Print text before code block + let before_text = &content[last_end..full_match.start()]; + self.print_text_with_inline_code(before_text); + + // Print code block with syntax highlighting + self.print_code_block(code, lang); + + last_end = full_match.end(); + } + + // Print remaining text after last code block + let remaining_text = &content[last_end..]; + self.print_text_with_inline_code(remaining_text); + + println!(); // Add newline at the end + } + + fn print_text_with_inline_code(&self, text: &str) { + let inline_code_regex = Regex::new(r"`([^`]+)`").unwrap(); + let mut last_end = 0; + + for captures in inline_code_regex.captures_iter(text) { + let full_match = captures.get(0).unwrap(); + let code = captures.get(1).unwrap().as_str(); + + // Print text before inline code + print!("{}", &text[last_end..full_match.start()]); + + // Print inline code with background highlighting + print!("{}", style(code).on_black().white()); + + last_end = full_match.end(); + } + + // Print remaining text + print!("{}", &text[last_end..]); + } + + fn print_code_block(&self, code: &str, language: &str) { + // Find the appropriate syntax definition + let syntax = self.syntax_set.find_syntax_by_extension(language) + .or_else(|| self.syntax_set.find_syntax_by_name(language)) + .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); + + // Use a dark theme for code highlighting + let theme = &self.theme_set.themes["base16-ocean.dark"]; + + println!("{}", style("```").dim()); + + let mut highlighter = HighlightLines::new(syntax, theme); + for line in LinesWithEndings::from(code) { + let ranges: Vec<(Style, &str)> = highlighter.highlight_line(line, &self.syntax_set).unwrap(); + let escaped = as_24_bit_terminal_escaped(&ranges[..], false); + print!("{}", escaped); + } + + println!("{}", style("```").dim()); } pub fn print_command_result(&self, message: &str) { @@ -74,8 +191,15 @@ Available Commands: /new - Create a new session /switch - Interactive session manager (switch/delete) /clear - Clear current conversation + /history [user|assistant] [number] - View conversation history /tools - Interactive tool and feature manager +Input Features: + Multi-line input - End your message with '\' to enter multi-line mode + Keyboard shortcuts - Ctrl+C: Cancel, Ctrl+D: Exit, Arrow keys: History + Syntax highlighting - Code blocks are automatically highlighted + Pagination - Long responses are paginated (press Enter or 'q') + Environment Variables: OPENAI_API_KEY - Required for OpenAI models ANTHROPIC_API_KEY - Required for Anthropic models @@ -96,6 +220,41 @@ Supported Models: io::stdout().flush().ok(); SpinnerHandle::new() } + + pub fn print_conversation_history(&self, messages: &[(usize, &crate::core::Message)]) { + println!("{}", style("📜 Conversation History").bold().cyan()); + println!("{}", style("─".repeat(50)).dim()); + + let mut content = String::new(); + + for (original_index, message) in messages { + let role_icon = match message.role.as_str() { + "user" => "👤", + "assistant" => "🤖", + _ => "💬", + }; + + let role_name = match message.role.as_str() { + "user" => "User", + "assistant" => "Assistant", + _ => &message.role, + }; + + content.push_str(&format!( + "\n{} {} {} [Message #{}]\n{}\n", + style("•").dim(), + role_icon, + style(role_name).bold(), + original_index, + style("─".repeat(40)).dim() + )); + + content.push_str(&message.content); + content.push_str("\n\n"); + } + + self.print_formatted_content_with_pagination(&content); + } } impl Default for Display { diff --git a/src/utils/input.rs b/src/utils/input.rs index 43e4159..eebbf3b 100644 --- a/src/utils/input.rs +++ b/src/utils/input.rs @@ -1,6 +1,6 @@ use anyhow::Result; use dialoguer::{theme::ColorfulTheme, Select}; -use rustyline::{error::ReadlineError, DefaultEditor}; +use rustyline::{error::ReadlineError, DefaultEditor, KeyEvent, Cmd}; pub struct InputHandler { editor: DefaultEditor, @@ -8,8 +8,13 @@ pub struct InputHandler { impl InputHandler { pub fn new() -> Result { + // Use a simpler configuration approach let mut editor = DefaultEditor::new()?; + // Configure key bindings for better UX + editor.bind_sequence(KeyEvent::ctrl('C'), Cmd::Interrupt); + editor.bind_sequence(KeyEvent::ctrl('D'), Cmd::EndOfFile); + // Try to load history file let history_file = dirs::home_dir() .map(|home| home.join(".chat_cli_history")) @@ -23,8 +28,13 @@ impl InputHandler { pub fn read_line(&mut self, prompt: &str) -> Result> { match self.editor.readline(prompt) { Ok(line) => { - let _ = self.editor.add_history_entry(&line); - Ok(Some(line)) + // Check if user wants to enter multi-line mode + if line.trim().ends_with("\\") { + self.read_multiline_input(line.trim_end_matches("\\").to_string()) + } else { + let _ = self.editor.add_history_entry(&line); + Ok(Some(line)) + } } Err(ReadlineError::Interrupted) => { println!("^C"); @@ -38,6 +48,34 @@ impl InputHandler { } } + pub fn read_multiline_input(&mut self, initial_line: String) -> Result> { + let mut lines = vec![initial_line]; + println!("Multi-line mode: Type your message. End with a line containing only '.' or press Ctrl+D"); + + loop { + match self.editor.readline("... ") { + Ok(line) => { + if line.trim() == "." { + break; + } + lines.push(line); + } + Err(ReadlineError::Interrupted) => { + println!("^C"); + return Ok(None); + } + Err(ReadlineError::Eof) => { + break; + } + Err(err) => return Err(anyhow::anyhow!("Error reading input: {}", err)), + } + } + + let full_message = lines.join("\n"); + let _ = self.editor.add_history_entry(&full_message); + Ok(Some(full_message)) + } + pub fn save_history(&mut self) -> Result<()> { let history_file = dirs::home_dir() .map(|home| home.join(".chat_cli_history"))