use std::path::Path;
use std::time::Duration;
use anyhow::Result;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::agent::context::ConversationContext;
use crate::agent::r#loop::AgentEvent;
use crate::agent::prompt;
use crate::api::provider::OpenAiCompatibleProvider;
use crate::config::Config;
use crate::project_cache;
pub struct WatchConfig {
pub prompt: String,
pub debounce_ms: u64,
pub extensions: Vec<String>,
pub watch_dir: Option<String>,
pub model: Option<String>,
pub agent: Option<String>,
}
pub async fn run_watch(
mut config: Config,
client: OpenAiCompatibleProvider,
watch_config: WatchConfig,
) -> Result<()> {
let working_dir = watch_config.watch_dir.clone().unwrap_or_else(|| {
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string())
});
if let Some(ref model) = watch_config.model {
config.model = model.clone();
}
if let Some(ref agent_name) = watch_config.agent
&& let Some(agent) = config.agents.iter().find(|a| &a.name == agent_name)
{
config.model = agent.model.clone();
}
eprintln!(
"👁 Watch mode active (debounce: {}ms)",
watch_config.debounce_ms
);
eprintln!("📁 Directory: {}", working_dir);
eprintln!("🤖 Model: {}", config.model);
eprintln!("📝 Prompt: {}", &watch_config.prompt);
if !watch_config.extensions.is_empty() {
eprintln!(
"📎 Watching extensions: {}",
watch_config.extensions.join(", ")
);
}
if let Some(ref agent) = watch_config.agent {
eprintln!("👤 Agent: {}", agent);
}
eprintln!("Press Ctrl+C to stop.");
eprintln!("---");
project_cache::global().ensure_background_tasks();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
cancel_clone.cancel();
});
let mut last_snapshot = snapshot_mtimes(&working_dir, &watch_config.extensions);
let debounce = Duration::from_millis(watch_config.debounce_ms);
loop {
if cancel.is_cancelled() {
break;
}
tokio::time::sleep(debounce).await;
let current_snapshot = snapshot_mtimes(&working_dir, &watch_config.extensions);
let changed: Vec<String> = current_snapshot
.iter()
.filter(|(path, mtime)| {
last_snapshot
.get(path.as_str())
.map(|t| t != *mtime)
.unwrap_or(true)
})
.map(|(path, _)| path.clone())
.collect();
if changed.is_empty() {
continue;
}
last_snapshot = current_snapshot;
eprintln!("\n🔄 Files changed: {}", changed.join(", "));
for path in &changed {
let abs = if Path::new(path).is_absolute() {
path.clone()
} else {
format!("{}/{}", working_dir, path)
};
project_cache::global().notify_file_modified(&abs);
}
let snap = {
let wd = working_dir.clone();
tokio::task::spawn_blocking(move || project_cache::snapshot(&wd))
.await
.unwrap()
};
let system_prompt = prompt::build_default_prompt(
&snap.map_string,
snap.file_count,
snap.symbol_count,
None,
);
let context = ConversationContext::with_budget(
system_prompt,
config.context_max_tokens,
config.compaction_threshold,
);
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<AgentEvent>();
let run_cancel = CancellationToken::new();
let client_clone = client.clone();
let config_clone = config.clone();
let wd = working_dir.clone();
let prompt = format!(
"{}\n\nChanged files: {}",
watch_config.prompt,
changed.join(", "),
);
let lsp_manager = crate::lsp::manager::LspManager::new(working_dir.clone());
let lsp_clone = lsp_manager.clone();
tokio::spawn(async move {
crate::agent::r#loop::run_with_mode(crate::agent::r#loop::AgentParams {
client: client_clone,
config: config_clone,
context,
user_msg: prompt,
working_dir: wd,
event_tx,
cancel: run_cancel,
lsp_manager: lsp_clone,
trust_level: crate::trust::TrustLevel::Full, approval_gate: crate::agent::approval::ApprovalGate::yolo(),
images: Vec::new(),
})
.await;
});
while let Some(event) = event_rx.recv().await {
match event {
AgentEvent::Token(token) => print!("{token}"),
AgentEvent::ToolCall { name, .. } => eprintln!(" 🔧 {name}"),
AgentEvent::ToolResult { name, success, .. } => {
let icon = if success { "✓" } else { "✗" };
eprintln!(" {icon} {name}");
}
AgentEvent::FileModified { path } => eprintln!(" 📄 {path}"),
AgentEvent::Error(msg) => eprintln!(" ❌ {msg}"),
AgentEvent::Done { .. } => {
println!();
eprintln!("--- Run complete. Watching for changes...");
break;
}
_ => {}
}
}
if cancel.is_cancelled() {
break;
}
}
eprintln!("👁 Watch mode stopped.");
Ok(())
}
fn snapshot_mtimes(dir: &str, extensions: &[String]) -> std::collections::HashMap<String, u64> {
let mut map = std::collections::HashMap::new();
let walker = ignore::WalkBuilder::new(dir)
.hidden(true)
.git_ignore(true)
.add_custom_ignore_filename(".colletignore")
.max_depth(Some(10))
.build();
for entry in walker.flatten() {
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
if !extensions.is_empty() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if !extensions.iter().any(|e| e == ext) {
continue;
}
} else {
continue;
}
}
if let Ok(meta) = path.metadata()
&& let Ok(modified) = meta.modified()
{
let secs = modified
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let rel = path
.strip_prefix(dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
map.insert(rel, secs);
}
}
map
}