1087 lines
40 KiB
Rust
1087 lines
40 KiB
Rust
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";
|
|
const MAX_OUTPUT: usize = 50_000;
|
|
|
|
// ── 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,
|
|
watch_rx: Option<std::sync::mpsc::Receiver<Vec<String>>>,
|
|
mode: Mode,
|
|
input: String,
|
|
input_task: String,
|
|
message: Option<Message>,
|
|
should_quit: bool,
|
|
}
|
|
|
|
/// Protocol + self-awareness. Personality comes from aigpt MCP.
|
|
const AI_IDENTITY: &str = "\
|
|
You are the main AI in aishell, a multi-agent development tool.
|
|
Your personality comes from aigpt MCP (core.md).
|
|
You oversee agents, integrate their results, and guide the user.
|
|
You can spawn agents with: @name task -c dir
|
|
|
|
Report any usability issues you notice about aishell itself.
|
|
|
|
一言だけ挨拶してください。";
|
|
|
|
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(),
|
|
watch_rx: None,
|
|
mode: Mode::Ai,
|
|
input: String::new(),
|
|
input_task: String::new(),
|
|
message: None,
|
|
should_quit: false,
|
|
};
|
|
|
|
// Send protocol + identity + 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 git_ctx = crate::agent::git_context(&cwd).unwrap_or_default();
|
|
let identity_ctx = load_identity_context();
|
|
let msg = format!("{AI_IDENTITY}\n\n{identity_ctx}[project]\n{git_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);
|
|
if self.ai_output.len() > MAX_OUTPUT {
|
|
let trim = self.ai_output.len() - MAX_OUTPUT;
|
|
let boundary = self.ai_output[trim..].find('\n').map(|i| trim + i + 1).unwrap_or(trim);
|
|
self.ai_output.drain(..boundary);
|
|
}
|
|
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;
|
|
}
|
|
|
|
// Check file watcher
|
|
if let Some(ref rx) = self.watch_rx {
|
|
let mut changed = Vec::new();
|
|
while let Ok(files) = rx.try_recv() {
|
|
changed.extend(files);
|
|
}
|
|
if !changed.is_empty() {
|
|
changed.dedup();
|
|
let short: Vec<&str> = changed.iter()
|
|
.map(|f| f.rsplit('/').next().unwrap_or(f.as_str()))
|
|
.take(5).collect();
|
|
let msg = format!("[files changed: {}]", short.join(", "));
|
|
self.message = Some(Message { text: msg.clone(), is_error: false });
|
|
// Notify AI
|
|
if let Some(ref mut claude) = self.claude {
|
|
claude.send(&msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write state files
|
|
if self.agents.iter().any(|a| a.dirty) {
|
|
write_state(&self.agents);
|
|
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);
|
|
|
|
// watch <dir> → start file watcher
|
|
if input.starts_with("watch ") || input == "watch" {
|
|
let dir = input.strip_prefix("watch").unwrap_or(".").trim();
|
|
let dir = if dir.is_empty() { "." } else { dir };
|
|
self.start_watch(dir);
|
|
return;
|
|
}
|
|
// @name task [-c cwd] → spawn agent
|
|
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 start_watch(&mut self, dir: &str) {
|
|
let dir = expand_tilde(dir);
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
|
|
let dir_clone = dir.clone();
|
|
std::thread::spawn(move || {
|
|
use notify::{Watcher, RecursiveMode};
|
|
let tx = tx;
|
|
let mut watcher = match notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
|
|
if let Ok(event) = res {
|
|
if matches!(event.kind, notify::EventKind::Create(_) | notify::EventKind::Modify(_)) {
|
|
let files: Vec<String> = event.paths.iter()
|
|
.map(|p| p.display().to_string())
|
|
.filter(|s| !s.contains("/.") && !s.contains("/target/"))
|
|
.collect();
|
|
if !files.is_empty() { let _ = tx.send(files); }
|
|
}
|
|
}
|
|
}) {
|
|
Ok(w) => w,
|
|
Err(_) => return,
|
|
};
|
|
let _ = watcher.watch(std::path::Path::new(&dir_clone), RecursiveMode::Recursive);
|
|
loop { std::thread::sleep(std::time::Duration::from_secs(60)); }
|
|
});
|
|
|
|
self.watch_rx = Some(rx);
|
|
self.message = Some(Message { text: format!("watching: {dir}"), is_error: false });
|
|
}
|
|
|
|
fn selected_agent_name(&self) -> Option<&str> {
|
|
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()
|
|
}
|
|
|
|
/// Load identity context from atproto config + recent chat.
|
|
fn load_identity_context() -> String {
|
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
let config_path = if cfg!(target_os = "macos") {
|
|
format!("{home}/Library/Application Support/ai.syui.log/config.json")
|
|
} else {
|
|
format!("{home}/.config/ai.syui.log/config.json")
|
|
};
|
|
|
|
let config: serde_json::Value = match std::fs::read_to_string(&config_path) {
|
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
|
Err(_) => return String::new(),
|
|
};
|
|
|
|
let mut ctx = String::new();
|
|
|
|
// User & bot identity
|
|
let user_handle = config["handle"].as_str().unwrap_or("?");
|
|
let bot_handle = config["bot"]["handle"].as_str().unwrap_or("?");
|
|
let bot_did = config["bot"]["did"].as_str().unwrap_or("?");
|
|
let network = config["network"].as_str().unwrap_or("?");
|
|
|
|
ctx.push_str(&format!(
|
|
"[identity]\nuser: {user_handle}\nyou: {bot_handle} ({bot_did})\nnetwork: {network}\n\n"
|
|
));
|
|
|
|
// Recent chat (last 2 entries for context)
|
|
let bot_path = config["bot"]["path"].as_str().unwrap_or("");
|
|
let expanded = expand_tilde(bot_path);
|
|
let chat_dir = format!(
|
|
"{}/{}/ai.syui.log.chat",
|
|
if expanded.is_empty() { format!("{home}/.config/ai.syui.log/at") } else { expanded.clone() },
|
|
config["bot"]["did"].as_str().unwrap_or("did")
|
|
);
|
|
|
|
// Also check user's chat dir
|
|
let user_did = config["did"].as_str().unwrap_or("");
|
|
let user_chat_dir = format!("{}/{}/ai.syui.log.chat",
|
|
if expanded.is_empty() { format!("{home}/.config/ai.syui.log/at") } else { expanded },
|
|
user_did
|
|
);
|
|
|
|
for dir in [&chat_dir, &user_chat_dir] {
|
|
if let Ok(mut entries) = std::fs::read_dir(dir) {
|
|
let mut files: Vec<_> = entries
|
|
.flatten()
|
|
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
|
.collect();
|
|
files.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
|
|
|
|
for f in files.iter().take(1) {
|
|
if let Ok(content) = std::fs::read_to_string(f.path()) {
|
|
if let Ok(record) = serde_json::from_str::<serde_json::Value>(&content) {
|
|
let text = record["value"]["content"]["text"].as_str().unwrap_or("");
|
|
if !text.is_empty() {
|
|
let short: String = text.chars().take(200).collect();
|
|
ctx.push_str(&format!("[recent chat]\n{short}\n\n"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break; // Use first available dir
|
|
}
|
|
}
|
|
|
|
ctx
|
|
}
|
|
|
|
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(())
|
|
}
|