2
0

feat(config): add interval-based auto-cycling and TUI file watcher

This commit is contained in:
2026-03-24 14:37:33 +09:00
parent cefa51e73c
commit 0044a3b059
3 changed files with 121 additions and 23 deletions

View File

@@ -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)] #[derive(Deserialize)]
struct MultiConfig { struct MultiConfig {
agents: Option<Vec<AgentConfig>>, agents: Option<Vec<AgentConfig>>,
// Single agent at top level interval: Option<u64>,
name: Option<String>, name: Option<String>,
task: Option<String>, task: Option<String>,
cwd: 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> { pub fn load(path: &str) -> Vec<AgentConfig> {
let path = Path::new(path); load_full(path).agents
if path.is_dir() {
load_dir(path)
} else {
load_file(path)
}
} }
/// 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> { fn load_file(path: &Path) -> Vec<AgentConfig> {
let content = match std::fs::read_to_string(path) { let content = match std::fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,

View File

@@ -13,8 +13,9 @@ pub fn run(config_or_task: &str, cwd_override: Option<&str>, name_override: Opti
create_state_dir(); create_state_dir();
let is_multi = config_or_task.ends_with(".json") || std::path::Path::new(config_or_task).is_dir(); let is_multi = config_or_task.ends_with(".json") || std::path::Path::new(config_or_task).is_dir();
let configs = if is_multi { let (configs, interval) = if is_multi {
config::load(config_or_task) let loaded = config::load_full(config_or_task);
(loaded.agents, loaded.interval)
} else { } else {
let cwd = cwd_override let cwd = cwd_override
.map(|s| s.to_string()) .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()) .map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string())); .unwrap_or_else(|_| ".".to_string()));
let name = name_override.unwrap_or("task").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() { 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 // Pause: interval-based auto or manual signal
write_loop_waiting(&agents, has_next); if let Some(secs) = interval {
eprintln!(" waiting... (aishell next / aishell stop)"); eprintln!(" next cycle in {secs}s... (aishell stop to quit)");
write_loop_waiting(&agents, has_next);
let action = wait_for_loop_signal(&running); for _ in 0..secs * 2 {
reset_loop_control(); if !running.load(Ordering::Relaxed) { break; }
// Check for early stop signal
match action { if let Ok(content) = std::fs::read_to_string(format!("{STATE_DIR}/loop.json")) {
LoopAction::Next => {} if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
LoopAction::Quit => break, 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; } if !running.load(Ordering::Relaxed) { break; }

View File

@@ -48,6 +48,7 @@ pub struct App {
agent_scroll: u16, agent_scroll: u16,
cmd_cache: CommandCache, cmd_cache: CommandCache,
watch_rx: Option<std::sync::mpsc::Receiver<Vec<String>>>,
mode: Mode, mode: Mode,
input: String, input: String,
input_task: String, input_task: String,
@@ -83,6 +84,7 @@ impl App {
next_id: 1, next_id: 1,
agent_scroll: 0, agent_scroll: 0,
cmd_cache: CommandCache::new(), cmd_cache: CommandCache::new(),
watch_rx: None,
mode: Mode::Ai, mode: Mode::Ai,
input: String::new(), input: String::new(),
input_task: String::new(), input_task: String::new(),
@@ -211,6 +213,26 @@ impl App {
self.ai_phase = AiPhase::Integrating; 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 // Write state files
if self.agents.iter().any(|a| a.dirty) { if self.agents.iter().any(|a| a.dirty) {
write_state(&self.agents); write_state(&self.agents);
@@ -277,6 +299,13 @@ impl App {
if self.ai_input.is_empty() { return; } if self.ai_input.is_empty() { return; }
let input = std::mem::take(&mut self.ai_input); 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 // @name task [-c cwd] → spawn agent
if let Some(rest) = input.strip_prefix('@') { if let Some(rest) = input.strip_prefix('@') {
let (name, task, cwd) = parse_at_command(rest); 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> { fn selected_agent_name(&self) -> Option<&str> {
self.agents.get(self.selected).map(|a| a.name.as_str()) self.agents.get(self.selected).map(|a| a.name.as_str())
} }