use std::io::Write;
use std::sync::{Arc, Mutex};
use crossterm::style::Stylize;
use rustyline::error::ReadlineError;
use crate::llm::message::Usage;
use crate::query::{QueryEngine, StreamSink};
use crate::tools::ToolResult;
use crate::ui::activity::ActivityIndicator;
struct TerminalSink {
mid_line: Arc<Mutex<bool>>,
response_buffer: Arc<Mutex<String>>,
indicator: Arc<Mutex<Option<ActivityIndicator>>>,
verbose: bool,
}
impl TerminalSink {
fn new(verbose: bool) -> Self {
Self {
mid_line: Arc::new(Mutex::new(false)),
response_buffer: Arc::new(Mutex::new(String::new())),
indicator: Arc::new(Mutex::new(Some(ActivityIndicator::thinking()))),
verbose,
}
}
fn ensure_newline(&self) {
let mut mid = self.mid_line.lock().unwrap();
if *mid {
println!();
*mid = false;
}
}
fn stop_indicator(&self) {
if let Ok(mut guard) = self.indicator.lock()
&& let Some(ind) = guard.take()
{
ind.stop();
}
}
fn restart_indicator(&self) {
if let Ok(mut guard) = self.indicator.lock() {
*guard = Some(ActivityIndicator::thinking());
}
}
}
impl StreamSink for TerminalSink {
fn on_text(&self, text: &str) {
self.stop_indicator();
print!("{text}");
let _ = std::io::stdout().flush();
*self.mid_line.lock().unwrap() = !text.ends_with('\n');
self.response_buffer.lock().unwrap().push_str(text);
}
fn on_tool_start(&self, tool_name: &str, input: &serde_json::Value) {
self.stop_indicator();
self.ensure_newline();
let label = format!(" {tool_name} ");
let detail = summarize_tool_input(tool_name, input);
eprintln!(
"{} {}",
label.on_dark_cyan().white().bold(),
detail.dark_grey()
);
}
fn on_tool_result(&self, tool_name: &str, result: &ToolResult) {
if result.is_error {
let label = format!(" {tool_name} ERROR ");
let first_line = result.content.lines().next().unwrap_or("");
eprintln!("{} {}", label.on_red().white().bold(), first_line.red());
}
self.restart_indicator();
}
fn on_thinking(&self, text: &str) {
self.stop_indicator();
if text.len() > 80 {
eprint!(
"\r{}\r",
format!(" thinking ({} chars)...", text.len()).dark_grey()
);
}
}
fn on_turn_complete(&self, turn: usize) {
self.stop_indicator();
self.ensure_newline();
if self.verbose {
eprintln!("{}", format!(" (turn {turn} complete)").dark_grey());
}
}
fn on_error(&self, error: &str) {
self.stop_indicator();
self.ensure_newline();
eprintln!("{} {error}", " ERROR ".on_red().white().bold());
}
fn on_usage(&self, usage: &Usage) {
if self.verbose && usage.total() > 0 {
let cache_info = if usage.cache_read_input_tokens > 0 {
format!(
", cache: {}r/{}w",
usage.cache_read_input_tokens, usage.cache_creation_input_tokens
)
} else {
String::new()
};
eprintln!(
"{}",
format!(
" tokens: {}in + {}out{cache_info}",
usage.input_tokens, usage.output_tokens
)
.dark_grey()
);
}
}
fn on_compact(&self, freed_tokens: u64) {
eprintln!(
"{}",
format!(" compacted ~{freed_tokens} tokens").dark_grey()
);
}
fn on_warning(&self, msg: &str) {
eprintln!("{} {msg}", " WARN ".on_yellow().black().bold());
}
}
pub async fn run_repl(engine: &mut QueryEngine) -> anyhow::Result<()> {
let input_mode = super::keymap::InputMode::default();
let rl_config = rustyline::Config::builder()
.edit_mode(input_mode.to_edit_mode())
.build();
let mut rl =
rustyline::Editor::<(), rustyline::history::DefaultHistory>::with_config(rl_config)?;
let session_id = crate::services::session::new_session_id();
let history_path = dirs::data_dir().map(|d| d.join("agent-code").join("history.txt"));
if let Some(ref path) = history_path {
let _ = std::fs::create_dir_all(path.parent().unwrap());
let _ = rl.load_history(path);
}
let verbose = engine.state().config.ui.syntax_highlight;
let mode_label = match input_mode {
super::keymap::InputMode::Vi => " vi",
super::keymap::InputMode::Emacs => "",
};
println!(
"{} {}{}\n{}\n",
" agent ".on_dark_cyan().white().bold(),
format!("session {session_id}").dark_grey(),
mode_label.dark_grey(),
"Type your message, or /help for commands. Ctrl+C to cancel, Ctrl+D to exit.".dark_grey(),
);
loop {
let sink = TerminalSink::new(verbose);
let prompt = format!("{} ", ">".dark_cyan().bold());
match rl.readline(&prompt) {
Ok(line) => {
let mut input_buf = line.clone();
while input_buf.trim_end().ends_with('\\') {
input_buf.truncate(input_buf.trim_end().len() - 1);
input_buf.push('\n');
let cont_prompt = format!("{} ", ".".dark_grey());
match rl.readline(&cont_prompt) {
Ok(next) => input_buf.push_str(&next),
Err(_) => break,
}
}
let input = input_buf.trim();
if input.is_empty() {
continue;
}
rl.add_history_entry(input)?;
if input.starts_with('/') {
match crate::commands::execute(input, engine) {
crate::commands::CommandResult::Handled => continue,
crate::commands::CommandResult::Exit => break,
crate::commands::CommandResult::Passthrough(text) => {
if let Err(e) = engine.run_turn_with_sink(&text, &sink).await {
eprintln!("{} {e}", " ERROR ".on_red().white().bold());
}
sink.ensure_newline();
println!();
}
crate::commands::CommandResult::Prompt(prompt) => {
if let Err(e) = engine.run_turn_with_sink(&prompt, &sink).await {
eprintln!("{} {e}", " ERROR ".on_red().white().bold());
}
sink.ensure_newline();
println!();
}
}
continue;
}
if let Err(e) = engine.run_turn_with_sink(input, &sink).await {
eprintln!("{} {e}", " ERROR ".on_red().white().bold());
}
sink.ensure_newline();
println!();
}
Err(ReadlineError::Interrupted) => {
engine.cancel();
eprintln!("{}", "(cancelled)".dark_grey());
}
Err(ReadlineError::Eof) => {
break;
}
Err(e) => {
eprintln!("Input error: {e}");
break;
}
}
}
if let Some(ref path) = history_path {
let _ = rl.save_history(path);
}
let state = engine.state();
if !state.messages.is_empty() {
match crate::services::session::save_session(
&session_id,
&state.messages,
&state.cwd,
&state.config.api.model,
state.turn_count,
) {
Ok(_) => {}
Err(e) => eprintln!("{}", format!("Failed to save session: {e}").dark_grey()),
}
}
if state.total_usage.total() > 0 {
println!(
"\n{} {} turns | {} tokens | ${:.4}",
" session ".on_dark_cyan().white().bold(),
state.turn_count,
state.total_usage.total(),
state.total_cost_usd,
);
}
Ok(())
}
fn summarize_tool_input(tool_name: &str, input: &serde_json::Value) -> String {
let raw = match tool_name {
"Bash" => input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"FileRead" | "FileWrite" | "FileEdit" | "NotebookEdit" => input
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"Grep" | "Glob" | "WebSearch" => input
.get("pattern")
.or_else(|| input.get("query"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"WebFetch" => input
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"Agent" => input
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => {
serde_json::to_string(input)
.unwrap_or_default()
.chars()
.take(80)
.collect()
}
};
if raw.len() > 120 {
format!("{}...", &raw[..117])
} else {
raw
}
}