use std::borrow::Cow;
use std::io::Write;
use std::sync::{Arc, Mutex};
use crossterm::style::Stylize;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Helper};
use crate::ui::activity::ActivityIndicator;
use agent_code_lib::llm::message::Usage;
use agent_code_lib::query::{QueryEngine, StreamSink};
use agent_code_lib::tools::ToolResult;
struct CommandCompleter;
impl Completer for CommandCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
if !line.starts_with('/') {
return Ok((0, vec![]));
}
let partial = &line[1..pos];
let matches: Vec<Pair> = crate::commands::COMMANDS
.iter()
.filter(|c| !c.hidden)
.filter(|c| c.name.starts_with(partial))
.map(|c| Pair {
display: format!("/{} — {}", c.name, c.description),
replacement: format!("/{}", c.name),
})
.collect();
Ok((0, matches))
}
}
impl Hinter for CommandCompleter {
type Hint = String;
fn hint(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Option<String> {
if !line.starts_with('/') || pos < 2 {
return None;
}
let partial = &line[1..pos];
crate::commands::COMMANDS
.iter()
.filter(|c| !c.hidden)
.find(|c| c.name.starts_with(partial) && c.name != partial)
.map(|c| c.name[partial.len()..].to_string())
}
}
impl Highlighter for CommandCompleter {
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
}
}
impl Validator for CommandCompleter {}
impl Helper for CommandCompleter {}
struct TerminalSink {
mid_line: Arc<Mutex<bool>>,
response_buffer: Arc<Mutex<String>>,
indicator: Arc<Mutex<Option<ActivityIndicator>>>,
verbose: bool,
turn_state: super::tui::SharedTurnState,
}
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(None)),
verbose,
turn_state: super::tui::new_turn_state(),
}
}
fn start_indicator(&self) {
if let Ok(mut guard) = self.indicator.lock()
&& guard.is_none()
{
*guard = Some(ActivityIndicator::thinking());
}
}
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 detail = summarize_tool_input(tool_name, input);
self.turn_state
.lock()
.unwrap()
.add_tool_start(tool_name, &detail);
super::tui::render_tool_block(tool_name, &detail, None, false);
}
fn on_tool_result(&self, _tool_name: &str, result: &ToolResult) {
self.turn_state
.lock()
.unwrap()
.complete_last_tool(&result.content, result.is_error);
let t = super::theme::current();
if result.is_error {
let first_line = result.content.lines().next().unwrap_or("");
eprintln!(" {} {}", "✗".with(t.error), first_line.with(t.error));
} else {
let preview: String = result
.content
.lines()
.next()
.unwrap_or("(ok)")
.chars()
.take(80)
.collect();
let line_count = result.content.lines().count();
let suffix = if line_count > 1 {
format!(" (+{} lines)", line_count - 1)
.with(t.muted)
.to_string()
} else {
String::new()
};
eprintln!(
" {} {}{}",
"✓".with(t.success),
preview.with(t.muted),
suffix
);
}
self.restart_indicator();
}
fn on_thinking(&self, text: &str) {
self.stop_indicator();
self.turn_state.lock().unwrap().thinking_chars = text.len();
super::tui::render_thinking_block(text);
}
fn on_turn_complete(&self, turn: usize) {
self.stop_indicator();
self.ensure_newline();
let state = self.turn_state.lock().unwrap();
let has_success = state.tools.iter().any(|t| !t.is_error);
if state.tools.len() > 1 || has_success {
super::tui::render_turn_summary(&state, turn);
}
drop(state);
self.turn_state.lock().unwrap().clear();
}
fn on_error(&self, error: &str) {
self.stop_indicator();
self.ensure_newline();
let t = super::theme::current();
eprintln!(
"{} {error}",
super::theme::label(" ERROR ", t.error, crossterm::style::Color::White)
);
}
fn on_usage(&self, usage: &Usage) {
{
let mut state = self.turn_state.lock().unwrap();
state.tokens_in = usage.input_tokens;
state.tokens_out = usage.output_tokens;
state.cache_read = usage.cache_read_input_tokens;
state.cache_write = usage.cache_creation_input_tokens;
}
}
fn on_compact(&self, freed_tokens: u64) {
let t = super::theme::current();
eprintln!(
" {} {}",
"↻".with(t.accent),
format!("compacted ~{freed_tokens} tokens").with(t.muted),
);
}
fn on_warning(&self, msg: &str) {
let t = super::theme::current();
eprintln!(
"{} {msg}",
super::theme::label(" WARN ", t.warning, crossterm::style::Color::Black)
);
}
}
fn spawn_escape_watcher(engine_cancel: impl Fn() + Send + 'static) -> tokio::task::JoinHandle<()> {
tokio::task::spawn_blocking(move || {
use crossterm::event::{self, Event, KeyCode, KeyEvent};
loop {
if event::poll(std::time::Duration::from_millis(100)).unwrap_or(false)
&& let Ok(Event::Key(KeyEvent {
code: KeyCode::Esc, ..
})) = event::read()
{
engine_cancel();
break;
}
}
})
}
pub async fn run_repl(engine: &mut QueryEngine) -> anyhow::Result<()> {
let input_mode = super::keymap::InputMode::default();
let _keybindings = super::keybindings::KeybindingRegistry::load();
let rl_config = rustyline::Config::builder()
.edit_mode(input_mode.to_edit_mode())
.completion_type(rustyline::config::CompletionType::List)
.bracketed_paste(true)
.build();
let mut rl =
rustyline::Editor::<CommandCompleter, rustyline::history::DefaultHistory>::with_config(
rl_config,
)?;
rl.set_helper(Some(CommandCompleter));
let session_id = agent_code_lib::services::session::new_session_id();
let session_id_display = session_id.clone();
agent_code_lib::memory::session_notes::init_session_notes(&session_id);
agent_code_lib::memory::session_notes::cleanup_old_notes();
let history_path = dirs::data_dir().map(|d| {
let cwd = &engine.state().cwd;
let hash: u64 = cwd
.bytes()
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64));
d.join("agent-code")
.join("history")
.join(format!("{hash:x}.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 term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let divider = "─".repeat(term_width.min(100));
let model = engine.state().config.api.model.clone();
let cwd = engine.state().cwd.clone();
let theme_name = super::theme::resolve_theme(&engine.state().config.ui.theme);
super::theme::init(&theme_name);
let t = super::theme::current();
println!();
let crab_lines = super::tui::render_crab_banner();
let info_lines = [
String::new(),
String::new(),
format!(" \x1b[1mAgent Code\x1b[0m v{}", env!("CARGO_PKG_VERSION")),
format!(" {} · session {}", model, session_id_display.as_str()),
format!(" {cwd}"),
String::new(),
String::new(),
];
for (i, crab_line) in crab_lines.iter().enumerate() {
let info = info_lines.get(i).cloned().unwrap_or_default();
if info.is_empty() {
println!("{crab_line}");
} else {
println!("{crab_line}{info}");
}
}
for frame in 0..3 {
let shimmer_lines = super::tui::render_crab_shimmer(frame);
eprint!("\x1b[{}A", shimmer_lines.len());
for (i, crab_line) in shimmer_lines.iter().enumerate() {
let info = info_lines.get(i).cloned().unwrap_or_default();
if info.is_empty() {
println!("{crab_line}");
} else {
println!("{crab_line}{info}");
}
}
std::thread::sleep(std::time::Duration::from_millis(120));
}
eprint!("\x1b[{}A", crab_lines.len());
for (i, crab_line) in crab_lines.iter().enumerate() {
let info = info_lines.get(i).cloned().unwrap_or_default();
if info.is_empty() {
println!("{crab_line}");
} else {
println!("{crab_line}{info}");
}
}
println!();
println!("{}", divider.with(t.muted));
println!(" {}", "? for shortcuts".with(t.muted),);
println!();
let mut ctrl_c_pending = false;
let update_footer = |engine: &QueryEngine| {
let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
let w = term_w as usize;
let h = term_h as usize;
let state = engine.state();
let model_str = &state.config.api.model;
let turns = state.turn_count;
let tokens = state.total_usage.total();
let cost = state.total_cost_usd;
let left = format!(" {model_str} ");
let right = if turns > 0 {
format!(" turn {turns} │ {tokens} tokens │ ${cost:.4} ")
} else {
" ? for shortcuts ".to_string()
};
let mid_len = w.saturating_sub(left.len() + right.len());
let mid = "─".repeat(mid_len);
eprint!("\x1b7\x1b[{h};1H\x1b[2K\x1b[90m{left}{mid}{right}\x1b[0m\x1b8");
let _ = std::io::stderr().flush();
};
let setup_scroll_region = || {
let (_w, h) = crossterm::terminal::size().unwrap_or((80, 24));
let scroll_bottom = h - 1;
eprint!("\x1b[1;{scroll_bottom}r\x1b[{scroll_bottom};1H");
let _ = std::io::stderr().flush();
};
let reset_scroll_region = || {
eprint!("\x1b[r");
let _ = std::io::stderr().flush();
};
setup_scroll_region();
update_footer(engine);
loop {
let sink = TerminalSink::new(verbose);
let t = super::theme::current();
update_footer(engine);
let prompt = format!("{} ", "❯".with(t.accent).bold());
match rl.readline(&prompt) {
Ok(line) => {
ctrl_c_pending = false;
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!("{} ", ".".with(t.muted));
match rl.readline(&cont_prompt) {
Ok(next) => input_buf.push_str(&next),
Err(_) => break,
}
}
let input = input_buf.trim();
if input.is_empty() {
continue;
}
if input == "?" {
let t = super::theme::current();
println!();
println!(" {}", "Keyboard Shortcuts".with(t.accent).bold());
println!(" {}", "─".repeat(50).with(t.muted));
println!(
" {} {}",
"! command".with(t.text),
"run shell command directly".with(t.muted),
);
println!(
" {} {}",
"/ + command".with(t.text),
"slash commands (/help to list all)".with(t.muted),
);
println!(
" {} {}",
"Tab".with(t.text),
"auto-complete /commands".with(t.muted),
);
println!(
" {} {}",
"\\ + Enter".with(t.text),
"multi-line input".with(t.muted),
);
println!(
" {} {}",
"Ctrl+C".with(t.text),
"cancel (twice to exit)".with(t.muted),
);
println!(
" {} {}",
"Ctrl+R".with(t.text),
"search history".with(t.muted),
);
println!(" {} {}", "Ctrl+D".with(t.text), "exit".with(t.muted),);
println!(" {}", "─".repeat(50).with(t.muted));
println!(
" {} {}",
"/model <name>".with(t.text),
"switch model".with(t.muted),
);
println!(
" {} {}",
"/scroll".with(t.text),
"scrollable conversation view".with(t.muted),
);
println!(
" {} {}",
"/snip <N-M>".with(t.text),
"remove messages from history".with(t.muted),
);
println!(
" {} {}",
"/fork".with(t.text),
"branch conversation".with(t.muted),
);
println!(
" {} {}",
"/rewind".with(t.text),
"undo last turn".with(t.muted),
);
println!(
" {} {}",
"/features".with(t.text),
"show feature flags".with(t.muted),
);
println!(
" {} {}",
"/doctor".with(t.text),
"check environment health".with(t.muted),
);
println!();
continue;
}
rl.add_history_entry(input)?;
if !input.starts_with('/') && input != "?" && !input.starts_with('!') {
let t = super::theme::current();
let bg = if t.is_dark {
"\x1b[48;2;55;55;55m" } else {
"\x1b[48;2;235;235;240m" };
for line in input.lines() {
let pad = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80)
.saturating_sub(line.len() + 4);
println!(
"{bg} {} {}{}\x1b[0m",
"❯".with(t.accent),
line,
" ".repeat(pad),
);
}
println!();
}
if input.starts_with('!') {
let cmd = input.strip_prefix('!').unwrap_or("").trim();
if !cmd.is_empty() {
let output = std::process::Command::new("bash")
.arg("-c")
.arg(cmd)
.current_dir(&engine.state().cwd)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if !stdout.is_empty() {
print!("{stdout}");
}
if !stderr.is_empty() {
eprint!("{stderr}");
}
}
Err(e) => eprintln!("bash error: {e}"),
}
}
continue;
}
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) => {
sink.start_indicator();
if let Err(e) = engine.run_turn_with_sink(&text, &sink).await {
{
let t = super::theme::current();
eprintln!(
"{} {e}",
super::theme::label(
" ERROR ",
t.error,
crossterm::style::Color::White
)
);
}
}
sink.ensure_newline();
println!();
}
crate::commands::CommandResult::Prompt(prompt) => {
sink.start_indicator();
if let Err(e) = engine.run_turn_with_sink(&prompt, &sink).await {
{
let t = super::theme::current();
eprintln!(
"{} {e}",
super::theme::label(
" ERROR ",
t.error,
crossterm::style::Color::White
)
);
}
}
sink.ensure_newline();
println!();
}
}
continue;
}
sink.start_indicator();
let cancel_token = engine.cancel_token();
let esc_watcher = spawn_escape_watcher(move || cancel_token.cancel());
if let Err(e) = engine.run_turn_with_sink(input, &sink).await {
{
let t = super::theme::current();
eprintln!(
"{} {e}",
super::theme::label(" ERROR ", t.error, crossterm::style::Color::White)
);
}
}
esc_watcher.abort();
sink.ensure_newline();
println!();
}
Err(ReadlineError::Interrupted) => {
if engine.state().is_query_active {
engine.cancel();
eprintln!("{}", "(cancelled)".with(super::theme::current().muted));
ctrl_c_pending = false;
} else if ctrl_c_pending {
break;
} else {
eprintln!(
"{}",
"(Ctrl+C again to exit, or type /exit)".with(super::theme::current().muted)
);
ctrl_c_pending = true;
}
}
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 agent_code_lib::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}").with(super::theme::current().muted)
),
}
}
let divider = "─".repeat(term_width.min(100));
let t = super::theme::current();
println!("{}", divider.with(t.muted));
if state.total_usage.total() > 0 {
println!(
" {} {} turns | {} tokens | ${:.4} | session {}",
"session".with(t.accent),
state.turn_count,
state.total_usage.total(),
state.total_cost_usd,
session_id_display.as_str().with(t.muted),
);
} else {
println!(
" {} session {}",
"goodbye".with(t.accent),
session_id_display.as_str().with(t.muted)
);
}
reset_scroll_region();
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
}
}