update
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ Thumbs.db
|
||||
/.claude
|
||||
/claude.md
|
||||
/CLAUDE.md
|
||||
/docs
|
||||
/example
|
||||
/config
|
||||
|
||||
@@ -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
327
src/agent.rs
Normal 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
140
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<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
202
src/claude.rs
Normal 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
90
src/config.rs
Normal 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()
|
||||
}
|
||||
@@ -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
1034
src/headless.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
279
src/main.rs
279
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::<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(); }
|
||||
let config_path = parse_flag("-f");
|
||||
if let Err(e) = aishell::tui::run(config_path.as_deref()) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
None => {
|
||||
eprintln!("unknown preset: {preset_name}");
|
||||
eprintln!("available: daily, review, improve");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else if judge::is_command(input, &cmd_cache) {
|
||||
executor::execute(input);
|
||||
} else {
|
||||
if let Ok(mut cm) = claude.lock() {
|
||||
cm.send(input);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
if let Ok(mut cm) = claude.lock() {
|
||||
if cm.is_busy() {
|
||||
cm.cancel();
|
||||
println!("\x1b[33m(cancelled)\x1b[0m");
|
||||
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),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(ReadlineError::Eof) => break,
|
||||
Err(e) => {
|
||||
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);
|
||||
break;
|
||||
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
948
src/tui.rs
Normal 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
110
src/watch.rs
Normal 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)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user