From 0044a3b059fdb80e5194db19d779230854425d4c Mon Sep 17 00:00:00 2001 From: syui Date: Tue, 24 Mar 2026 14:37:33 +0900 Subject: [PATCH] feat(config): add interval-based auto-cycling and TUI file watcher --- src/config.rs | 48 +++++++++++++++++++++++++++++++--------- src/headless.rs | 37 ++++++++++++++++++++----------- src/tui.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/config.rs b/src/config.rs index ff10a65..999639d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,27 +41,55 @@ pub fn preset(name: &str) -> Option> { } } +/// Parsed config with optional loop interval. +pub struct LoadedConfig { + pub agents: Vec, + pub interval: Option, +} + #[derive(Deserialize)] struct MultiConfig { agents: Option>, - // Single agent at top level + interval: Option, name: Option, task: Option, cwd: Option, } -/// 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 { - 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::(&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::>(&content).unwrap_or_default(); + LoadedConfig { agents, interval: None } +} + +/// Legacy: load agents only from file/directory. fn load_file(path: &Path) -> Vec { let content = match std::fs::read_to_string(path) { Ok(c) => c, diff --git a/src/headless.rs b/src/headless.rs index ad3af74..531e17b 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -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::(&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; } diff --git a/src/tui.rs b/src/tui.rs index 6b7cafd..eeac064 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -48,6 +48,7 @@ pub struct App { agent_scroll: u16, cmd_cache: CommandCache, + watch_rx: Option>>, 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 → 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| { + if let Ok(event) = res { + if matches!(event.kind, notify::EventKind::Create(_) | notify::EventKind::Modify(_)) { + let files: Vec = 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()) }