added streaming support

This commit is contained in:
leach 2025-08-19 00:20:29 -04:00
parent 6d0592bda5
commit 0faecbf657
7 changed files with 969 additions and 30 deletions

323
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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,6 +91,53 @@ impl ChatCLI {
let enable_reasoning_summary = self.session.enable_reasoning_summary;
let reasoning_effort = self.session.reasoning_effort.clone();
// Check if we should use streaming before getting client
let should_use_streaming = {
let client = self.get_client()?;
client.supports_streaming()
};
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<Box<dyn Future<Output = ()> + 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);
}
}
} else {
// Fallback to non-streaming
let spinner = self.display.show_spinner("Thinking");
let client = self.get_client()?;
match client
@ -114,6 +161,7 @@ impl ChatCLI {
return Err(e);
}
}
}
Ok(())
}
@ -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<usize> = None;
// Parse parameters
for &part in parts.iter().skip(1) {
match part {
"user" | "assistant" => filter_role = Some(part),
_ => {
if let Ok(num) = part.parse::<usize>() {
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(())
}
}

View File

@ -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<dyn Fn(&str) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
#[derive(Debug)]
pub enum ChatClient {
OpenAI(OpenAIClient),
@ -33,6 +38,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<String> {
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 {
ChatClient::OpenAI(client) => client.supports_feature(feature),
@ -96,6 +125,40 @@ struct FunctionCall {
arguments: String,
}
// Streaming response structures
#[derive(Deserialize, Debug)]
struct StreamingChoice {
delta: StreamingDelta,
#[allow(dead_code)]
finish_reason: Option<String>,
}
#[derive(Deserialize, Debug)]
struct StreamingDelta {
content: Option<String>,
tool_calls: Option<Vec<StreamingToolCall>>,
}
#[derive(Deserialize, Debug)]
struct StreamingToolCall {
index: usize,
id: Option<String>,
#[serde(rename = "type")]
tool_type: Option<String>,
function: Option<StreamingFunction>,
}
#[derive(Deserialize, Debug)]
struct StreamingFunction {
name: Option<String>,
arguments: Option<String>,
}
#[derive(Deserialize, Debug)]
struct StreamingResponse {
choices: Vec<StreamingChoice>,
}
// 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<String> {
// 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<usize, (String, String, String)> = 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::<StreamingResponse>(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::<serde_json::Value>(&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<String> {
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::<Vec<_>>()
.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::<serde_json::Value>(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,

View File

@ -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;

View File

@ -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 <session_name> - 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 {

View File

@ -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<Self> {
// 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,9 +28,14 @@ impl InputHandler {
pub fn read_line(&mut self, prompt: &str) -> Result<Option<String>> {
match self.editor.readline(prompt) {
Ok(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");
Ok(None)
@ -38,6 +48,34 @@ impl InputHandler {
}
}
pub fn read_multiline_input(&mut self, initial_line: String) -> Result<Option<String>> {
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"))