2
0
This commit is contained in:
2026-03-24 11:43:54 +09:00
parent 9e9b974051
commit da1159cbf9
13 changed files with 2877 additions and 304 deletions

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ Thumbs.db
/.claude
/claude.md
/CLAUDE.md
/docs
/example
/config

View File

@@ -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"

327
src/agent.rs Normal file
View File

@@ -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<ProcessEntry>,
pub tools_used: HashSet<String>,
pub files_read: HashSet<String>,
pub commands_run: Vec<String>,
rx: mpsc::Receiver<StreamEvent>,
stdin: std::process::ChildStdin,
pid: u32,
stopped: bool,
}
impl Agent {
pub fn spawn(id: usize, name: &str, task: &str, cwd: &str) -> Result<Self, String> {
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<String> {
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<char> = 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}")
}

140
src/ai.rs
View File

@@ -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<Mutex<String>>,
output_rx: mpsc::Receiver<OutputEvent>,
status: StatusKind,
output_rx: mpsc::Receiver<StreamEvent>,
child_pid: u32,
}
impl ClaudeManager {
pub fn spawn() -> Result<Self, String> {
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<OutputEvent>,
state: Arc<Mutex<String>>,
) {
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<Mutex<String>>, 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<OutputEvent> {
self.output_rx.try_recv().ok()
pub fn try_recv(&mut self) -> Option<OutputEvent> {
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); }
}
}

202
src/claude.rs Normal file
View File

@@ -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<StreamEvent>) {
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()
}

90
src/config.rs Normal file
View File

@@ -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<Vec<AgentConfig>> {
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<Vec<AgentConfig>>,
// Single agent at top level
name: Option<String>,
task: Option<String>,
cwd: Option<String>,
}
/// Load agent configs from a file or directory.
/// - File: single JSON with `agents` array, or a single agent object
/// - Directory: each .json file is one agent (or multi)
pub fn load(path: &str) -> Vec<AgentConfig> {
let path = Path::new(path);
if path.is_dir() {
load_dir(path)
} else {
load_file(path)
}
}
fn load_file(path: &Path) -> Vec<AgentConfig> {
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::<MultiConfig>(&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::<Vec<AgentConfig>>(&content).unwrap_or_default()
}
fn load_dir(path: &Path) -> Vec<AgentConfig> {
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()
}

View File

@@ -23,7 +23,7 @@ fn home_dir() -> Option<String> {
}
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() {

1034
src/headless.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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::<serde_json::Value>(&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<String> {
let args: Vec<String> = 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<String> = 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 <task>");
eprintln!(" aishell run -f <config>");
eprintln!(" aishell run -p <preset> (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 <id|name>");
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<usize> = 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 <task> Run single agent");
println!(" aishell run -p <preset> Preset: daily, review, improve");
println!(" aishell run -f <config> Custom config file");
println!(" aishell status [-v] Show agent status");
println!(" aishell log <id|name> 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 <dir> [-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");
}

948
src/tui.rs Normal file
View File

@@ -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<ClaudeManager>,
ai_output: String,
ai_input: String,
ai_status: String,
ai_scroll: u16,
ai_phase: AiPhase,
agents: Vec<Agent>,
selected: usize,
next_id: usize,
agent_scroll: u16,
cmd_cache: CommandCache,
mode: Mode,
input: String,
input_task: String,
message: Option<Message>,
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<AgentConfig>) -> 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<ListItem> = 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(())
}

110
src/watch.rs Normal file
View File

@@ -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::<Event>();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
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<String> = 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)");
}
}