From da1159cbf9cef203a16ffdf9377829316a45e97e Mon Sep 17 00:00:00 2001 From: syui Date: Tue, 24 Mar 2026 11:43:54 +0900 Subject: [PATCH] update --- .gitignore | 3 + Cargo.toml | 3 + src/agent.rs | 327 +++++++++++++++ src/ai.rs | 140 ++----- src/claude.rs | 202 +++++++++ src/config.rs | 90 +++++ src/executor.rs | 2 +- src/headless.rs | 1034 +++++++++++++++++++++++++++++++++++++++++++++++ src/judge.rs | 5 +- src/lib.rs | 6 + src/main.rs | 311 ++++++-------- src/tui.rs | 948 +++++++++++++++++++++++++++++++++++++++++++ src/watch.rs | 110 +++++ 13 files changed, 2877 insertions(+), 304 deletions(-) create mode 100644 src/agent.rs create mode 100644 src/claude.rs create mode 100644 src/config.rs create mode 100644 src/headless.rs create mode 100644 src/tui.rs create mode 100644 src/watch.rs diff --git a/.gitignore b/.gitignore index d2a7145..cc16241 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ Thumbs.db /.claude /claude.md /CLAUDE.md +/docs +/example +/config diff --git a/Cargo.toml b/Cargo.toml index dfead01..f006b4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" terminal_size = "0.4" libc = "0.2" +notify = { version = "7", features = ["macos_fsevent"] } +ratatui = "0.29" +crossterm = "0.28" diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..4b2f318 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,327 @@ +use std::sync::mpsc; +use std::collections::HashSet; +use serde_json::json; +use crate::claude::{self, StreamEvent, StatusKind}; + +#[derive(Clone, PartialEq)] +pub enum AgentStatus { + Thinking, + Responding, + Tool(String), + Done, + Error(String), +} + +impl AgentStatus { + fn from_status_kind(kind: &StatusKind) -> Self { + match kind { + StatusKind::Idle => Self::Done, + StatusKind::Thinking => Self::Thinking, + StatusKind::Responding => Self::Responding, + StatusKind::Tool(name) => Self::Tool(name.clone()), + } + } +} + +impl std::fmt::Display for AgentStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Thinking => write!(f, "thinking..."), + Self::Responding => write!(f, "responding..."), + Self::Tool(name) => write!(f, "tool: {name}"), + Self::Done => write!(f, "done"), + Self::Error(e) => write!(f, "error: {e}"), + } + } +} + +#[derive(Clone)] +pub struct ProcessEntry { + pub elapsed: String, + pub kind: String, + pub detail: String, +} + +pub struct Agent { + pub id: usize, + pub name: String, + pub task: String, + pub cwd: String, + pub status: AgentStatus, + pub output: String, + pub dirty: bool, + pub started_at: std::time::Instant, + pub process: Vec, + pub tools_used: HashSet, + pub files_read: HashSet, + pub commands_run: Vec, + rx: mpsc::Receiver, + stdin: std::process::ChildStdin, + pid: u32, + stopped: bool, +} + +impl Agent { + pub fn spawn(id: usize, name: &str, task: &str, cwd: &str) -> Result { + let cwd_path = std::path::Path::new(cwd); + if !cwd_path.is_dir() { + return Err(format!("directory not found: {cwd}")); + } + + let (child, mut stdin, stdout) = claude::spawn_claude(Some(cwd))?; + let pid = child.id(); + + // Inject git context if available + let full_task = match git_context(cwd) { + Some(ctx) => format!("{task}\n\n[git context]\n{ctx}"), + None => task.to_string(), + }; + claude::send_message(&mut stdin, &full_task); + let (tx, rx) = mpsc::channel(); + claude::spawn_reader(child, stdout, tx); + + Ok(Self { + id, + name: name.to_string(), + task: task.to_string(), + cwd: cwd.to_string(), + status: AgentStatus::Thinking, + output: String::new(), + dirty: true, + started_at: std::time::Instant::now(), + process: vec![ProcessEntry { + elapsed: "0s".into(), kind: "start".into(), detail: task.to_string(), + }], + tools_used: HashSet::new(), + files_read: HashSet::new(), + commands_run: Vec::new(), + rx, stdin, pid, + stopped: false, + }) + } + + pub fn send(&mut self, input: &str) { + self.status = AgentStatus::Thinking; + self.dirty = true; + self.log("instruction", input); + claude::send_message(&mut self.stdin, input); + } + + pub fn poll(&mut self) { + loop { + match self.rx.try_recv() { + Ok(event) => { + self.dirty = true; + match event { + StreamEvent::StreamStart => {} + StreamEvent::Chunk(text) => self.output.push_str(&text), + StreamEvent::StreamEnd => { + self.log("response", &format!("{}chars", self.output.len())); + } + StreamEvent::Status(kind) => { + self.status = AgentStatus::from_status_kind(&kind); + } + StreamEvent::ToolInvoke(tool) => { + self.tools_used.insert(tool.name.clone()); + if tool.name == "Bash" && !tool.input_summary.is_empty() { + self.commands_run.push(tool.input_summary.clone()); + } + if matches!(tool.name.as_str(), "Read" | "Glob" | "Grep") + && !tool.input_summary.is_empty() + { + self.files_read.insert(tool.input_summary.clone()); + } + self.log("tool", &format!("{}: {}", tool.name, tool.input_summary)); + } + StreamEvent::ToolResult { name, output } => { + self.log("tool_result", &format!("{name}: {}", truncate(&output, 200))); + } + } + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + if self.is_running() { + self.status = AgentStatus::Error("process exited".to_string()); + self.dirty = true; + self.log("error", "process exited"); + } + break; + } + } + } + } + + pub fn stop(&mut self) { + if self.is_running() && !self.stopped { + self.stopped = true; + unsafe { libc::kill(self.pid as i32, libc::SIGTERM); } + self.status = AgentStatus::Error("stopped".to_string()); + self.dirty = true; + self.log("stopped", ""); + } + } + + pub fn is_running(&self) -> bool { + matches!(self.status, AgentStatus::Thinking | AgentStatus::Responding | AgentStatus::Tool(_)) + } + + pub fn elapsed(&self) -> String { + format_duration(self.started_at.elapsed().as_secs()) + } + + pub fn conclusion(&self) -> &str { + match &self.status { + AgentStatus::Done => "success", + AgentStatus::Error(e) if e == "stopped" => "stopped", + AgentStatus::Error(_) => "error", + _ => "running", + } + } + + pub fn summary(&self) -> String { + if self.output.is_empty() { return "(no output)".to_string(); } + let first_line = self.output.lines().next().unwrap_or(""); + if first_line.chars().count() > 120 { + let s: String = first_line.chars().take(120).collect(); + format!("{s}...") + } else { + first_line.to_string() + } + } + + fn log(&mut self, kind: &str, detail: &str) { + self.process.push(ProcessEntry { + elapsed: self.elapsed(), + kind: kind.to_string(), + detail: detail.to_string(), + }); + } + + // ── Output formats ───────────────────────────────────── + // JSON only. One format for storage, AI communication, and atproto. + + /// Compact JSON for AI communication. Includes result for integration. + pub fn to_ai_json(&self) -> serde_json::Value { + let result = truncate_middle(&self.output, 2000); + let error = if let AgentStatus::Error(ref e) = self.status { + Some(e.clone()) + } else { + None + }; + let mut v = json!({ + "name": self.name, + "conclusion": self.conclusion(), + "task": self.task, + "result": result, + }); + if let Some(e) = error { + v["error"] = json!(e); + } + v + } + + /// Full JSON for file storage. + pub fn to_json(&self) -> serde_json::Value { + let conclusion = self.conclusion(); + let summary = self.summary(); + let process: Vec<_> = self.process.iter().map(|p| { + json!({"t": p.elapsed, "type": p.kind, "detail": p.detail}) + }).collect(); + let tools: Vec<_> = self.tools_used.iter().cloned().collect(); + let files: Vec<_> = self.files_read.iter().cloned().collect(); + + json!({ + "id": self.id, + "name": self.name, + "task": self.task, + "cwd": self.cwd, + "conclusion": conclusion, + "elapsed": self.elapsed(), + "summary": summary, + "result": self.output, + "process": process, + "meta": { + "tools": tools, + "commands": self.commands_run, + "files_read": files, + } + }) + } + + /// Summary JSON for agents.json (no full output). + pub fn to_summary_json(&self) -> serde_json::Value { + json!({ + "id": self.id, + "name": self.name, + "conclusion": self.conclusion(), + "summary": self.summary(), + "elapsed": self.elapsed(), + "task": self.task, + "cwd": self.cwd, + }) + } +} + +impl Drop for Agent { + fn drop(&mut self) { + if !self.stopped { + self.stopped = true; + unsafe { libc::kill(self.pid as i32, libc::SIGTERM); } + } + } +} + +pub fn git_context(cwd: &str) -> Option { + use std::process::Command; + let run = |args: &[&str]| -> String { + Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() + }; + + let branch = run(&["branch", "--show-current"]); + if branch.is_empty() { return None; } + + let status = run(&["status", "--short"]); + let log = run(&["log", "--oneline", "-5"]); + let diff_stat = run(&["diff", "--stat", "HEAD"]); + + let mut ctx = format!("branch: {branch}"); + if !status.is_empty() { + ctx.push_str(&format!("\nchanged:\n{status}")); + } + if !diff_stat.is_empty() { + ctx.push_str(&format!("\ndiff:\n{diff_stat}")); + } + if !log.is_empty() { + ctx.push_str(&format!("\nrecent:\n{log}")); + } + Some(ctx) +} + +fn format_duration(secs: u64) -> String { + if secs < 60 { format!("{secs}s") } + else if secs < 3600 { format!("{}m{}s", secs / 60, secs % 60) } + else { format!("{}h{}m", secs / 3600, (secs % 3600) / 60) } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { return s.to_string(); } + let end: String = s.chars().take(max).collect(); + format!("{end}...") +} + +/// Truncate keeping head + tail for context. +fn truncate_middle(s: &str, max: usize) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() <= max { return s.to_string(); } + let half = max / 2; + let head: String = chars[..half].iter().collect(); + let tail: String = chars[chars.len() - half..].iter().collect(); + format!("{head}\n...[truncated]...\n{tail}") +} diff --git a/src/ai.rs b/src/ai.rs index b13cac9..aed2455 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -1,9 +1,5 @@ -use std::io::{BufRead, BufReader, Write}; -use std::process::{Command, Stdio}; -use std::sync::{Arc, Mutex}; use std::sync::mpsc; -use std::thread; -use serde_json::Value; +use crate::claude::{self, StreamEvent, StatusKind}; pub enum OutputEvent { StreamStart, @@ -13,136 +9,56 @@ pub enum OutputEvent { pub struct ClaudeManager { stdin: std::process::ChildStdin, - state: Arc>, - output_rx: mpsc::Receiver, + status: StatusKind, + output_rx: mpsc::Receiver, child_pid: u32, } impl ClaudeManager { pub fn spawn() -> Result { - let mut child = Command::new("claude") - .arg("--input-format").arg("stream-json") - .arg("--output-format").arg("stream-json") - .arg("--verbose") - .arg("--dangerously-skip-permissions") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("failed to start claude: {}", e))?; - + let (child, stdin, stdout) = claude::spawn_claude(None)?; let child_pid = child.id(); - let stdin = child.stdin.take().ok_or("failed to capture stdin")?; - let stdout = child.stdout.take().ok_or("failed to capture stdout")?; - - let state = Arc::new(Mutex::new("idle".to_string())); let (tx, rx) = mpsc::channel(); - let state_bg = state.clone(); + claude::spawn_reader(child, stdout, tx); - thread::spawn(move || { - Self::reader_loop(child, stdout, tx, state_bg); - }); - - Ok(Self { stdin, state, output_rx: rx, child_pid }) - } - - fn reader_loop( - mut child: std::process::Child, - stdout: std::process::ChildStdout, - tx: mpsc::Sender, - state: Arc>, - ) { - let reader = BufReader::new(stdout); - let mut stream_started = false; - let mut last_text_len: usize = 0; - - for line in reader.lines().flatten() { - if line.trim().is_empty() { continue; } - let json: Value = match serde_json::from_str(&line) { - Ok(v) => v, - Err(_) => continue, - }; - - match json.get("type").and_then(|t| t.as_str()) { - Some("assistant") => { - let content = json.pointer("/message/content").and_then(|c| c.as_array()); - for item in content.into_iter().flatten() { - match item.get("type").and_then(|t| t.as_str()) { - Some("text") => { - if let Some(text) = item.get("text").and_then(|t| t.as_str()) { - if !stream_started { - let _ = tx.send(OutputEvent::StreamStart); - stream_started = true; - last_text_len = 0; - } - if text.len() > last_text_len { - let _ = tx.send(OutputEvent::StreamChunk( - text[last_text_len..].to_string(), - )); - last_text_len = text.len(); - } - } - Self::set_state(&state, "responding..."); - } - Some("tool_use") => { - let name = item.get("name") - .and_then(|n| n.as_str()) - .unwrap_or("tool"); - Self::set_state(&state, &format!("running: {name}...")); - } - _ => {} - } - } - } - Some("result") => { - if !stream_started { - if let Some(text) = json.get("result").and_then(|r| r.as_str()) { - if !text.is_empty() { - let _ = tx.send(OutputEvent::StreamStart); - let _ = tx.send(OutputEvent::StreamChunk(text.to_string())); - } - } - } - let _ = tx.send(OutputEvent::StreamEnd); - stream_started = false; - last_text_len = 0; - Self::set_state(&state, "idle"); - } - _ => {} - } - } - let _ = child.wait(); - } - - fn set_state(state: &Arc>, s: &str) { - if let Ok(mut st) = state.lock() { *st = s.to_string(); } + Ok(Self { stdin, status: StatusKind::Idle, output_rx: rx, child_pid }) } pub fn send(&mut self, input: &str) { - Self::set_state(&self.state, "thinking..."); - let msg = serde_json::json!({ - "type": "user", - "message": { "role": "user", "content": input } - }); - let _ = writeln!(self.stdin, "{}", msg); - let _ = self.stdin.flush(); + self.status = StatusKind::Thinking; + claude::send_message(&mut self.stdin, input); } - pub fn try_recv(&self) -> Option { - self.output_rx.try_recv().ok() + pub fn try_recv(&mut self) -> Option { + match self.output_rx.try_recv().ok()? { + StreamEvent::StreamStart => Some(OutputEvent::StreamStart), + StreamEvent::Chunk(text) => Some(OutputEvent::StreamChunk(text)), + StreamEvent::StreamEnd => Some(OutputEvent::StreamEnd), + StreamEvent::Status(kind) => { + self.status = kind; + None + } + StreamEvent::ToolInvoke(_) | StreamEvent::ToolResult { .. } => None, + } } pub fn cancel(&mut self) { - Self::set_state(&self.state, "idle"); + self.status = StatusKind::Idle; unsafe { libc::kill(self.child_pid as i32, libc::SIGINT); } while self.output_rx.try_recv().is_ok() {} } pub fn is_busy(&self) -> bool { - self.state.lock().map(|s| *s != "idle").unwrap_or(false) + self.status != StatusKind::Idle } pub fn status_line(&self) -> String { - self.state.lock().map(|s| s.clone()).unwrap_or_else(|_| "error".to_string()) + self.status.to_string() + } +} + +impl Drop for ClaudeManager { + fn drop(&mut self) { + unsafe { libc::kill(self.child_pid as i32, libc::SIGTERM); } } } diff --git a/src/claude.rs b/src/claude.rs new file mode 100644 index 0000000..ff7172d --- /dev/null +++ b/src/claude.rs @@ -0,0 +1,202 @@ +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq)] +pub enum StatusKind { + Idle, + Thinking, + Responding, + Tool(String), +} + +impl std::fmt::Display for StatusKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Idle => write!(f, "idle"), + Self::Thinking => write!(f, "thinking..."), + Self::Responding => write!(f, "responding..."), + Self::Tool(name) => write!(f, "running: {name}..."), + } + } +} + +/// Structured tool invocation details. +#[derive(Clone, Debug)] +pub struct ToolUse { + pub name: String, + pub input_summary: String, +} + +/// Events emitted while reading Claude's stream-json output. +#[derive(Debug)] +pub enum StreamEvent { + StreamStart, + Chunk(String), + StreamEnd, + Status(StatusKind), + /// A tool was invoked by the AI. + ToolInvoke(ToolUse), + /// A tool returned its result. + ToolResult { name: String, output: String }, +} + +pub fn spawn_claude(cwd: Option<&str>) -> Result<(Child, ChildStdin, ChildStdout), String> { + let mut cmd = Command::new("claude"); + cmd.arg("--input-format").arg("stream-json") + .arg("--output-format").arg("stream-json") + .arg("--verbose") + .arg("--dangerously-skip-permissions") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + + let mut child = cmd.spawn().map_err(|e| format!("failed to start claude: {e}"))?; + let stdin = child.stdin.take().ok_or("failed to capture stdin")?; + let stdout = child.stdout.take().ok_or("failed to capture stdout")?; + Ok((child, stdin, stdout)) +} + +pub fn send_message(stdin: &mut ChildStdin, input: &str) { + let msg = serde_json::json!({ + "type": "user", + "message": { "role": "user", "content": input } + }); + let _ = writeln!(stdin, "{}", msg); + let _ = stdin.flush(); +} + +pub fn spawn_reader(mut child: Child, stdout: ChildStdout, tx: mpsc::Sender) { + thread::spawn(move || { + let reader = BufReader::new(stdout); + let mut stream_started = false; + let mut last_text_len: usize = 0; + let mut last_tool_name = String::new(); + + for line in reader.lines().flatten() { + if line.trim().is_empty() { continue; } + let json: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + match json.get("type").and_then(|t| t.as_str()) { + Some("assistant") => { + let content = json.pointer("/message/content").and_then(|c| c.as_array()); + for item in content.into_iter().flatten() { + match item.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + if !stream_started { + let _ = tx.send(StreamEvent::StreamStart); + stream_started = true; + last_text_len = 0; + } + if text.len() > last_text_len { + if let Some(delta) = text.get(last_text_len..) { + let _ = tx.send(StreamEvent::Chunk(delta.to_string())); + } + last_text_len = text.len(); + } + } + let _ = tx.send(StreamEvent::Status(StatusKind::Responding)); + } + Some("tool_use") => { + let name = item.get("name") + .and_then(|n| n.as_str()) + .unwrap_or("tool") + .to_string(); + + let input_summary = extract_tool_summary(item); + + let display = if input_summary.is_empty() { + name.clone() + } else { + format!("{name}: {input_summary}") + }; + + let _ = tx.send(StreamEvent::ToolInvoke(ToolUse { + name: name.clone(), + input_summary, + })); + last_tool_name = name.clone(); + let _ = tx.send(StreamEvent::Status(StatusKind::Tool(display))); + } + _ => {} + } + } + } + // tool_result comes back as a "user" message + Some("user") => { + if let Some(content) = json.pointer("/message/content").and_then(|c| c.as_array()) { + for item in content { + if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") { + let output = item.get("content") + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string(); + let truncated = if output.len() > 500 { + let end = output.char_indices() + .take_while(|&(i, _)| i < 500) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(output.len()); + format!("{}...", &output[..end]) + } else { + output + }; + let _ = tx.send(StreamEvent::ToolResult { + name: last_tool_name.clone(), + output: truncated, + }); + } + } + } + } + Some("result") => { + if !stream_started { + if let Some(text) = json.get("result").and_then(|r| r.as_str()) { + if !text.is_empty() { + let _ = tx.send(StreamEvent::StreamStart); + let _ = tx.send(StreamEvent::Chunk(text.to_string())); + } + } + } + let _ = tx.send(StreamEvent::StreamEnd); + stream_started = false; + last_text_len = 0; + let _ = tx.send(StreamEvent::Status(StatusKind::Idle)); + } + _ => {} + } + } + let _ = child.wait(); + }); +} + +fn extract_tool_summary(item: &Value) -> String { + let input = match item.get("input") { + Some(inp) => inp, + None => return String::new(), + }; + + // Try common fields in priority order + for key in &["command", "pattern", "file_path", "content", "prompt", "skill"] { + if let Some(val) = input.get(*key).and_then(|v| v.as_str()) { + let truncated = if val.chars().count() > 80 { + let s: String = val.chars().take(80).collect(); + format!("{s}...") + } else { + val.to_string() + }; + return truncated; + } + } + String::new() +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..151b045 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,90 @@ +use serde::Deserialize; +use std::path::Path; + +#[derive(Clone, Deserialize)] +pub struct AgentConfig { + pub name: String, + pub task: String, + #[serde(default = "default_cwd")] + pub cwd: String, +} + +fn default_cwd() -> String { + std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string()) +} + +/// Built-in presets. +pub fn preset(name: &str) -> Option> { + let cwd = default_cwd(); + match name { + "daily" => Some(vec![ + AgentConfig { name: "health".into(), task: "Run cargo test and cargo build --release. Report: pass? warnings?".into(), cwd: cwd.clone() }, + AgentConfig { name: "quality".into(), task: "Find one unwrap() that could panic and one function over 50 lines. Give file:line.".into(), cwd: cwd.clone() }, + AgentConfig { name: "idea".into(), task: "Read docs/architecture.md and git log -10. Suggest one practical improvement.".into(), cwd }, + ]), + "review" => Some(vec![ + AgentConfig { name: "diff-review".into(), task: "Review the git diff. Report problems or say 'no issues'.".into(), cwd: cwd.clone() }, + AgentConfig { name: "commit-msg".into(), task: "Suggest a commit message for the current changes. Conventional commits format.".into(), cwd }, + ]), + "improve" => Some(vec![ + AgentConfig { name: "bug-hunt".into(), task: "Find one concrete bug in the codebase. Give file:line and a fix.".into(), cwd: cwd.clone() }, + AgentConfig { name: "simplify".into(), task: "Find one function that can be removed or simplified. Be specific.".into(), cwd }, + ]), + _ => None, + } +} + +#[derive(Deserialize)] +struct MultiConfig { + agents: Option>, + // Single agent at top level + 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) + } +} + +fn load_file(path: &Path) -> Vec { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + // Try as { "agents": [...] } or single { "name", "task", "cwd" } + if let Ok(multi) = serde_json::from_str::(&content) { + if let Some(agents) = multi.agents { + return agents; + } + if let (Some(name), Some(task), Some(cwd)) = (multi.name, multi.task, multi.cwd) { + return vec![AgentConfig { name, task, cwd }]; + } + } + + // Try as bare array [{ ... }, { ... }] + serde_json::from_str::>(&content).unwrap_or_default() +} + +fn load_dir(path: &Path) -> Vec { + let mut entries: Vec<_> = std::fs::read_dir(path) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + entries.iter().flat_map(|e| load_file(&e.path())).collect() +} diff --git a/src/executor.rs b/src/executor.rs index 5996896..789a88b 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -23,7 +23,7 @@ fn home_dir() -> Option { } fn handle_cd(input: &str) -> i32 { - let target = input.strip_prefix("cd").unwrap().trim(); + let target = input.strip_prefix("cd").unwrap_or(input).trim(); let dir: PathBuf = if target.is_empty() { match home_dir() { diff --git a/src/headless.rs b/src/headless.rs new file mode 100644 index 0000000..ebc5683 --- /dev/null +++ b/src/headless.rs @@ -0,0 +1,1034 @@ +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use crate::agent::Agent; +use crate::ai::{ClaudeManager, OutputEvent}; +use crate::config; + +const STATE_DIR: &str = "/tmp/aishell"; + +pub fn run(config_or_task: &str, cwd_override: Option<&str>, name_override: Option<&str>) -> Result<(), String> { + let _ = std::fs::remove_dir_all(STATE_DIR); + 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) + } else { + let cwd = cwd_override + .map(|s| s.to_string()) + .unwrap_or_else(|| std::env::current_dir() + .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 }] + }; + + if configs.is_empty() { + return Err(format!("no agents found in {config_or_task}")); + } + + if !is_multi { + return run_once(&configs); + } + + let running = Arc::new(AtomicBool::new(true)); + setup_ctrlc(running.clone()); + + let mut cycle = 1; + let mut saved_sessions: Vec = Vec::new(); + let mut current_configs = configs; + let mut prev_decision = String::new(); + + loop { + eprintln!("\n── cycle {} ──", cycle); + + let agents = spawn_and_wait(¤t_configs, &running); + if agents.is_empty() { break; } + write_state(&agents); + + // AI integration: skip if only 1 agent and no previous decision + let skip_ai = agents.len() <= 1 && prev_decision.is_empty(); + + atomic_write( + &format!("{STATE_DIR}/loop.json"), + b"{\"ready\":false}", + ); + + let decision = if skip_ai { + eprintln!("\n (AI integration skipped: single agent)"); + agents.first().map(|a| a.output.clone()).unwrap_or_default() + } else { + eprintln!("\n ◐ AI integrating..."); + match integrate_results(&agents, &prev_decision, cycle) { + Ok(d) => { + let data = serde_json::json!({ + "cycle": cycle, + "agents": agents.iter().map(|a| a.to_ai_json()).collect::>(), + "decision": &d, + }); + atomic_write( + &format!("{STATE_DIR}/decision.json"), + serde_json::to_string_pretty(&data).unwrap_or_default().as_bytes(), + ); + eprintln!(" ✓ Done\n"); + println!("{d}"); + d + } + Err(e) => { eprintln!(" ✗ {e}"); String::new() } + } + }; + + // Auto-save immediately after AI integration (before waiting) + let all_indices: Vec = (0..agents.len()).collect(); + let path = save_session(cycle, &agents, &decision, &all_indices); + eprintln!(" saved: {path}"); + saved_sessions.push(path); + + // Extract @agent commands from AI decision → next cycle's config + let next_configs = extract_agent_configs(&decision); + let has_next = !next_configs.is_empty(); + + if has_next { + eprintln!(" AI proposed {} agent(s) for next cycle:", next_configs.len()); + for c in &next_configs { + eprintln!(" @{} {} ({})", c.name, c.task, c.cwd); + } + } + + // 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, + } + + if !running.load(Ordering::Relaxed) { break; } + + // Prepare next cycle + prev_decision = decision; + if has_next { + current_configs = next_configs; + } + // If AI didn't propose new agents, reuse current config + cycle += 1; + } + + if !saved_sessions.is_empty() { + eprintln!("\nsessions saved:"); + for s in &saved_sessions { eprintln!(" {s}"); } + } + + Ok(()) +} + +pub fn run_preset(preset_name: &str) -> Result<(), String> { + let configs = config::preset(preset_name) + .ok_or_else(|| format!("unknown preset: {preset_name}"))?; + run_with_configs(configs) +} + +fn run_with_configs(configs: Vec) -> Result<(), String> { + let _ = std::fs::remove_dir_all(STATE_DIR); + create_state_dir(); + + let running = Arc::new(AtomicBool::new(true)); + setup_ctrlc(running.clone()); + + let mut cycle = 1; + let mut saved_sessions: Vec = Vec::new(); + let mut current_configs = configs; + let mut prev_decision = String::new(); + + loop { + eprintln!("\n── cycle {} ──", cycle); + let agents = spawn_and_wait(¤t_configs, &running); + if agents.is_empty() { break; } + write_state(&agents); + + let skip_ai = agents.len() <= 1 && prev_decision.is_empty(); + atomic_write(&format!("{STATE_DIR}/loop.json"), b"{\"ready\":false}"); + + let decision = if skip_ai { + eprintln!("\n (AI integration skipped: single agent)"); + agents.first().map(|a| a.output.clone()).unwrap_or_default() + } else { + eprintln!("\n ◐ AI integrating..."); + match integrate_results(&agents, &prev_decision, cycle) { + Ok(d) => { + let data = serde_json::json!({ + "cycle": cycle, + "agents": agents.iter().map(|a| a.to_ai_json()).collect::>(), + "decision": &d, + }); + atomic_write( + &format!("{STATE_DIR}/decision.json"), + serde_json::to_string_pretty(&data).unwrap_or_default().as_bytes(), + ); + eprintln!(" ✓ Done\n"); + println!("{d}"); + d + } + Err(e) => { eprintln!(" ✗ {e}"); String::new() } + } + }; + + let all_indices: Vec = (0..agents.len()).collect(); + let path = save_session(cycle, &agents, &decision, &all_indices); + eprintln!(" saved: {path}"); + saved_sessions.push(path); + + let next_configs = extract_agent_configs(&decision); + let has_next = !next_configs.is_empty(); + if has_next { + eprintln!(" AI proposed {} agent(s) for next cycle", next_configs.len()); + } + + write_loop_waiting(&agents, has_next); + eprintln!(" waiting..."); + + let action = wait_for_loop_signal(&running); + reset_loop_control(); + + match action { LoopAction::Next => {} LoopAction::Quit => break } + if !running.load(Ordering::Relaxed) { break; } + + prev_decision = decision; + if has_next { current_configs = next_configs; } + cycle += 1; + } + + if !saved_sessions.is_empty() { + eprintln!("\nsessions saved:"); + for s in &saved_sessions { eprintln!(" {s}"); } + } + Ok(()) +} + +fn run_once(configs: &[config::AgentConfig]) -> Result<(), String> { + let running = Arc::new(AtomicBool::new(true)); + setup_ctrlc(running.clone()); + let agents = spawn_and_wait(configs, &running); + if let Some(a) = agents.first() { + write_state(&agents); + println!("{}", strip_dir_listing(&a.output)); + } + Ok(()) +} + +/// Remove directory listing that Claude CLI prepends to output. +fn strip_dir_listing(text: &str) -> &str { + // Pattern: lines of single words (filenames) at the start, possibly starting with "." + let mut end = 0; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { end += line.len() + 1; continue; } + // If line looks like a filename (no spaces, short) + if !trimmed.contains(' ') && trimmed.len() < 80 { + end += line.len() + 1; + } else { + break; + } + } + text.get(end..).unwrap_or(text).trim_start_matches('\n') +} + +fn spawn_and_wait(configs: &[config::AgentConfig], running: &Arc) -> Vec { + let mut agents = Vec::new(); + let mut next_id = 1; + for cfg in configs { + let cwd = expand_tilde(&cfg.cwd); + match Agent::spawn(next_id, &cfg.name, &cfg.task, &cwd) { + Ok(agent) => { + eprintln!(" started: {} ({})", cfg.name, cwd); + agents.push(agent); + next_id += 1; + } + Err(e) => eprintln!(" failed: {}: {e}", cfg.name), + } + } + + let mut last_status: Vec = Vec::new(); + while running.load(Ordering::Relaxed) { + let mut any_running = false; + for agent in &mut agents { + agent.poll(); + if agent.is_running() { any_running = true; } + } + + if agents.iter().any(|a| a.dirty) { + write_state(&agents); + for a in &mut agents { a.dirty = false; } + } + + let current: Vec = agents.iter().map(|a| { + let icon = match a.conclusion() { + "success" => "✓", "error" | "stopped" => "✗", _ => "●", + }; + format!(" {icon} {:16} {:6} {}", a.name, a.elapsed(), a.status) + }).collect(); + if current != last_status { + for line in ¤t { eprintln!("{line}"); } + last_status = current; + } + + if !any_running { break; } + std::thread::sleep(Duration::from_millis(200)); + } + agents +} + +// ── File-based loop control ──────────────────────────────── + +enum LoopAction { + Next, + Quit, +} + +/// Write loop.json control file. External tools edit this to control the loop. +/// Extract @agent-name task -c cwd from AI decision text. +fn extract_agent_configs(text: &str) -> Vec { + text.lines() + .filter(|line| line.starts_with('@') && line.len() > 1) + .filter_map(|line| { + let rest = line[1..].trim(); + let mut parts = rest.splitn(2, char::is_whitespace); + let name = parts.next()?.to_string(); + let remainder = parts.next().unwrap_or("").to_string(); + + let (task, cwd) = if let Some(pos) = remainder.find(" -c ") { + (remainder[..pos].to_string(), remainder[pos + 4..].trim().to_string()) + } else { + (remainder, std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string())) + }; + + if name.is_empty() || task.is_empty() { return None; } + Some(config::AgentConfig { name, task, cwd }) + }) + .collect() +} + +/// Write loop.json: paused state with agent list for external control. +fn write_loop_waiting(agents: &[Agent], has_next: bool) { + let agents_list: Vec<_> = agents.iter().enumerate().map(|(i, a)| { + serde_json::json!({ + "id": i, + "name": a.name, + "conclusion": a.conclusion(), + "summary": a.summary(), + }) + }).collect(); + + let control = serde_json::json!({ + "ready": true, + "next": false, + "quit": false, + "save": null, + "has_next_agents": has_next, + "agents": agents_list, + }); + + atomic_write( + &format!("{STATE_DIR}/loop.json"), + serde_json::to_string_pretty(&control).unwrap_or_default().as_bytes(), + ); +} + +/// Reset loop.json after resuming: next=false, save=null. +fn reset_loop_control() { + let path = format!("{STATE_DIR}/loop.json"); + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(mut v) = serde_json::from_str::(&content) { + v["next"] = serde_json::json!(false); + v["save"] = serde_json::json!(null); + atomic_write(&path, serde_json::to_string_pretty(&v).unwrap_or_default().as_bytes()); + } + } +} + +/// Poll loop.json until `next: true` or `quit: true`. +/// Only reads signals when `ready: true` (set by write_loop_waiting). +fn wait_for_loop_signal(running: &Arc) -> LoopAction { + let path = format!("{STATE_DIR}/loop.json"); + loop { + if !running.load(Ordering::Relaxed) { + return LoopAction::Quit; + } + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&content) { + // Only process signals after ready=true was set + if v["ready"].as_bool() != Some(true) { + std::thread::sleep(Duration::from_millis(500)); + continue; + } + if v["quit"].as_bool() == Some(true) { + return LoopAction::Quit; + } + if v["next"].as_bool() == Some(true) { + return LoopAction::Next; + } + } + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +// ── Session persistence ──────────────────────────────────── + +fn save_session(cycle: usize, agents: &[Agent], decision: &str, indices: &[usize]) -> String { + let session_dir = session_base_dir(); + let _ = std::fs::create_dir_all(&session_dir); + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let filename = format!("{session_dir}/{timestamp}.json"); + + let saved_agents: Vec<_> = indices.iter() + .filter_map(|&i| agents.get(i).map(|a| a.to_json())) + .collect(); + + let session = serde_json::json!({ + "cycle": cycle, + "timestamp": timestamp, + "agents": saved_agents, + "decision": decision, + }); + + let json = serde_json::to_string_pretty(&session).unwrap_or_default(); + atomic_write(&filename, json.as_bytes()); + + if !decision.is_empty() { + save_to_aigpt_memory(decision, &saved_agents); + } + + // Update unified command history + update_cmd_history(&saved_agents, timestamp); + + filename +} + +/// Write a compressed summary to aigpt memory. +fn save_to_aigpt_memory(decision: &str, agents: &[serde_json::Value]) { + let agent_names: Vec<&str> = agents.iter() + .filter_map(|a| a["name"].as_str()) + .collect(); + + // Build a concise memory entry + let summary = format!( + "[aishell session] agents: {}\n{}", + agent_names.join(", "), + if decision.len() > 500 { + format!("{}...", &decision.chars().take(500).collect::()) + } else { + decision.to_string() + } + ); + + // Write to aigpt memory directory + if let Some(memory_dir) = aigpt_memory_dir() { + let _ = std::fs::create_dir_all(&memory_dir); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let record = serde_json::json!({ + "uri": format!("at://did/ai.syui.gpt.memory/{timestamp}"), + "value": { + "$type": "ai.syui.gpt.memory", + "content": { + "$type": "ai.syui.gpt.memory#markdown", + "text": summary + }, + "createdAt": format!("{}Z", chrono_now()) + } + }); + + let path = format!("{memory_dir}/{timestamp}.json"); + let json = serde_json::to_string_pretty(&record).unwrap_or_default(); + atomic_write(&path, json.as_bytes()); + } +} + +fn aigpt_memory_dir() -> Option { + let home = std::env::var("HOME").ok()?; + let config_path = if cfg!(target_os = "macos") { + format!("{home}/Library/Application Support/ai.syui.gpt/config.json") + } else { + format!("{home}/.config/ai.syui.gpt/config.json") + }; + + let content = std::fs::read_to_string(&config_path).ok()?; + let config: serde_json::Value = serde_json::from_str(&content).ok()?; + + let path = config["bot"]["path"].as_str()?; + let did = config["bot"]["did"].as_str()?; + let expanded = expand_tilde(path); + Some(format!("{expanded}/{did}/ai.syui.gpt.memory")) +} + +fn chrono_now() -> String { + // Simple ISO 8601 without chrono crate + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Days since epoch → date, remainder → time + let days = secs / 86400; + let rem = secs % 86400; + let h = rem / 3600; + let m = (rem % 3600) / 60; + let s = rem % 60; + // Approximate date from days since 1970-01-01 (good enough for memory records) + let (y, mo, d) = days_to_ymd(days); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}") +} + +fn days_to_ymd(mut days: u64) -> (u64, u64, u64) { + let mut y = 1970; + loop { + let dy = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 }; + if days < dy { break; } + days -= dy; + y += 1; + } + let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); + let mdays = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut mo = 0; + for &md in &mdays { + if days < md { break; } + days -= md; + mo += 1; + } + (y, mo + 1, days + 1) +} + +fn session_base_dir() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + if cfg!(target_os = "macos") { + format!("{home}/Library/Application Support/ai.syui.gpt/sessions") + } else { + format!("{home}/.local/share/aishell/sessions") + } +} + +// ── AI integration ───────────────────────────────────────── + +fn integrate_results(agents: &[Agent], prev_decision: &str, cycle: usize) -> Result { + let mut claude = ClaudeManager::spawn()?; + let data: Vec<_> = agents.iter().map(|a| a.to_ai_json()).collect(); + let payload = serde_json::to_string_pretty( + &serde_json::json!({ "event": "all_done", "cycle": cycle, "agents": data }) + ).unwrap_or_default(); + + let prev_context = if prev_decision.is_empty() { + String::new() + } else { + format!("前回のサイクルでの判断:\n{}\n\n---\n\n", prev_decision) + }; + + claude.send(&format!( + "{prev_context}以下はcycle {cycle}のエージェント実行結果です。\n\n```json\n{payload}\n```\n\n\ + 結果を統合し回答してください:\n\ + 1. 全体の結論(1行)\n\ + 2. 各エージェントの重要な発見(箇条書き)\n\ + 3. 次に取るべきアクション\n\n\ + 次のサイクルでエージェントを実行する場合は、以下の形式で指示してください:\n\ + @agent-name タスク内容 -c 作業ディレクトリ\n\ + 不要なら@agentは書かないでください。" + )); + + let mut output = String::new(); + let start = std::time::Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(60) { break; } + match claude.try_recv() { + Some(OutputEvent::StreamStart) => { output.clear(); } + Some(OutputEvent::StreamChunk(text)) => { output.push_str(&text); } + Some(OutputEvent::StreamEnd) => break, + None => {} + } + std::thread::sleep(Duration::from_millis(50)); + } + if output.is_empty() { Err("timeout".into()) } else { Ok(output) } +} + +// ── Status/Log/Decision commands ─────────────────────────── + +pub fn status(verbose: bool) { + let path = format!("{STATE_DIR}/agents.json"); + match std::fs::read_to_string(&path) { + Ok(content) => { + if let Ok(agents) = serde_json::from_str::>(&content) { + for a in &agents { + let id = a["id"].as_u64().unwrap_or(0); + let name = a["name"].as_str().unwrap_or("?"); + let c = a["conclusion"].as_str().unwrap_or("?"); + let elapsed = a["elapsed"].as_str().unwrap_or(""); + let task = a["task"].as_str().unwrap_or("?"); + let icon = match c { + "success" => "✓", "error" | "stopped" => "✗", + "running" => "●", _ => "○", + }; + println!(" {icon} {id} {name:16} {c:10} {elapsed:>6} {task}"); + if verbose { + if let Ok(d) = std::fs::read_to_string(format!("{STATE_DIR}/{id}.json")) { + if let Ok(d) = serde_json::from_str::(&d) { + let s = d["summary"].as_str().unwrap_or(""); + if !s.is_empty() { println!(" {s}"); } + } + } + } + } + } + } + Err(_) => println!("No session."), + } +} + +pub fn log(id_or_name: &str) { + let path = if id_or_name.chars().all(|c| c.is_ascii_digit()) { + format!("{STATE_DIR}/{id_or_name}.json") + } else { + find_agent_by_name(id_or_name).unwrap_or_default() + }; + match std::fs::read_to_string(&path) { + Ok(content) => { + if let Ok(a) = serde_json::from_str::(&content) { + let name = a["name"].as_str().unwrap_or("?"); + let c = a["conclusion"].as_str().unwrap_or("?"); + let elapsed = a["elapsed"].as_str().unwrap_or(""); + let task = a["task"].as_str().unwrap_or(""); + let result = a["result"].as_str().unwrap_or("(no output)"); + eprintln!("{name} [{c}] {elapsed}"); + eprintln!("task: {task}\n"); + println!("{result}"); + } + } + Err(_) => eprintln!("Agent '{id_or_name}' not found."), + } +} + +pub fn decision() { + let path = format!("{STATE_DIR}/decision.json"); + match std::fs::read_to_string(&path) { + Ok(content) => { + if let Ok(d) = serde_json::from_str::(&content) { + println!("{}", d["decision"].as_str().unwrap_or("No decision.")); + } + } + Err(_) => println!("No decision."), + } +} + +pub fn signal_next(save: Vec) { + let save_val = if save.is_empty() { + serde_json::json!(null) + } else { + serde_json::json!(save) + }; + let control = serde_json::json!({"ready": true, "next": true, "quit": false, "save": save_val}); + atomic_write( + &format!("{STATE_DIR}/loop.json"), + serde_json::to_string_pretty(&control).unwrap_or_default().as_bytes(), + ); + eprintln!("signaled: next (save: {:?})", save); +} + +pub fn signal_quit() { + let control = serde_json::json!({"ready": true, "quit": true}); + atomic_write( + &format!("{STATE_DIR}/loop.json"), + serde_json::to_string_pretty(&control).unwrap_or_default().as_bytes(), + ); + eprintln!("signaled: quit"); +} + +/// Update cmd_history.json: deduplicated command log across all agents. +fn update_cmd_history(agents: &[serde_json::Value], session_ts: u64) { + let history_path = format!("{}/cmd_history.json", session_base_dir()); + + // Load existing + let mut entries: Vec = std::fs::read_to_string(&history_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let now = chrono_now(); + + for agent in agents { + let agent_name = agent["name"].as_str().unwrap_or("?"); + let process = match agent["process"].as_array() { + Some(p) => p, + None => continue, + }; + + // Pair tool invocations with their results + let mut i = 0; + while i < process.len() { + let entry = &process[i]; + let kind = entry["type"].as_str().unwrap_or(""); + + if kind != "tool" { i += 1; continue; } + + let detail = entry["detail"].as_str().unwrap_or(""); + if detail.is_empty() { i += 1; continue; } + + let (tool, command) = match detail.split_once(": ") { + Some((t, c)) if t == "Bash" => (t, c), + _ => { i += 1; continue; } + }; + + // Check next entry for result — default ok=true, only false on explicit error + let ok = if i + 1 < process.len() { + let next = &process[i + 1]; + let nk = next["type"].as_str().unwrap_or(""); + if nk == "tool_result" { + let rd = next["detail"].as_str().unwrap_or(""); + !(rd.starts_with("Error") || rd.starts_with("error:") || rd.starts_with("FAILED") || rd.contains("exit code")) + } else { true } + } else { true }; + + let existing = entries.iter_mut().find(|e| { + e["tool"].as_str() == Some(tool) && e["command"].as_str() == Some(command) + }); + + if let Some(e) = existing { + e["updated_at"] = serde_json::json!(now); + e["agent"] = serde_json::json!(agent_name); + e["session"] = serde_json::json!(session_ts); + e["ok"] = serde_json::json!(ok); + } else { + entries.push(serde_json::json!({ + "tool": tool, + "command": command, + "agent": agent_name, + "session": session_ts, + "updated_at": now, + "ok": ok, + })); + } + + i += 1; + } + } + + let json = serde_json::to_string_pretty(&entries).unwrap_or_default(); + atomic_write(&history_path, json.as_bytes()); +} + +pub fn history(detail: Option<&str>) { + let dir = session_base_dir(); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .into_iter().flatten().flatten() + .filter(|e| { + let name = e.file_name(); + let s = name.to_string_lossy(); + s.ends_with(".json") && s != "cmd_history.json" + }) + .collect(); + files.sort_by_key(|e| e.file_name()); + + if files.is_empty() { + println!("No sessions."); + return; + } + + if let Some(id) = detail { + // Show detail of a specific session + let target = format!("{id}.json"); + let path = files.iter() + .find(|f| f.file_name().to_string_lossy().contains(id)) + .map(|f| f.path()) + .unwrap_or_else(|| std::path::PathBuf::from(format!("{dir}/{target}"))); + + match std::fs::read_to_string(&path) { + Ok(content) => { + if let Ok(d) = serde_json::from_str::(&content) { + let cycle = d["cycle"].as_u64().unwrap_or(0); + let agents = d["agents"].as_array(); + let decision = d["decision"].as_str().unwrap_or(""); + + println!("cycle {cycle}"); + if let Some(agents) = agents { + for a in agents { + let name = a["name"].as_str().unwrap_or("?"); + let c = a["conclusion"].as_str().unwrap_or("?"); + println!(" {name} [{c}]"); + } + } + if !decision.is_empty() { + println!("\n{decision}"); + } + } + } + Err(_) => eprintln!("Session '{id}' not found."), + } + return; + } + + // List all sessions + for f in &files { + let path = f.path(); + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(d) = serde_json::from_str::(&content) { + let ts = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("?"); + let cycle = d["cycle"].as_u64().unwrap_or(0); + let agents = d["agents"].as_array().map(|a| a.len()).unwrap_or(0); + let decision = d["decision"].as_str().unwrap_or(""); + let summary: String = decision.lines().next().unwrap_or("").chars().take(60).collect(); + println!(" {ts} cycle:{cycle} agents:{agents} {summary}"); + } + } + } +} + +pub fn context() { + let run = |cmd: &str, args: &[&str]| -> String { + std::process::Command::new(cmd).args(args) + .output().ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() + }; + + // Git + let branch = run("git", &["branch", "--show-current"]); + let git_status = run("git", &["status", "--short"]); + let git_log = run("git", &["log", "--oneline", "-5"]); + let git_diff = run("git", &["diff", "--stat"]); + + // Session info + let dec_path = format!("{STATE_DIR}/decision.json"); + let session_dir = session_base_dir(); + let session_count = std::fs::read_dir(&session_dir) + .into_iter().flatten().flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "json") && e.file_name().to_string_lossy() != "cmd_history.json") + .count(); + let cycle = std::fs::read_to_string(&dec_path).ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|d| d["cycle"].as_u64()) + .unwrap_or(0); + + println!("[session]"); + println!("sessions: {session_count} last_cycle: {cycle}"); + + println!("\n[git]"); + println!("branch: {branch}"); + if git_status.is_empty() { + println!("status: clean"); + } else { + println!("status:\n{git_status}"); + } + if !git_diff.is_empty() { + println!("diff:\n{git_diff}"); + } + println!("recent:\n{git_log}"); + + // Agents + println!("\n[agents]"); + let agent_path = format!("{STATE_DIR}/agents.json"); + if let Ok(content) = std::fs::read_to_string(&agent_path) { + if let Ok(agents) = serde_json::from_str::>(&content) { + if agents.is_empty() { + println!("(none)"); + } else { + for a in &agents { + let name = a["name"].as_str().unwrap_or("?"); + let c = a["conclusion"].as_str().unwrap_or("?"); + let summary = a["summary"].as_str().unwrap_or(""); + println!(" {name} [{c}] {summary}"); + } + } + } + } else { + println!("(no session)"); + } + + // Last decision + println!("\n[decision]"); + let dec_path = format!("{STATE_DIR}/decision.json"); + if let Ok(content) = std::fs::read_to_string(&dec_path) { + if let Ok(d) = serde_json::from_str::(&content) { + let text = d["decision"].as_str().unwrap_or(""); + // Skip markdown headers, show first substantive lines + let first_lines: String = text.lines() + .filter(|l| !l.starts_with('#') && !l.is_empty()) + .take(3) + .collect::>().join("\n"); + if first_lines.is_empty() { + println!("(none)"); + } else { + println!("{first_lines}"); + } + } + } else { + println!("(none)"); + } + + // Command history (last 10) + let history_path = format!("{}/cmd_history.json", session_base_dir()); + if let Ok(content) = std::fs::read_to_string(&history_path) { + if let Ok(entries) = serde_json::from_str::>(&content) { + if !entries.is_empty() { + println!("\n[commands]"); + for e in entries.iter().rev().take(10) { + let tool = e["tool"].as_str().unwrap_or("?"); + let cmd = e["command"].as_str().unwrap_or(""); + let ok = if e["ok"].as_bool() == Some(true) { "✓" } else { "✗" }; + println!(" {ok} {tool}: {cmd}"); + } + } + } + } +} + +/// Clean old sessions, keep last N. +pub fn history_clean() { + let dir = session_base_dir(); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .into_iter().flatten().flatten() + .filter(|e| { + let s = e.file_name().to_string_lossy().to_string(); + s.ends_with(".json") && s != "cmd_history.json" + }) + .collect(); + files.sort_by_key(|e| e.file_name()); + + let keep = 10; + if files.len() <= keep { + println!("nothing to clean ({} sessions)", files.len()); + return; + } + let remove = files.len() - keep; + for f in files.iter().take(remove) { + let _ = std::fs::remove_file(f.path()); + } + println!("removed {} old sessions, kept {}", remove, keep); +} + +pub fn history_cmd(filter: Option<&str>) { + show_cmd_history(filter); +} + +fn show_cmd_history(filter: Option<&str>) { + let path = format!("{}/cmd_history.json", session_base_dir()); + match std::fs::read_to_string(&path) { + Ok(content) => { + if let Ok(entries) = serde_json::from_str::>(&content) { + if entries.is_empty() { println!("No commands."); return; } + for e in &entries { + let cmd = e["command"].as_str().unwrap_or(""); + // Apply filter + if let Some(f) = filter { + if !cmd.contains(f) { continue; } + } + let agent = e["agent"].as_str().unwrap_or("?"); + let updated = e["updated_at"].as_str().unwrap_or(""); + let ok = if e["ok"].as_bool() == Some(true) { "✓" } else { "✗" }; + println!(" {ok} {agent:16} {updated} {cmd}"); + } + } + } + Err(_) => println!("No command history."), + } +} + +fn find_agent_by_name(name: &str) -> Option { + let content = std::fs::read_to_string(format!("{STATE_DIR}/agents.json")).ok()?; + let agents: Vec = serde_json::from_str(&content).ok()?; + agents.iter() + .find(|a| a["name"].as_str() == Some(name)) + .and_then(|a| a["id"].as_u64()) + .map(|id| format!("{STATE_DIR}/{id}.json")) +} + +// ── Infra ────────────────────────────────────────────────── + +fn setup_ctrlc(running: Arc) { + static RUNNING: AtomicBool = AtomicBool::new(true); + RUNNING.store(true, Ordering::SeqCst); + unsafe { + libc::signal(libc::SIGINT, handle_sigint as *const () as libc::sighandler_t); + } + std::thread::spawn(move || { + while running.load(Ordering::Relaxed) { + if !RUNNING.load(Ordering::SeqCst) { + running.store(false, Ordering::Relaxed); + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + }); + extern "C" fn handle_sigint(_: libc::c_int) { + RUNNING.store(false, Ordering::SeqCst); + } +} + +fn write_state(agents: &[Agent]) { + let summary: Vec<_> = agents.iter().map(|a| a.to_summary_json()).collect(); + atomic_write( + &format!("{STATE_DIR}/agents.json"), + serde_json::to_string_pretty(&serde_json::Value::Array(summary)).unwrap_or_default().as_bytes(), + ); + for a in agents { + atomic_write( + &format!("{STATE_DIR}/{}.json", a.id), + serde_json::to_string_pretty(&a.to_json()).unwrap_or_default().as_bytes(), + ); + } +} + +/// Atomic write: write to temp file then rename. +fn atomic_write(path: &str, content: &[u8]) { + let tmp = format!("{path}.tmp"); + #[cfg(unix)] { + use std::os::unix::fs::OpenOptionsExt; + let ok = std::fs::OpenOptions::new() + .write(true).create(true).truncate(true).mode(0o600) + .open(&tmp) + .and_then(|mut f| f.write_all(content)); + if ok.is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } + #[cfg(not(unix))] { + if std::fs::write(&tmp, content).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } +} + +fn create_state_dir() { + #[cfg(unix)] { + use std::os::unix::fs::DirBuilderExt; + let _ = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(STATE_DIR); + } + #[cfg(not(unix))] { + let _ = std::fs::create_dir_all(STATE_DIR); + } +} + +pub fn expand_tilde_pub(path: &str) -> String { expand_tilde(path) } + +fn expand_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix('~') { + if let Ok(home) = std::env::var("HOME") { + return format!("{home}{rest}"); + } + } + path.to_string() +} diff --git a/src/judge.rs b/src/judge.rs index 893ab4d..a09e3c1 100644 --- a/src/judge.rs +++ b/src/judge.rs @@ -103,7 +103,10 @@ fn is_variable_assignment(input: &str) -> bool { if name.contains(' ') { return false; } - let first = name.chars().next().unwrap(); + let first = match name.chars().next() { + Some(c) => c, + None => return false, + }; if !first.is_alphabetic() && first != '_' { return false; } diff --git a/src/lib.rs b/src/lib.rs index 39950cf..711fafc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,11 @@ +pub mod claude; +pub mod config; pub mod judge; pub mod executor; pub mod ai; pub mod status; pub mod completer; +pub mod agent; +pub mod tui; +pub mod headless; +pub mod watch; diff --git a/src/main.rs b/src/main.rs index cf9c574..ee4d37a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,61 +1,8 @@ use std::env; -use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; -use std::thread; -use std::time::Duration; -use std::io::{Write, stdout}; -use rustyline::error::ReadlineError; -use rustyline::{Editor, Config, CompletionType}; -use aishell::judge::{self, CommandCache}; -use aishell::executor; -use aishell::ai::{ClaudeManager, OutputEvent}; -use aishell::status::StatusBar; -use aishell::completer::ShellHelper; -fn show_accounts() { - let config_dir = if cfg!(target_os = "macos") { - env::var("HOME").ok().map(|h| format!("{}/Library/Application Support/ai.syui.log", h)) - } else { - env::var("XDG_CONFIG_HOME") - .or_else(|_| env::var("HOME").map(|h| format!("{}/.config", h))) - .ok() - .map(|c| format!("{}/ai.syui.log", c)) - }; - let config_dir = match config_dir { - Some(d) => d, - None => return, - }; - - let mut accounts = Vec::new(); - for (label, filename) in [("bot", "bot.json"), ("user", "token.json")] { - let path = format!("{}/{}", config_dir, filename); - if let Ok(data) = std::fs::read_to_string(&path) { - if let Ok(json) = serde_json::from_str::(&data) { - let handle = json.get("handle").and_then(|v| v.as_str()).unwrap_or("?"); - let did = json.get("did").and_then(|v| v.as_str()).unwrap_or("?"); - accounts.push(format!(" \x1b[2m{}\x1b[0m {} \x1b[2m({})\x1b[0m", label, handle, did)); - } - } - } - if !accounts.is_empty() { - println!("{}", accounts.join("\n")); - } -} - -fn prompt_string() -> String { - let cwd = env::current_dir() - .map(|p| { - if let Ok(home) = env::var("HOME") { - if let Some(rest) = p.to_str().and_then(|s| s.strip_prefix(&home)) { - if rest.is_empty() { - return "~".to_string(); - } - return format!("~{rest}"); - } - } - p.display().to_string() - }) - .unwrap_or_else(|_| "?".to_string()); - format!("{cwd} $ ") +fn parse_flag(flag: &str) -> Option { + let args: Vec = env::args().collect(); + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned()) } // ascii-image-converter -W 30 -b ai.png @@ -74,145 +21,129 @@ const LOGO: &str = "\ ⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⠿⠿⠿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀"; fn main() { - // aishell v → print version and exit - if let Some(arg) = env::args().nth(1) { - if arg == "v" || arg == "version" || arg == "--version" || arg == "-v" { + match env::args().nth(1).as_deref() { + Some("v" | "version" | "--version" | "-v") => { println!("{}", env!("CARGO_PKG_VERSION")); - return; } - } + Some("help" | "--help" | "-h") => print_help(), + None | Some("tui") => { + // Show logo before entering alternate screen + eprintln!("\x1b[38;5;226m{}\x1b[0m\n\x1b[1m aishell\x1b[0m v{}\n", + LOGO, env!("CARGO_PKG_VERSION")); - let status = Arc::new(std::sync::Mutex::new(StatusBar::new())); - println!("\x1b[38;5;226m{}\x1b[0m\n\x1b[1m aishell\x1b[0m v{}", LOGO, env!("CARGO_PKG_VERSION")); - show_accounts(); - println!(); - if let Ok(mut s) = status.lock() { s.set("starting claude..."); } - - let claude = match ClaudeManager::spawn() { - Ok(c) => c, - Err(e) => { - if let Ok(mut s) = status.lock() { s.cleanup(); } - eprintln!("aishell: {}", e); - std::process::exit(1); - } - }; - - let claude = Arc::new(std::sync::Mutex::new(claude)); - let cmd_cache = CommandCache::new(); - - if let Ok(mut s) = status.lock() { s.set("idle"); } - - // Background thread: poll for responses and update status bar - let claude_bg = claude.clone(); - let running = Arc::new(AtomicBool::new(true)); - let running_bg = running.clone(); - let status_bg = status.clone(); - thread::spawn(move || { - let mut in_stream = false; - while running_bg.load(Ordering::Relaxed) { - { - let cm = claude_bg.lock().unwrap(); - while let Some(event) = cm.try_recv() { - match event { - OutputEvent::StreamStart => { - print!("\r\x1b[2K\x1b[36m◆ "); - stdout().flush().ok(); - in_stream = true; - } - OutputEvent::StreamChunk(text) => { - print!("{}", text); - stdout().flush().ok(); - } - OutputEvent::StreamEnd => { - if in_stream { - print!("\x1b[0m\n"); - stdout().flush().ok(); - in_stream = false; - } - } - } - } - let claude_line = cm.status_line(); - if let Ok(mut s) = status_bg.lock() { - s.set(&claude_line); - } - } - thread::sleep(Duration::from_millis(50)); - } - }); - - let config = Config::builder() - .completion_type(CompletionType::List) - .build(); - let mut rl = match Editor::with_config(config) { - Ok(mut editor) => { - editor.set_helper(Some(ShellHelper::new())); - editor - } - Err(e) => { - running.store(false, Ordering::Relaxed); - eprintln!("aishell: failed to initialize: {}", e); - std::process::exit(1); - } - }; - - let history_path = env::var("HOME") - .map(|h| format!("{}/.aishell_history", h)) - .unwrap_or_else(|_| ".aishell_history".to_string()); - let _ = rl.load_history(&history_path); - - loop { - let prompt = prompt_string(); - match rl.readline(&prompt) { - Ok(line) => { - let input = line.trim(); - if input.is_empty() { - continue; - } - let _ = rl.add_history_entry(input); - if input == "exit" || input == "quit" { - break; - } - - // Prefix forcing: ! → shell, ? → AI - if let Some(rest) = input.strip_prefix('!') { - let rest = rest.trim(); - if !rest.is_empty() { - executor::execute(rest); - } - } else if let Some(rest) = input.strip_prefix('?') { - let rest = rest.trim(); - if !rest.is_empty() { - if let Ok(mut cm) = claude.lock() { - cm.send(rest); - } - } - } else if judge::is_command(input, &cmd_cache) { - executor::execute(input); - } else { - if let Ok(mut cm) = claude.lock() { - cm.send(input); - } - } - } - Err(ReadlineError::Interrupted) => { - if let Ok(mut cm) = claude.lock() { - if cm.is_busy() { - cm.cancel(); - println!("\x1b[33m(cancelled)\x1b[0m"); - } - } - continue; - } - Err(ReadlineError::Eof) => break, - Err(e) => { + let config_path = parse_flag("-f"); + if let Err(e) = aishell::tui::run(config_path.as_deref()) { eprintln!("aishell: {}", e); - break; + std::process::exit(1); } } + Some("run") => { + // -p preset + if let Some(preset_name) = parse_flag("-p") { + match aishell::config::preset(&preset_name) { + Some(_) => { + if let Err(e) = aishell::headless::run_preset(&preset_name) { + eprintln!("aishell: {e}"); + std::process::exit(1); + } + } + None => { + eprintln!("unknown preset: {preset_name}"); + eprintln!("available: daily, review, improve"); + std::process::exit(1); + } + } + } else { + let config_or_task = parse_flag("-f") + .or_else(|| { + let all: Vec = env::args().skip(2).collect(); + let mut parts = Vec::new(); + let mut skip = false; + for a in &all { + if skip { skip = false; continue; } + if a == "-c" || a == "-n" || a == "-f" || a == "-p" { skip = true; continue; } + parts.push(a.clone()); + } + if parts.is_empty() { None } else { Some(parts.join(" ")) } + }) + .unwrap_or_else(|| { + eprintln!("usage: aishell run "); + eprintln!(" aishell run -f "); + eprintln!(" aishell run -p (daily|review|improve)"); + std::process::exit(1); + }); + let cwd = parse_flag("-c"); + let name = parse_flag("-n"); + if let Err(e) = aishell::headless::run(&config_or_task, cwd.as_deref(), name.as_deref()) { + eprintln!("aishell: {e}"); + std::process::exit(1); + } + } + } + Some("status") => { + let verbose = env::args().any(|a| a == "-v" || a == "--verbose"); + aishell::headless::status(verbose); + } + Some("log") => { + let id = env::args().nth(2).unwrap_or_else(|| { + eprintln!("usage: aishell log "); + std::process::exit(1); + }); + aishell::headless::log(&id); + } + Some("decision") => aishell::headless::decision(), + Some("context") => aishell::headless::context(), + Some("history") => { + let sub = env::args().nth(2); + match sub.as_deref() { + Some("cmd") => { + let filter = env::args().nth(3); + aishell::headless::history_cmd(filter.as_deref()); + } + Some("clean") => aishell::headless::history_clean(), + other => aishell::headless::history(other), + } + } + Some("watch") => { + let dir = env::args().nth(2).unwrap_or_else(|| ".".to_string()); + let config = parse_flag("-f"); + if let Err(e) = aishell::watch::run(&dir, config.as_deref()) { + eprintln!("aishell: {}", e); + std::process::exit(1); + } + } + Some("next") => { + let save: Vec = env::args().skip(2) + .filter_map(|s| s.parse().ok()) + .collect(); + aishell::headless::signal_next(save); + } + Some("stop") => aishell::headless::signal_quit(), + Some(cmd) => { + eprintln!("unknown: {cmd}"); + eprintln!("try: aishell help"); + std::process::exit(1); + } } - - running.store(false, Ordering::Relaxed); - if let Ok(mut s) = status.lock() { s.cleanup(); } - let _ = rl.save_history(&history_path); +} + +fn print_help() { + println!("aishell v{}\n", env!("CARGO_PKG_VERSION")); + println!("USAGE:"); + println!(" aishell TUI (AI + Agents + Shell)"); + println!(" aishell run Run single agent"); + println!(" aishell run -p Preset: daily, review, improve"); + println!(" aishell run -f Custom config file"); + println!(" aishell status [-v] Show agent status"); + println!(" aishell log Show agent output"); + println!(" aishell decision Show AI integration result"); + println!(" aishell context Full project context (for AI)"); + println!(" aishell history [id] Show past sessions"); + println!(" aishell history cmd [grep] Command history (filterable)"); + println!(" aishell history clean Remove old sessions (keep 10)"); + println!(" aishell watch [-f cfg] Watch files, auto-trigger loop"); + println!(" aishell next Resume loop"); + println!(" aishell stop Stop the loop"); + println!(" aishell help This help"); + println!(" aishell v Version"); } diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..f2402ad --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,948 @@ +use std::io; +use std::time::Duration; +use crossterm::event::{self, Event, KeyCode, KeyModifiers, KeyEventKind}; +use ratatui::prelude::*; +use ratatui::widgets::*; +use crate::agent::{Agent, AgentStatus}; +use crate::ai::{ClaudeManager, OutputEvent}; +use crate::config::AgentConfig; +use crate::judge::{self, CommandCache}; + +const STATE_DIR: &str = "/tmp/aishell"; + +// ── App state ────────────────────────────────────────────── + +enum Mode { + Ai, + Agents, + InputTask, + InputCwd, + InputInstruct, +} + +/// AI autonomy state machine. +#[derive(PartialEq)] +enum AiPhase { + Idle, + WaitingForAgents, + Integrating, +} + +struct Message { + text: String, + is_error: bool, +} + +pub struct App { + claude: Option, + ai_output: String, + ai_input: String, + ai_status: String, + ai_scroll: u16, + ai_phase: AiPhase, + + agents: Vec, + selected: usize, + next_id: usize, + agent_scroll: u16, + + cmd_cache: CommandCache, + mode: Mode, + input: String, + input_task: String, + message: Option, + should_quit: bool, +} + +/// Protocol prompt. Personality comes from aigpt MCP. This only teaches @agent syntax. +const AI_IDENTITY: &str = "エージェント起動コマンド(応答に書くと自動実行される): +@name task -c dir +例: @test cargo test -c ~/ai/shell + +一言だけ挨拶してください。"; + +impl App { + fn new(configs: Vec) -> Self { + let claude = ClaudeManager::spawn().ok(); + let ai_status = if claude.is_some() { "starting..." } else { "not available" }; + + let mut app = Self { + claude, + ai_output: String::new(), + ai_input: String::new(), + ai_status: ai_status.to_string(), + ai_scroll: 0, + ai_phase: AiPhase::Idle, + agents: Vec::new(), + selected: 0, + next_id: 1, + agent_scroll: 0, + cmd_cache: CommandCache::new(), + mode: Mode::Ai, + input: String::new(), + input_task: String::new(), + message: None, + should_quit: false, + }; + + // Send protocol + project context + if let Some(ref mut claude) = app.claude { + let cwd = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string()); + let ctx = crate::agent::git_context(&cwd).unwrap_or_default(); + let msg = format!("{AI_IDENTITY}\n\n[project context]\n{ctx}"); + claude.send(&msg); + } + + let mut errors = Vec::new(); + for cfg in configs { + let cwd = expand_tilde(&cfg.cwd); + match Agent::spawn(app.next_id, &cfg.name, &cfg.task, &cwd) { + Ok(agent) => { + app.agents.push(agent); + app.next_id += 1; + } + Err(e) => errors.push(format!("{}: {e}", cfg.name)), + } + } + if !errors.is_empty() { + app.message = Some(Message { text: errors.join("; "), is_error: true }); + } + + create_state_dir(); + app + } + + fn poll_all(&mut self) { + let mut stream_ended = false; + + if let Some(ref mut claude) = self.claude { + while let Some(event) = claude.try_recv() { + match event { + OutputEvent::StreamStart => { + if !self.ai_output.is_empty() { + self.ai_output.push_str("\n---\n"); + } + } + OutputEvent::StreamChunk(text) => { + self.ai_output.push_str(&text); + self.ai_scroll = u16::MAX; + } + OutputEvent::StreamEnd => { + write_private( + &format!("{STATE_DIR}/ai.txt"), + self.ai_output.as_bytes(), + ); + stream_ended = true; + } + } + } + self.ai_status = claude.status_line(); + } + + // State machine: handle AI response completion + if stream_ended && self.ai_phase == AiPhase::Integrating { + let cmds = parse_agent_commands(&self.ai_output); + if !cmds.is_empty() { + // AI decided to spawn more agents → chain + for (name, task, cwd) in cmds { + if !self.agents.iter().any(|a| a.name == name) { + self.spawn_agent_direct(&name, &task, &cwd); + } + } + self.ai_phase = AiPhase::WaitingForAgents; + self.message = Some(Message { + text: "AI → agents spawned".into(), is_error: false, + }); + } else { + // AI decided no more agents needed → cycle complete + self.ai_phase = AiPhase::Idle; + self.message = Some(Message { + text: "cycle complete".into(), is_error: false, + }); + } + } else if stream_ended && self.ai_phase == AiPhase::Idle { + // Normal conversation — still check for @agent + let cmds = parse_agent_commands(&self.ai_output); + for (name, task, cwd) in cmds { + if !self.agents.iter().any(|a| a.name == name) { + self.spawn_agent_direct(&name, &task, &cwd); + self.ai_phase = AiPhase::WaitingForAgents; + } + } + } + + // Poll agents + let mut completed = Vec::new(); + for (i, agent) in self.agents.iter_mut().enumerate() { + let was_running = agent.is_running(); + agent.poll(); + if was_running && !agent.is_running() { + completed.push(i); + } + } + + // When all agents done → notify AI, enter Integrating phase + if !completed.is_empty() { + let all_done = !self.agents.iter().any(|a| a.is_running()); + if all_done && self.ai_phase == AiPhase::WaitingForAgents { + self.ai_phase = AiPhase::Integrating; + } + self.notify_ai_agents_done(&completed); + } + + // Detect initial agents from config completing + if self.ai_phase == AiPhase::Idle + && !self.agents.is_empty() + && !self.agents.iter().any(|a| a.is_running()) + { + // Config agents finished, start integration + self.ai_phase = AiPhase::Integrating; + } + + // Write state files + if self.agents.iter().any(|a| a.dirty) { + write_state(&self.agents); + for agent in &mut self.agents { + agent.dirty = false; + } + } + } + + /// Notify AI of completed agents. JSON only. + fn notify_ai_agents_done(&mut self, completed_indices: &[usize]) { + let all_done = !self.agents.iter().any(|a| a.is_running()); + + let data: Vec<_> = if all_done { + self.agents.iter().map(|a| a.to_ai_json()).collect() + } else { + completed_indices.iter() + .filter_map(|&i| self.agents.get(i).map(|a| a.to_ai_json())) + .collect() + }; + + let event = if all_done { "all_done" } else { "agent_done" }; + let payload = serde_json::json!({ + "event": event, + "agents": data, + }); + + let instruction = if all_done { + "全エージェントが完了しました。結果を統合し、以下を判断してください:\n1. 全体の結論(成功/失敗/要対応)\n2. 重要な発見のまとめ\n3. 次に取るべきアクションの提案" + } else { + "エージェントが完了しました。" + }; + + let msg = format!( + "```json\n{}\n```\n\n{instruction}", + serde_json::to_string_pretty(&payload).unwrap_or_default() + ); + if let Some(ref mut claude) = self.claude { + claude.send(&msg); + } + } + + /// Share selected agent's data with AI. JSON only. + fn share_with_ai(&mut self) { + let (name, data) = match self.agents.get(self.selected) { + Some(a) if !a.output.is_empty() => (a.name.clone(), a.to_ai_json()), + _ => return, + }; + let msg = format!( + "```json\n{}\n```\n\nこのエージェントの結果を分析し、次に何をすべきか提案してください。", + serde_json::to_string_pretty(&data).unwrap_or_default() + ); + if let Some(ref mut claude) = self.claude { + claude.send(&msg); + } + self.message = Some(Message { + text: format!("{name} を共有しました"), + is_error: false, + }); + self.mode = Mode::Ai; + } + + fn send_to_ai(&mut self) { + if self.ai_input.is_empty() { return; } + let input = std::mem::take(&mut self.ai_input); + + // @name task [-c cwd] → spawn agent + if let Some(rest) = input.strip_prefix('@') { + let (name, task, cwd) = parse_at_command(rest); + if !name.is_empty() && !task.is_empty() { + self.spawn_agent_direct(&name, &task, &cwd); + } + return; + } + // !command → forced shell + if let Some(cmd) = input.strip_prefix('!') { + let cmd = cmd.trim(); + if !cmd.is_empty() { self.run_shell(cmd); } + return; + } + // ?question → forced AI (skip command detection) + if let Some(rest) = input.strip_prefix('?') { + let input = rest.trim().to_string(); + if input.is_empty() { return; } + let msg = if self.agents.is_empty() { input } + else { format!("{input}\n\n```json\n{}\n```", self.agents_context_json()) }; + if let Some(ref mut claude) = self.claude { claude.send(&msg); } + return; + } + // Auto-detect: command → shell, otherwise → AI + if judge::is_command(&input, &self.cmd_cache) { + self.run_shell(&input); + return; + } + + let msg = if self.agents.is_empty() { + input + } else { + let context = self.agents_context_json(); + format!("{input}\n\n```json\n{context}\n```") + }; + + if let Some(ref mut claude) = self.claude { + claude.send(&msg); + } + } + + fn spawn_agent_direct(&mut self, name: &str, task: &str, cwd: &str) { + let cwd = if cwd.is_empty() { + std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string()) + } else { + expand_tilde(cwd) + }; + match Agent::spawn(self.next_id, name, task, &cwd) { + Ok(agent) => { + self.agents.push(agent); + self.selected = self.agents.len() - 1; + self.next_id += 1; + self.message = Some(Message { + text: format!("agent started: {name}"), + is_error: false, + }); + } + Err(e) => { + self.message = Some(Message { text: e, is_error: true }); + } + } + } + + fn run_shell(&mut self, cmd: &str) { + let output = match std::process::Command::new("sh") + .arg("-c").arg(cmd).output() + { + Ok(o) => { + let mut s = String::from_utf8_lossy(&o.stdout).to_string(); + let err = String::from_utf8_lossy(&o.stderr); + if !err.is_empty() { s.push_str(&err); } + s + } + Err(e) => format!("error: {e}\n"), + }; + if !self.ai_output.is_empty() { + self.ai_output.push_str("\n---\n"); + } + self.ai_output.push_str(&format!("$ {cmd}\n{output}")); + self.ai_scroll = u16::MAX; + } + + /// Build JSON context of all agents for AI. + fn agents_context_json(&self) -> String { + let agents: Vec<_> = self.agents.iter().map(|a| a.to_ai_json()).collect(); + let payload = serde_json::json!({ + "event": "context", + "agents": agents, + }); + serde_json::to_string_pretty(&payload).unwrap_or_default() + } + + fn next(&mut self) { + if !self.agents.is_empty() { + self.selected = (self.selected + 1) % self.agents.len(); + } + } + + fn previous(&mut self) { + if !self.agents.is_empty() { + self.selected = self.selected.checked_sub(1).unwrap_or(self.agents.len() - 1); + } + } + + fn spawn_agent(&mut self) { + let cwd = if self.input.is_empty() { + std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string()) + } else { + expand_tilde(&self.input) + }; + + let name = format!("agent-{}", self.next_id); + match Agent::spawn(self.next_id, &name, &self.input_task, &cwd) { + Ok(agent) => { + self.agents.push(agent); + self.selected = self.agents.len() - 1; + self.next_id += 1; + self.message = None; + } + Err(e) => { + self.message = Some(Message { text: e, is_error: true }); + } + } + self.input.clear(); + self.input_task.clear(); + self.mode = Mode::Agents; + } + + fn send_to_agent(&mut self) { + if self.input.is_empty() { return; } + let input = std::mem::take(&mut self.input); + if let Some(agent) = self.agents.get_mut(self.selected) { + agent.send(&input); + } + self.mode = Mode::Agents; + } + + fn stop_selected(&mut self) { + if let Some(agent) = self.agents.get_mut(self.selected) { + agent.stop(); + } + } + + fn remove_selected(&mut self) { + if let Some(agent) = self.agents.get(self.selected) { + if !agent.is_running() { + self.agents.remove(self.selected); + if self.selected >= self.agents.len() && self.selected > 0 { + self.selected -= 1; + } + } else { + self.message = Some(Message { + text: "stop first (d)".to_string(), + is_error: true, + }); + } + } + } + + fn handle_key(&mut self, key: event::KeyEvent) { + if key.kind != KeyEventKind::Press { return; } + + // Clear message on any keypress + self.message = None; + + match self.mode { + Mode::Ai => match key.code { + KeyCode::Tab => self.mode = Mode::Agents, + KeyCode::Enter if self.ai_input.is_empty() && !self.ai_output.is_empty() => { + return; // handled by caller (open editor) + } + KeyCode::Enter => self.send_to_ai(), + KeyCode::Backspace => { self.ai_input.pop(); } + KeyCode::PageUp => { self.ai_scroll = self.ai_scroll.saturating_sub(10); } + KeyCode::PageDown => { self.ai_scroll = self.ai_scroll.saturating_add(10); } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.ai_scroll = self.ai_scroll.saturating_sub(10); + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.ai_scroll = self.ai_scroll.saturating_add(10); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(ref mut claude) = self.claude { + if claude.is_busy() { claude.cancel(); } + else { self.should_quit = true; } + } else { + self.should_quit = true; + } + } + KeyCode::Char(c) => self.ai_input.push(c), + _ => {} + }, + Mode::Agents => match key.code { + KeyCode::Tab => self.mode = Mode::Ai, + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::PageUp => { self.agent_scroll = self.agent_scroll.saturating_sub(10); } + KeyCode::PageDown => { self.agent_scroll = self.agent_scroll.saturating_add(10); } + KeyCode::Char('j') | KeyCode::Down => { self.next(); self.agent_scroll = u16::MAX; } + KeyCode::Char('k') | KeyCode::Up => { self.previous(); self.agent_scroll = u16::MAX; } + KeyCode::Char('n') => { + self.mode = Mode::InputTask; + self.input.clear(); + } + KeyCode::Char('d') => self.stop_selected(), + KeyCode::Char('x') => self.remove_selected(), + KeyCode::Char('a') => { + if self.agents.get(self.selected).is_some() { + self.mode = Mode::InputInstruct; + self.input.clear(); + } + } + KeyCode::Char('s') => self.share_with_ai(), + KeyCode::Enter => return, // handled by caller (editor) + _ => {} + }, + Mode::InputTask => match key.code { + KeyCode::Esc => { self.mode = Mode::Agents; self.input.clear(); } + KeyCode::Enter if !self.input.is_empty() => { + self.input_task = std::mem::take(&mut self.input); + self.mode = Mode::InputCwd; + } + KeyCode::Backspace => { self.input.pop(); } + KeyCode::Char(c) => self.input.push(c), + _ => {} + }, + Mode::InputCwd => match key.code { + KeyCode::Esc => { + self.mode = Mode::Agents; + self.input.clear(); + self.input_task.clear(); + } + KeyCode::Enter => self.spawn_agent(), + KeyCode::Backspace => { self.input.pop(); } + KeyCode::Char(c) => self.input.push(c), + _ => {} + }, + Mode::InputInstruct => match key.code { + KeyCode::Esc => { self.mode = Mode::Agents; self.input.clear(); } + KeyCode::Enter if !self.input.is_empty() => self.send_to_agent(), + KeyCode::Backspace => { self.input.pop(); } + KeyCode::Char(c) => self.input.push(c), + _ => {} + }, + } + } + + fn selected_agent_name(&self) -> Option<&str> { + self.agents.get(self.selected).map(|a| a.name.as_str()) + } +} + +// ── Helpers ──────────────────────────────────────────────── + +fn write_private(path: &str, content: &[u8]) { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::OpenOptions::new() + .write(true).create(true).truncate(true) + .mode(0o600) + .open(path) + .and_then(|mut f| std::io::Write::write_all(&mut f, content)); + } + #[cfg(not(unix))] + { + let _ = std::fs::write(path, content); + } +} + +fn create_state_dir() { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let _ = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(STATE_DIR); + } + #[cfg(not(unix))] + { + let _ = std::fs::create_dir_all(STATE_DIR); + } +} + +fn write_state(agents: &[Agent]) { + let summary: Vec<_> = agents.iter().map(|a| a.to_summary_json()).collect(); + let json = serde_json::to_string_pretty(&serde_json::Value::Array(summary)).unwrap_or_default(); + write_private(&format!("{STATE_DIR}/agents.json"), json.as_bytes()); + + for a in agents { + if !a.dirty { continue; } + let json = serde_json::to_string_pretty(&a.to_json()).unwrap_or_default(); + write_private(&format!("{STATE_DIR}/{}.json", a.id), json.as_bytes()); + } +} + +/// Parse `@name task [-c cwd]` from user input. +fn parse_at_command(input: &str) -> (String, String, String) { + let input = input.trim(); + let mut parts = input.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or("").to_string(); + let rest = parts.next().unwrap_or("").to_string(); + + // Extract -c flag from rest + let (task, cwd) = if let Some(pos) = rest.find(" -c ") { + (rest[..pos].to_string(), rest[pos + 4..].trim().to_string()) + } else { + (rest, String::new()) + }; + (name, task, cwd) +} + +/// Extract `@name task` lines from AI response text. +fn parse_agent_commands(text: &str) -> Vec<(String, String, String)> { + text.lines() + .filter(|line| line.starts_with('@') && line.len() > 1) + .map(|line| parse_at_command(&line[1..])) + .filter(|(name, task, _)| !name.is_empty() && !task.is_empty()) + .collect() +} + +fn expand_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix('~') { + if let Ok(home) = std::env::var("HOME") { + return format!("{home}{rest}"); + } + } + path.to_string() +} + +// ── Editor ───────────────────────────────────────────────── + +fn open_text_in_editor(text: &str, label: &str) -> io::Result<()> { + let tmp = format!("/tmp/aishell_{label}.md"); + std::fs::write(&tmp, text)?; + crossterm::terminal::disable_raw_mode()?; + crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?; + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); + let _ = std::process::Command::new(&editor).arg(&tmp).status(); + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?; + Ok(()) +} + +fn open_in_editor(agent: &Agent) -> io::Result<()> { + let tmp = format!("/tmp/aishell_agent_{}.md", agent.id); + std::fs::write(&tmp, &agent.output)?; + + crossterm::terminal::disable_raw_mode()?; + crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?; + + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); + let _ = std::process::Command::new(&editor).arg(&tmp).status(); + + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?; + Ok(()) +} + +// ── Rendering ────────────────────────────────────────────── + +fn ui(frame: &mut Frame, app: &App) { + let outer = Layout::vertical([ + Constraint::Percentage(35), + Constraint::Min(6), + Constraint::Length(1), + ]).split(frame.area()); + + render_ai_section(frame, app, outer[0]); + render_agents_section(frame, app, outer[1]); + render_footer(frame, app, outer[2]); + + match app.mode { + Mode::InputTask | Mode::InputCwd => render_popup(frame, app, "New Agent"), + Mode::InputInstruct => { + let title = match app.selected_agent_name() { + Some(name) => format!("Instruct: {name}"), + None => "Instruct".to_string(), + }; + render_popup(frame, app, &title); + } + _ => {} + } +} + +fn render_ai_section(frame: &mut Frame, app: &App, area: Rect) { + let layout = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(3), + ]).split(area); + + let ai_focused = matches!(app.mode, Mode::Ai); + // Show busy color even when unfocused + let ai_color = ai_status_color(app.ai_status.as_str(), ai_focused); + let border_style = Style::default().fg(ai_color); + + let (ai_icon, ai_label_color) = ai_status_icon(app.ai_status.as_str()); + let title_spans = vec![ + Span::raw(" "), + Span::styled(format!("{ai_icon} "), Style::default().fg(ai_label_color)), + Span::styled("AI", Style::default().fg(ai_color).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {} ", app.ai_status), Style::default().fg(ai_label_color)), + ]; + + let display_text = if app.ai_output.is_empty() { + String::new() + } else { + app.ai_output.clone() + }; + let text_style = if app.ai_output.is_empty() { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + + let inner_h = layout[0].height.saturating_sub(2) as usize; + let max_scroll = display_text.lines().count().saturating_sub(inner_h) as u16; + let scroll = app.ai_scroll.min(max_scroll); + + let output = Paragraph::new(display_text) + .block(Block::default().borders(Borders::ALL).title(Line::from(title_spans)).border_style(border_style)) + .style(text_style) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); + frame.render_widget(output, layout[0]); + + let input_line = Line::from(vec![ + Span::styled(" > ", Style::default().fg(ai_color)), + Span::raw(&app.ai_input), + if ai_focused { Span::styled("_", Style::default().fg(ai_color)) } + else { Span::raw("") }, + ]); + + let input_block = Paragraph::new(input_line) + .block(Block::default().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM).border_style(border_style)); + frame.render_widget(input_block, layout[1]); +} + +fn render_agents_section(frame: &mut Frame, app: &App, area: Rect) { + let agents_focused = matches!(app.mode, Mode::Agents); + let panes = Layout::horizontal([ + Constraint::Percentage(30), + Constraint::Percentage(70), + ]).split(area); + + let items: Vec = app.agents.iter().map(|a| { + let (icon, color) = status_icon(&a.status); + ListItem::new(Line::from(vec![ + Span::styled(format!(" {icon} "), Style::default().fg(color)), + Span::styled(&a.name, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled(a.status.to_string(), Style::default().fg(color)), + ])) + }).collect(); + + let list_border = if agents_focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + let mut state = ListState::default().with_selected(Some(app.selected)); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Agents ").border_style(list_border)) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("▶"); + frame.render_stateful_widget(list, panes[0], &mut state); + + render_agent_output(frame, app, panes[1]); +} + +fn render_agent_output(frame: &mut Frame, app: &App, area: Rect) { + let (title_line, body, color) = if let Some(agent) = app.agents.get(app.selected) { + let (icon, c) = status_icon(&agent.status); + let title = Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{icon} "), Style::default().fg(c)), + Span::styled(&agent.name, Style::default().fg(c).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {} ", agent.cwd), Style::default().fg(Color::DarkGray)), + ]); + let body = if agent.output.is_empty() { + format!(" Task: {}", agent.task) + } else { + agent.output.clone() + }; + (title, body, c) + } else { + (Line::from(" Output "), " n: new agent".to_string(), Color::DarkGray) + }; + + let inner_h = area.height.saturating_sub(2) as usize; + let max_scroll = body.lines().count().saturating_sub(inner_h) as u16; + let scroll = app.agent_scroll.min(max_scroll); + + let para = Paragraph::new(body) + .block(Block::default().borders(Borders::ALL).title(title_line) + .border_style(Style::default().fg(color))) + .style(Style::default().fg(Color::White)) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); + frame.render_widget(para, area); +} + +fn status_icon(status: &AgentStatus) -> (&str, Color) { + match status { + AgentStatus::Thinking => ("◐", Color::Yellow), + AgentStatus::Responding => ("●", Color::Cyan), + AgentStatus::Tool(_) => ("⚙", Color::Magenta), + AgentStatus::Done => ("✓", Color::Green), + AgentStatus::Error(_) => ("✗", Color::Red), + } +} + +fn ai_status_color(status: &str, focused: bool) -> Color { + match status { + "not available" => Color::DarkGray, + s if s.contains("starting") => Color::Green, + "idle" => if focused { Color::Yellow } else { Color::DarkGray }, + s if s.contains("thinking") => Color::Blue, + s if s.contains("responding") => Color::Cyan, + s if s.contains("running") => Color::Magenta, + _ => if focused { Color::Yellow } else { Color::DarkGray }, + } +} + +fn ai_status_icon(status: &str) -> (&str, Color) { + match status { + s if s.contains("starting") => ("○", Color::Green), + "idle" => ("●", Color::Yellow), + s if s.contains("thinking") => ("◐", Color::Blue), + s if s.contains("responding") => ("◆", Color::Cyan), + s if s.contains("running") => ("⚙", Color::Magenta), + _ => ("○", Color::DarkGray), + } +} + +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + let text = if let Some(ref msg) = app.message { + let color = if msg.is_error { Color::Red } else { Color::Green }; + Line::from(Span::styled(format!(" {}", msg.text), Style::default().fg(color))) + } else { + match app.mode { + Mode::Ai => Line::from(vec![ + Span::styled(" Enter", Style::default().fg(Color::Cyan)), + Span::raw(":send "), + Span::styled("PgUp/Dn", Style::default().fg(Color::Cyan)), + Span::raw(":scroll "), + Span::styled("Tab", Style::default().fg(Color::Cyan)), + Span::raw(":agents "), + Span::styled("C-c", Style::default().fg(Color::Cyan)), + Span::raw(":cancel/quit"), + ]), + Mode::Agents => Line::from(vec![ + Span::styled(" n", Style::default().fg(Color::Cyan)), + Span::raw(":new "), + Span::styled("a", Style::default().fg(Color::Cyan)), + Span::raw(":instruct "), + Span::styled("s", Style::default().fg(Color::Cyan)), + Span::raw(":share "), + Span::styled("d", Style::default().fg(Color::Cyan)), + Span::raw(":stop "), + Span::styled("x", Style::default().fg(Color::Cyan)), + Span::raw(":remove "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(":vim "), + Span::styled("Tab", Style::default().fg(Color::Cyan)), + Span::raw(":ai "), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(":quit"), + ]), + _ => Line::from(vec![ + Span::styled(" Enter", Style::default().fg(Color::Cyan)), + Span::raw(":ok "), + Span::styled("Esc", Style::default().fg(Color::Cyan)), + Span::raw(":cancel"), + ]), + } + }; + frame.render_widget(Paragraph::new(text), area); +} + +fn render_popup(frame: &mut Frame, app: &App, title: &str) { + let area = frame.area(); + let w = 60u16.min(area.width.saturating_sub(4)); + let h = 5u16; + let popup = Rect::new( + (area.width.saturating_sub(w)) / 2, + (area.height.saturating_sub(h)) / 2, + w, h, + ); + + frame.render_widget(Clear, popup); + + let label = match app.mode { + Mode::InputTask => "Task", + Mode::InputCwd => "Dir (empty=cwd)", + Mode::InputInstruct => "Input", + _ => "", + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let content = Line::from(vec![ + Span::styled(format!("{label}: "), Style::default().fg(Color::Yellow)), + Span::raw(&app.input), + Span::styled("_", Style::default().fg(Color::Cyan)), + ]); + frame.render_widget(Paragraph::new(content), inner); +} + +// ── Entry point ──────────────────────────────────────────── + +pub fn run(config_path: Option<&str>) -> io::Result<()> { + let configs = config_path + .map(|p| crate::config::load(p)) + .unwrap_or_default(); + + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?; + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(configs); + + loop { + terminal.draw(|f| ui(f, &app))?; + + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press && key.code == KeyCode::Enter { + // AI mode: Enter with empty input → open AI output in editor + if matches!(app.mode, Mode::Ai) + && app.ai_input.is_empty() + && !app.ai_output.is_empty() + { + let _ = open_text_in_editor(&app.ai_output, "ai"); + terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + continue; + } + // Agents mode: Enter → open agent output in editor + if matches!(app.mode, Mode::Agents) { + if let Some(agent) = app.agents.get(app.selected) { + if !agent.output.is_empty() { + let _ = open_in_editor(agent); + terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + } + } + continue; + } + } + app.handle_key(key); + } + } + + app.poll_all(); + + if app.should_quit { break; } + } + + for agent in &mut app.agents { + agent.stop(); + } + + let _ = std::fs::remove_dir_all(STATE_DIR); + + crossterm::terminal::disable_raw_mode()?; + crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?; + Ok(()) +} diff --git a/src/watch.rs b/src/watch.rs new file mode 100644 index 0000000..8b353fa --- /dev/null +++ b/src/watch.rs @@ -0,0 +1,110 @@ +use std::path::Path; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use notify::{Watcher, RecursiveMode, Event, EventKind}; + +const STATE_DIR: &str = "/tmp/aishell"; +const DEBOUNCE_MS: u64 = 2000; + +/// Watch a directory for file changes. +/// On change, write next=true to loop.json with changed file list. +pub fn run(dir: &str, config: Option<&str>) -> Result<(), String> { + let dir = crate::headless::expand_tilde_pub(dir); + let dir_path = Path::new(&dir); + if !dir_path.is_dir() { + return Err(format!("not a directory: {dir}")); + } + + // Start headless run in background if config provided + if let Some(cfg) = config { + let cfg = cfg.to_string(); + std::thread::spawn(move || { + let _ = crate::headless::run(&cfg, None, None); + }); + // Give it time to start + std::thread::sleep(Duration::from_secs(2)); + } + + eprintln!("watching: {dir}"); + eprintln!("Ctrl+C to stop.\n"); + + let (tx, rx) = mpsc::channel::(); + + let mut watcher = notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }).map_err(|e| format!("watcher error: {e}"))?; + + watcher.watch(dir_path, RecursiveMode::Recursive) + .map_err(|e| format!("watch error: {e}"))?; + + let mut last_trigger = Instant::now(); + let mut changed_files: Vec = Vec::new(); + + loop { + match rx.recv_timeout(Duration::from_millis(500)) { + Ok(event) => { + if !is_relevant(&event) { continue; } + + for path in &event.paths { + let p = path.display().to_string(); + if !changed_files.contains(&p) { + changed_files.push(p); + } + } + last_trigger = Instant::now(); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // Debounce: if we have changes and enough time passed + if !changed_files.is_empty() + && last_trigger.elapsed() > Duration::from_millis(DEBOUNCE_MS) + { + trigger_cycle(&changed_files); + changed_files.clear(); + } + } + Err(_) => break, + } + } + + Ok(()) +} + +fn is_relevant(event: &Event) -> bool { + matches!(event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) && event.paths.iter().any(|p| { + let s = p.display().to_string(); + // Ignore hidden files, target/, /tmp/ + !s.contains("/.") && !s.contains("/target/") && !s.contains("/tmp/") + }) +} + +fn trigger_cycle(files: &[String]) { + let short: Vec<&str> = files.iter() + .map(|f| f.rsplit('/').next().unwrap_or(f)) + .take(5) + .collect(); + + eprintln!(" changed: {}", short.join(", ")); + + // Write to loop.json to signal the running headless loop + let loop_path = format!("{STATE_DIR}/loop.json"); + if Path::new(&loop_path).exists() { + let control = serde_json::json!({ + "ready": true, + "next": true, + "quit": false, + "changed_files": files, + }); + let json = serde_json::to_string_pretty(&control).unwrap_or_default(); + // atomic write + let tmp = format!("{loop_path}.tmp"); + if std::fs::write(&tmp, &json).is_ok() { + let _ = std::fs::rename(&tmp, &loop_path); + } + } else { + eprintln!(" (no active loop to signal)"); + } +}