2
0
Files
shell/src/tui.rs

1091 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 config_path = crate::config::config_path();
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"
));
// Core personality from atproto record
let bot_path = config["bot"]["path"].as_str().unwrap_or("");
let expanded = expand_tilde(bot_path);
let base = if expanded.is_empty() {
format!("{}/ai.syui.log/at", crate::config::config_dir())
} else {
expanded.clone()
};
let core_path = format!("{}/{bot_did}/ai.syui.gpt.core/self.json", base);
if let Ok(content) = std::fs::read_to_string(&core_path) {
if let Ok(record) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(text) = record["value"]["content"]["text"].as_str() {
if !text.is_empty() {
let short: String = text.chars().take(500).collect();
ctx.push_str(&format!("[core]\n{short}\n\n"));
}
}
}
}
// Recent chat (reuse base from core path)
let chat_dir = format!("{}/{bot_did}/ai.syui.log.chat", base);
let user_did = config["did"].as_str().unwrap_or("");
let user_chat_dir = format!("{base}/{user_did}/ai.syui.log.chat");
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(())
}