feat(config): add interval-based auto-cycling and TUI file watcher
This commit is contained in:
@@ -41,27 +41,55 @@ pub fn preset(name: &str) -> Option<Vec<AgentConfig>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed config with optional loop interval.
|
||||
pub struct LoadedConfig {
|
||||
pub agents: Vec<AgentConfig>,
|
||||
pub interval: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MultiConfig {
|
||||
agents: Option<Vec<AgentConfig>>,
|
||||
// Single agent at top level
|
||||
interval: Option<u64>,
|
||||
name: Option<String>,
|
||||
task: Option<String>,
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
/// Load agent configs from a file or directory.
|
||||
/// - File: single JSON with `agents` array, or a single agent object
|
||||
/// - Directory: each .json file is one agent (or multi)
|
||||
pub fn load(path: &str) -> Vec<AgentConfig> {
|
||||
let path = Path::new(path);
|
||||
if path.is_dir() {
|
||||
load_dir(path)
|
||||
} else {
|
||||
load_file(path)
|
||||
}
|
||||
load_full(path).agents
|
||||
}
|
||||
|
||||
/// Load config with metadata (interval etc).
|
||||
pub fn load_full(path: &str) -> LoadedConfig {
|
||||
let path = Path::new(path);
|
||||
if path.is_dir() {
|
||||
return LoadedConfig { agents: load_dir(path), interval: None };
|
||||
}
|
||||
load_file_full(path)
|
||||
}
|
||||
|
||||
fn load_file_full(path: &Path) -> LoadedConfig {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return LoadedConfig { agents: Vec::new(), interval: None },
|
||||
};
|
||||
if let Ok(multi) = serde_json::from_str::<MultiConfig>(&content) {
|
||||
if let Some(agents) = multi.agents {
|
||||
return LoadedConfig { agents, interval: multi.interval };
|
||||
}
|
||||
if let (Some(name), Some(task), Some(cwd)) = (multi.name, multi.task, multi.cwd) {
|
||||
return LoadedConfig {
|
||||
agents: vec![AgentConfig { name, task, cwd }],
|
||||
interval: multi.interval,
|
||||
};
|
||||
}
|
||||
}
|
||||
let agents = serde_json::from_str::<Vec<AgentConfig>>(&content).unwrap_or_default();
|
||||
LoadedConfig { agents, interval: None }
|
||||
}
|
||||
|
||||
/// Legacy: load agents only from file/directory.
|
||||
fn load_file(path: &Path) -> Vec<AgentConfig> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
|
||||
@@ -13,8 +13,9 @@ pub fn run(config_or_task: &str, cwd_override: Option<&str>, name_override: Opti
|
||||
create_state_dir();
|
||||
|
||||
let is_multi = config_or_task.ends_with(".json") || std::path::Path::new(config_or_task).is_dir();
|
||||
let configs = if is_multi {
|
||||
config::load(config_or_task)
|
||||
let (configs, interval) = if is_multi {
|
||||
let loaded = config::load_full(config_or_task);
|
||||
(loaded.agents, loaded.interval)
|
||||
} else {
|
||||
let cwd = cwd_override
|
||||
.map(|s| s.to_string())
|
||||
@@ -22,7 +23,7 @@ pub fn run(config_or_task: &str, cwd_override: Option<&str>, name_override: Opti
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| ".".to_string()));
|
||||
let name = name_override.unwrap_or("task").to_string();
|
||||
vec![config::AgentConfig { name, task: config_or_task.to_string(), cwd }]
|
||||
(vec![config::AgentConfig { name, task: config_or_task.to_string(), cwd }], None)
|
||||
};
|
||||
|
||||
if configs.is_empty() {
|
||||
@@ -97,16 +98,26 @@ pub fn run(config_or_task: &str, cwd_override: Option<&str>, name_override: Opti
|
||||
}
|
||||
}
|
||||
|
||||
// Pause and wait for signal
|
||||
write_loop_waiting(&agents, has_next);
|
||||
eprintln!(" waiting... (aishell next / aishell stop)");
|
||||
|
||||
let action = wait_for_loop_signal(&running);
|
||||
reset_loop_control();
|
||||
|
||||
match action {
|
||||
LoopAction::Next => {}
|
||||
LoopAction::Quit => break,
|
||||
// Pause: interval-based auto or manual signal
|
||||
if let Some(secs) = interval {
|
||||
eprintln!(" next cycle in {secs}s... (aishell stop to quit)");
|
||||
write_loop_waiting(&agents, has_next);
|
||||
for _ in 0..secs * 2 {
|
||||
if !running.load(Ordering::Relaxed) { break; }
|
||||
// Check for early stop signal
|
||||
if let Ok(content) = std::fs::read_to_string(format!("{STATE_DIR}/loop.json")) {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if v["quit"].as_bool() == Some(true) { break; }
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
} else {
|
||||
write_loop_waiting(&agents, has_next);
|
||||
eprintln!(" waiting... (aishell next / aishell stop)");
|
||||
let action = wait_for_loop_signal(&running);
|
||||
reset_loop_control();
|
||||
match action { LoopAction::Next => {} LoopAction::Quit => break }
|
||||
}
|
||||
|
||||
if !running.load(Ordering::Relaxed) { break; }
|
||||
|
||||
59
src/tui.rs
59
src/tui.rs
@@ -48,6 +48,7 @@ pub struct App {
|
||||
agent_scroll: u16,
|
||||
|
||||
cmd_cache: CommandCache,
|
||||
watch_rx: Option<std::sync::mpsc::Receiver<Vec<String>>>,
|
||||
mode: Mode,
|
||||
input: String,
|
||||
input_task: String,
|
||||
@@ -83,6 +84,7 @@ impl App {
|
||||
next_id: 1,
|
||||
agent_scroll: 0,
|
||||
cmd_cache: CommandCache::new(),
|
||||
watch_rx: None,
|
||||
mode: Mode::Ai,
|
||||
input: String::new(),
|
||||
input_task: String::new(),
|
||||
@@ -211,6 +213,26 @@ impl App {
|
||||
self.ai_phase = AiPhase::Integrating;
|
||||
}
|
||||
|
||||
// Check file watcher
|
||||
if let Some(ref rx) = self.watch_rx {
|
||||
let mut changed = Vec::new();
|
||||
while let Ok(files) = rx.try_recv() {
|
||||
changed.extend(files);
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
changed.dedup();
|
||||
let short: Vec<&str> = changed.iter()
|
||||
.map(|f| f.rsplit('/').next().unwrap_or(f.as_str()))
|
||||
.take(5).collect();
|
||||
let msg = format!("[files changed: {}]", short.join(", "));
|
||||
self.message = Some(Message { text: msg.clone(), is_error: false });
|
||||
// Notify AI
|
||||
if let Some(ref mut claude) = self.claude {
|
||||
claude.send(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write state files
|
||||
if self.agents.iter().any(|a| a.dirty) {
|
||||
write_state(&self.agents);
|
||||
@@ -277,6 +299,13 @@ impl App {
|
||||
if self.ai_input.is_empty() { return; }
|
||||
let input = std::mem::take(&mut self.ai_input);
|
||||
|
||||
// watch <dir> → start file watcher
|
||||
if input.starts_with("watch ") || input == "watch" {
|
||||
let dir = input.strip_prefix("watch").unwrap_or(".").trim();
|
||||
let dir = if dir.is_empty() { "." } else { dir };
|
||||
self.start_watch(dir);
|
||||
return;
|
||||
}
|
||||
// @name task [-c cwd] → spawn agent
|
||||
if let Some(rest) = input.strip_prefix('@') {
|
||||
let (name, task, cwd) = parse_at_command(rest);
|
||||
@@ -530,6 +559,36 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn start_watch(&mut self, dir: &str) {
|
||||
let dir = expand_tilde(dir);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
let dir_clone = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
use notify::{Watcher, RecursiveMode};
|
||||
let tx = tx;
|
||||
let mut watcher = match notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
if matches!(event.kind, notify::EventKind::Create(_) | notify::EventKind::Modify(_)) {
|
||||
let files: Vec<String> = event.paths.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.filter(|s| !s.contains("/.") && !s.contains("/target/"))
|
||||
.collect();
|
||||
if !files.is_empty() { let _ = tx.send(files); }
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = watcher.watch(std::path::Path::new(&dir_clone), RecursiveMode::Recursive);
|
||||
loop { std::thread::sleep(std::time::Duration::from_secs(60)); }
|
||||
});
|
||||
|
||||
self.watch_rx = Some(rx);
|
||||
self.message = Some(Message { text: format!("watching: {dir}"), is_error: false });
|
||||
}
|
||||
|
||||
fn selected_agent_name(&self) -> Option<&str> {
|
||||
self.agents.get(self.selected).map(|a| a.name.as_str())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user