use anyhow::Result;
use clap::Args;
use colored::Colorize;
use glob::glob;
use octomind::config::Config;
use octomind::session::chat::markdown::{is_markdown_content, MarkdownRenderer};
use octomind::session::{
chat_completion_with_provider, ChatCompletionProviderParams, Message, ProviderResponse,
};
use reedline::{
default_emacs_keybindings, EditCommand, Emacs, FileBackedHistory, KeyCode, KeyModifiers,
Reedline, ReedlineEvent, Signal,
};
use std::fs::{self, OpenOptions};
use std::io::IsTerminal;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Args, Debug)]
pub struct AskArgs {
#[arg(value_name = "INPUT")]
pub input: Option<String>,
#[arg(short = 'f', long = "file", value_name = "FILE_PATTERN")]
pub files: Vec<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub max_tokens: Option<u32>,
#[arg(long)]
pub temperature: Option<f32>,
#[arg(long)]
pub raw: bool,
}
fn print_response(content: &str, use_raw: bool, config: &Config) {
if use_raw {
println!("{}", content);
} else if is_markdown_content(content) {
let theme = config.markdown_theme.parse().unwrap_or_default();
let renderer = MarkdownRenderer::with_theme(theme);
match renderer.render_and_print(content) {
Ok(_) => {
}
Err(_) => {
println!("{}", content);
}
}
} else {
println!("{}", content.bright_green());
}
}
fn validate_file_patterns(file_patterns: &[String]) -> Result<()> {
if file_patterns.is_empty() {
return Ok(());
}
let mut has_errors = false;
let mut total_files = 0;
for pattern in file_patterns {
match glob(pattern) {
Ok(paths) => {
let mut found_any = false;
for path_result in paths {
match path_result {
Ok(path) => {
found_any = true;
total_files += 1;
if !path.exists() {
octomind::log_error!(
"Error: File does not exist: {}",
path.display()
);
has_errors = true;
} else if !path.is_file() {
octomind::log_error!(
"Error: Path is not a file: {}",
path.display()
);
has_errors = true;
} else if let Err(e) = fs::metadata(&path) {
octomind::log_error!(
"Error: Cannot access file {}: {}",
path.display(),
e
);
has_errors = true;
}
}
Err(e) => {
octomind::log_error!(
"Error: Invalid path in pattern '{}': {}",
pattern,
e
);
has_errors = true;
}
}
}
if !found_any {
octomind::log_error!("Error: No files found matching pattern '{}'", pattern);
has_errors = true;
}
}
Err(e) => {
octomind::log_error!("Error: Invalid glob pattern '{}': {}", pattern, e);
has_errors = true;
}
}
}
if has_errors {
return Err(anyhow::anyhow!(
"File validation failed. Please check the file patterns and try again."
));
}
if total_files > 50 {
octomind::log_error!(
"Warning: Including {} files as context. This may result in a very large prompt.",
total_files
);
}
Ok(())
}
fn read_files_as_context(file_patterns: &[String]) -> Result<String> {
if file_patterns.is_empty() {
return Ok(String::new());
}
let mut context = String::new();
context.push_str("## File Context\n\n");
for pattern in file_patterns {
match glob(pattern) {
Ok(paths) => {
for path_result in paths {
match path_result {
Ok(path) => {
if let Ok(content) = fs::read_to_string(&path) {
context.push_str(&format!("### File: {}\n\n", path.display()));
context.push_str("```\n");
context.push_str(&content);
if !content.ends_with('\n') {
context.push('\n');
}
context.push_str("```\n\n");
} else {
context.push_str(&format!(
"### File: {} (could not read)\n\n",
path.display()
));
}
}
Err(_) => {
}
}
}
}
Err(_) => {
}
}
}
Ok(context)
}
lazy_static::lazy_static! {
static ref ASK_HISTORY_MUTEX: Mutex<()> = Mutex::new(());
}
fn get_ask_history_file_path() -> Result<PathBuf> {
crate::session::history::get_ask_history_file_path()
}
fn encode_ask_history_line(line: &str) -> String {
line.chars()
.map(|c| match c {
'\\' => "\\\\".to_string(),
'\n' => "\\n".to_string(),
c => c.to_string(),
})
.collect()
}
fn append_to_ask_history_file(line: &str) -> Result<()> {
let _lock = ASK_HISTORY_MUTEX.lock().unwrap();
let history_path = get_ask_history_file_path()?;
if !history_path.exists() {
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&history_path)?;
file.flush()?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&history_path)?;
let encoded_line = encode_ask_history_line(line);
writeln!(file, "{}", encoded_line)?;
file.flush()?;
Ok(())
}
fn get_interactive_input() -> Result<String> {
println!("{}", "Enter your question:".bright_blue());
println!(
"{}",
"- Use Ctrl+J for multiline input, Enter to send".dimmed()
);
println!(
"{}",
"- Type '/exit' or '/quit' to cancel, or press Ctrl+D".dimmed()
);
println!();
let history_path =
get_ask_history_file_path().unwrap_or_else(|_| std::path::PathBuf::from("ask_history.txt"));
let history = Box::new(
FileBackedHistory::with_file(500, history_path)
.expect("Error configuring history with file"),
);
let mut keybindings = default_emacs_keybindings();
keybindings.add_binding(
KeyModifiers::CONTROL,
KeyCode::Char('j'),
ReedlineEvent::Edit(vec![EditCommand::InsertNewline]),
);
let edit_mode = Box::new(Emacs::new(keybindings));
let mut line_editor = Reedline::create()
.with_history(history)
.use_bracketed_paste(true)
.with_edit_mode(edit_mode);
let prompt = octomind::session::chat::ChatPrompt::new(
String::new(),
"〉".to_string(),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
);
match line_editor.read_line(&prompt) {
Ok(Signal::Success(line)) => {
let trimmed = line.trim();
if trimmed == "/exit" || trimmed == "/quit" {
return Err(anyhow::anyhow!("User cancelled input"));
}
if trimmed.is_empty() {
return Err(anyhow::anyhow!("No input provided"));
}
if let Err(e) = append_to_ask_history_file(&line) {
octomind::log_info!("Could not append to ask history file: {}", e);
}
Ok(line)
}
Ok(Signal::CtrlC) => Err(anyhow::anyhow!("User cancelled input")),
Ok(Signal::CtrlD) => Err(anyhow::anyhow!("User cancelled input")),
Err(err) => Err(anyhow::anyhow!("Error reading input: {}", err)),
}
}
pub async fn execute(args: &AskArgs, config: &Config) -> Result<()> {
if let Err(e) = validate_file_patterns(&args.files) {
octomind::log_error!("{}", e);
std::process::exit(1);
}
let model = args
.model
.clone()
.unwrap_or_else(|| config.get_effective_model());
let temperature = args.temperature.unwrap_or(config.ask.temperature);
let top_p = config.ask.top_p;
let top_k = config.ask.top_k;
let base_system_prompt = &config.ask.system;
let current_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let system_prompt = crate::session::helper_functions::process_placeholders_async(
base_system_prompt,
¤t_dir,
)
.await;
let mut clean_config = config.clone();
clean_config.mcp.servers.clear();
clean_config.custom_instructions_file_name = String::new();
let file_context = read_files_as_context(&args.files)?;
if let Some(input) = &args.input {
let full_input = if file_context.is_empty() {
input.clone()
} else {
format!("{}\n\n{}", file_context, input)
};
let response = execute_single_query(SingleQueryParams {
input: &full_input,
model: &model,
temperature,
top_p,
top_k,
max_tokens: args
.max_tokens
.unwrap_or_else(|| clean_config.get_effective_max_tokens()),
system_prompt: &system_prompt,
config: &clean_config,
})
.await?;
print_response(&response.content, args.raw, config);
Ok(())
} else if !std::io::stdin().is_terminal() {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
let input = buffer.trim().to_string();
if input.is_empty() {
octomind::log_error!("Error: No input provided.");
std::process::exit(1);
}
let full_input = if file_context.is_empty() {
input
} else {
format!("{}\n\n{}", file_context, input)
};
let response = execute_single_query(SingleQueryParams {
input: &full_input,
model: &model,
temperature,
top_p,
top_k,
max_tokens: args
.max_tokens
.unwrap_or_else(|| clean_config.get_effective_max_tokens()),
system_prompt: &system_prompt,
config: &clean_config,
})
.await?;
print_response(&response.content, args.raw, config);
Ok(())
} else {
println!(
"{}",
"Entering multimode - ask questions continuously (no context preserved)".bright_green()
);
println!();
loop {
match get_interactive_input() {
Ok(input) => {
if input.is_empty() {
octomind::log_error!("Error: No input provided.");
continue;
}
let full_input = if file_context.is_empty() {
input.clone()
} else {
format!("{}\n\n{}", file_context, input)
};
let animation_manager = octomind::session::chat::get_animation_manager();
animation_manager.start_with_params(0.0, 0, 0).await;
let query_result = execute_single_query(SingleQueryParams {
input: &full_input,
model: &model,
temperature,
top_p,
top_k,
max_tokens: args
.max_tokens
.unwrap_or_else(|| clean_config.get_effective_max_tokens()),
system_prompt: &system_prompt,
config: &clean_config,
})
.await;
animation_manager.stop_current().await;
match query_result {
Ok(response) => {
print_response(&response.content, args.raw, config);
println!(); }
Err(e) => {
octomind::log_error!("Error: {}", e);
}
}
}
Err(e) => {
if e.to_string().contains("User cancelled") {
println!("Exiting multimode.");
break;
} else {
octomind::log_error!("Error: {}", e);
continue;
}
}
}
}
Ok(())
}
}
struct SingleQueryParams<'a> {
input: &'a str,
model: &'a str,
temperature: f32,
top_p: f32,
top_k: u32,
max_tokens: u32,
system_prompt: &'a str,
config: &'a Config,
}
async fn execute_single_query(params: SingleQueryParams<'_>) -> Result<ProviderResponse> {
let messages = vec![
Message {
role: "system".to_string(),
content: params.system_prompt.to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
cached: false,
..Default::default()
},
Message {
role: "user".to_string(),
content: params.input.to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
cached: false,
..Default::default()
},
];
chat_completion_with_provider(ChatCompletionProviderParams {
messages: &messages,
model: params.model,
temperature: params.temperature,
top_p: params.top_p,
top_k: params.top_k,
max_tokens: params.max_tokens,
config: params.config,
max_retries: 0,
cancellation_token: None,
})
.await
}