use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use crate::cli::embedded_scripts;
use crate::config::UserConfig;
use crate::models::{Finding, Severity};
#[derive(Clone, Debug)]
pub enum AgentStatus {
Running,
Completed(bool), Failed(#[allow(dead_code)] String), }
pub struct AgentTask {
pub finding_index: usize,
pub finding_title: String,
pub started_at: Instant,
pub status: AgentStatus,
pub log_file: PathBuf,
child: Option<Child>,
}
impl AgentTask {
fn poll(&mut self) -> bool {
let Some(ref mut child) = self.child else {
return true; };
match child.try_wait() {
Ok(Some(status)) => {
self.status = agent_status_from_exit(status);
self.child = None;
true
}
Ok(None) => false, Err(e) => {
self.status = AgentStatus::Failed(format!("Poll error: {}", e));
self.child = None;
true
}
}
}
fn elapsed(&self) -> Duration {
self.started_at.elapsed()
}
fn elapsed_str(&self) -> String {
let secs = self.elapsed().as_secs();
if secs < 60 {
format!("{}s", secs)
} else {
format!("{}m{}s", secs / 60, secs % 60)
}
}
fn cancel(&mut self) -> bool {
let Some(ref mut child) = self.child else { return false; };
if child.kill().is_err() { return false; }
self.status = AgentStatus::Failed("Cancelled by user".to_string());
self.child = None;
true
}
}
fn agent_status_from_exit(status: std::process::ExitStatus) -> AgentStatus {
if status.success() {
AgentStatus::Completed(true)
} else {
AgentStatus::Failed(format!("Exit code: {:?}", status.code()))
}
}
fn format_spawn_error(error: std::io::Error, use_ollama: bool) -> String {
if use_ollama {
format!("❌ Failed: {}. Is Ollama running? (ollama serve)", error)
} else {
format!("❌ Failed: {}. Install claude-code or set up venv.", error)
}
}
fn resolve_api_key(config: &UserConfig) -> Option<String> {
if let Some(key) = config.anthropic_api_key() {
return Some(key.to_string());
}
std::env::var("ANTHROPIC_API_KEY").ok()
}
fn read_code_snippet(
repo_path: &Path,
file_path: &str,
line_start: u32,
line_end: u32,
context: usize,
) -> Option<Vec<(u32, String)>> {
let full_path = repo_path.join(file_path);
let content = fs::read_to_string(&full_path).ok()?;
let lines: Vec<&str> = content.lines().collect();
let start = (line_start as usize).saturating_sub(context + 1);
let end = (line_end as usize + context).min(lines.len());
Some(
lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| ((start + i + 1) as u32, line.to_string()))
.collect(),
)
}
fn get_agent_log_dir(repo_path: &Path) -> PathBuf {
let dir = repo_path.join(".repotoire").join("agents");
fs::create_dir_all(&dir).ok();
dir
}
fn tail_file(path: &Path, n: usize) -> Vec<String> {
if let Ok(file) = File::open(path) {
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
lines.into_iter().rev().take(n).rev().collect()
} else {
vec![]
}
}
const SPINNER_FRAMES: [char; 4] = ['⠋', '⠙', '⠹', '⠸'];
pub struct App {
findings: Vec<Finding>,
list_state: ListState,
show_detail: bool,
show_agents: bool,
repo_path: PathBuf,
code_cache: Option<Vec<(u32, String)>>,
cached_index: Option<usize>,
status_message: Option<(String, bool, Instant)>, agents: Vec<AgentTask>,
config: UserConfig,
frame: usize, }
impl App {
pub fn new(findings: Vec<Finding>, repo_path: PathBuf) -> Self {
let config = UserConfig::load().unwrap_or_default();
let mut list_state = ListState::default();
if !findings.is_empty() {
list_state.select(Some(0));
}
Self {
findings,
list_state,
show_detail: false,
show_agents: false,
repo_path,
code_cache: None,
cached_index: None,
status_message: None,
agents: Vec::new(),
config,
frame: 0,
}
}
fn spinner(&self) -> char {
SPINNER_FRAMES[self.frame % SPINNER_FRAMES.len()]
}
fn set_status(&mut self, msg: String, is_error: bool) {
self.status_message = Some((msg, is_error, Instant::now()));
}
fn maybe_clear_status(&mut self) {
if let Some((_, _, when)) = &self.status_message {
if when.elapsed() > Duration::from_secs(5) {
self.status_message = None;
}
}
}
fn poll_agents(&mut self) {
for agent in &mut self.agents {
agent.poll();
}
}
fn running_agent_count(&self) -> usize {
self.agents
.iter()
.filter(|a| matches!(a.status, AgentStatus::Running))
.count()
}
fn cancel_latest_agent(&mut self) -> Option<String> {
if let Some(agent) = self
.agents
.iter_mut()
.rev()
.find(|a| matches!(a.status, AgentStatus::Running))
{
let title = agent.finding_title.clone();
let index = agent.finding_index;
if agent.cancel() {
Some(format!("⛔ Cancelled agent #{}: {}", index, title))
} else {
Some(format!("❌ Failed to cancel agent #{}", index))
}
} else {
Some("⚠️ No running agents to cancel".to_string())
}
}
fn launch_agent(&mut self) -> Option<String> {
let finding = self.selected_finding()?.clone();
let index = self.list_state.selected()? + 1;
if self
.agents
.iter()
.any(|a| a.finding_index == index && matches!(a.status, AgentStatus::Running))
{
return Some(format!("⚠️ Agent already running for finding #{}", index));
}
let use_ollama = self.config.use_ollama();
let api_key = if use_ollama {
String::new() } else {
match resolve_api_key(&self.config) {
Some(key) => key,
None => return Some("❌ No API key. Run: repotoire config init\n Or use Ollama: repotoire config set ai.backend ollama".to_string()),
}
};
let log_dir = get_agent_log_dir(&self.repo_path);
let log_file = log_dir.join(format!("agent_{}.log", index));
let log_handle = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_file);
let stdout_file = match log_handle {
Ok(f) => f,
Err(e) => return Some(format!("❌ Failed to create log file: {}", e)),
};
let stderr_file = stdout_file.try_clone().ok();
let finding_json = serde_json::json!({
"index": index,
"title": finding.title,
"severity": finding.severity.to_string(),
"description": finding.description,
"suggested_fix": finding.suggested_fix,
"affected_files": finding.affected_files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
"line_start": finding.line_start,
"line_end": finding.line_end,
});
let (ollama_script, claude_script) =
match embedded_scripts::get_script_paths(&self.repo_path) {
Ok(paths) => paths,
Err(e) => return Some(format!("❌ Failed to extract scripts: {}", e)),
};
let venv_python = self.repo_path.join(".venv/bin/python");
let system_python = std::path::PathBuf::from("python3");
let python = if venv_python.exists() {
&venv_python
} else {
&system_python
};
let ollama_script_str = ollama_script.to_string_lossy().to_string();
let claude_script_str = claude_script.to_string_lossy().to_string();
let repo_path_str = self.repo_path.to_string_lossy().to_string();
let result = if use_ollama {
Command::new(python)
.args([
ollama_script_str.as_str(),
"--finding-json",
&finding_json.to_string(),
"--repo-path",
repo_path_str.as_str(),
"--model",
self.config.ollama_model(),
])
.env("OLLAMA_URL", self.config.ollama_url())
.current_dir(&self.repo_path)
.stdout(Stdio::from(stdout_file))
.stderr(stderr_file.map(Stdio::from).unwrap_or(Stdio::null()))
.spawn()
} else {
Command::new(python)
.args([
claude_script_str.as_str(),
"--finding-json",
&finding_json.to_string(),
"--repo-path",
repo_path_str.as_str(),
])
.env("ANTHROPIC_API_KEY", &api_key)
.current_dir(&self.repo_path)
.stdout(Stdio::from(stdout_file))
.stderr(stderr_file.map(Stdio::from).unwrap_or(Stdio::null()))
.spawn()
};
let backend_name = if use_ollama {
format!("Ollama ({})", self.config.ollama_model())
} else {
"Claude".to_string()
};
let child = match result {
Ok(c) => c,
Err(e) => return Some(format_spawn_error(e, use_ollama)),
};
let pid = child.id();
self.agents.push(AgentTask {
finding_index: index,
finding_title: finding.title.clone(),
started_at: Instant::now(),
status: AgentStatus::Running,
log_file: log_file.clone(),
child: Some(child),
});
Some(format!("🚀 {} agent launched (PID: {})", backend_name, pid))
}
fn run_fix(&self) -> Option<String> {
let index = self.list_state.selected()? + 1;
Some(format!("Run: repotoire fix {}", index))
}
fn get_code_snippet(&mut self) -> Option<&Vec<(u32, String)>> {
let selected = self.list_state.selected()?;
if self.cached_index == Some(selected) {
return self.code_cache.as_ref();
}
let finding = self.findings.get(selected)?;
let file_path = finding.affected_files.first()?;
let line_start = finding.line_start.unwrap_or(1);
let line_end = finding.line_end.unwrap_or(line_start);
self.code_cache = read_code_snippet(
&self.repo_path,
&file_path.to_string_lossy(),
line_start,
line_end,
3,
);
self.cached_index = Some(selected);
self.code_cache.as_ref()
}
fn next(&mut self) {
if self.findings.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => (i + 1) % self.findings.len(),
None => 0,
};
self.list_state.select(Some(i));
}
fn previous(&mut self) {
if self.findings.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(0) | None => self.findings.len() - 1,
Some(i) => i - 1,
};
self.list_state.select(Some(i));
}
fn selected_finding(&self) -> Option<&Finding> {
self.list_state
.selected()
.and_then(|i| self.findings.get(i))
}
}
pub fn run(findings: Vec<Finding>, repo_path: PathBuf) -> Result<()> {
use std::io::IsTerminal;
if !io::stdout().is_terminal() {
anyhow::bail!(
"Interactive mode requires a terminal (TTY).\n\
Run without -i flag, or use: repotoire findings --json"
);
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(findings, repo_path);
let res = run_app(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("Error: {err:?}");
}
Ok(())
}
fn handle_key_event(app: &mut App, code: KeyCode) -> bool {
match code {
KeyCode::Char('q') | KeyCode::Esc if !app.show_detail && !app.show_agents => {
return true;
}
KeyCode::Esc => {
app.show_detail = false;
app.show_agents = false;
}
_ => handle_key_action(app, code),
}
false
}
fn handle_key_action(app: &mut App, code: KeyCode) {
match code {
KeyCode::Down | KeyCode::Char('j') if !app.show_agents => app.next(),
KeyCode::Up | KeyCode::Char('k') if !app.show_agents => app.previous(),
KeyCode::Enter if !app.show_agents => app.show_detail = !app.show_detail,
KeyCode::PageDown => (0..10).for_each(|_| app.next()),
KeyCode::PageUp => (0..10).for_each(|_| app.previous()),
KeyCode::Char('f') => {
if let Some(msg) = app.run_fix() {
app.set_status(msg, false);
}
}
KeyCode::Char('F') => {
if let Some(msg) = app.launch_agent() {
let is_error = msg.starts_with("❌") || msg.starts_with("⚠️");
app.set_status(msg, is_error);
}
}
KeyCode::Char('a') | KeyCode::Char('A') => app.show_agents = !app.show_agents,
KeyCode::Char('c') => {
if let Some(msg) = app.cancel_latest_agent() {
let is_error = msg.starts_with("❌") || msg.starts_with("⚠️");
app.set_status(msg, is_error);
}
}
_ => {}
}
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
loop {
app.poll_agents();
app.maybe_clear_status();
app.frame = app.frame.wrapping_add(1);
terminal.draw(|f| ui(f, app))?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if handle_key_event(app, key.code) {
return Ok(());
}
}
}
fn ui(f: &mut Frame, app: &mut App) {
let running = app.running_agent_count();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(if running > 0 { 2 } else { 1 }),
])
.split(f.area());
let selected = app.list_state.selected().unwrap_or(0) + 1;
let agent_indicator = if running > 0 {
format!(
" | 🤖 {} agent{}",
running,
if running > 1 { "s" } else { "" }
)
} else {
String::new()
};
let header = Paragraph::new(format!(
" 🎼 Repotoire | {} findings | {}/{}{}",
app.findings.len(),
selected,
app.findings.len(),
agent_indicator
))
.style(Style::default().fg(Color::Cyan).bold())
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
if app.show_agents {
render_agents_panel(f, chunks[1], app);
} else {
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(chunks[1]);
render_list(f, main_chunks[0], app);
if let Some(finding) = app.selected_finding().cloned() {
let code = app.get_code_snippet().cloned();
render_detail(f, main_chunks[1], &finding, code.as_ref());
}
}
render_footer(f, chunks[2], app);
}
fn render_agents_panel(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6.min(app.agents.len() as u16 + 2)),
Constraint::Min(5),
])
.split(area);
let items: Vec<ListItem> = app
.agents
.iter()
.map(|agent| {
let (status_icon, status_color) = match &agent.status {
AgentStatus::Running => ("⏳", Color::Yellow),
AgentStatus::Completed(true) => ("✅", Color::Green),
AgentStatus::Completed(false) => ("❌", Color::Red),
AgentStatus::Failed(_) => ("💥", Color::Red),
};
let line = Line::from(vec![
Span::styled(
format!(" {} ", status_icon),
Style::default().fg(status_color),
),
Span::styled(
format!("#{:<3} ", agent.finding_index),
Style::default().fg(Color::Cyan),
),
Span::raw(&agent.finding_title),
Span::styled(
format!(" [{}]", agent.elapsed_str()),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(" Agents (press 'a' to close) "),
);
f.render_widget(list, chunks[0]);
let log_lines: Vec<Line> = app
.agents
.iter()
.rfind(|a| matches!(a.status, AgentStatus::Running))
.map(|agent| {
tail_file(&agent.log_file, 15)
.into_iter()
.map(|line| {
let style = if line.starts_with("🚀") || line.starts_with("✅") {
Style::default().fg(Color::Green)
} else if line.starts_with("❌") || line.starts_with("💥") {
Style::default().fg(Color::Red)
} else if line.starts_with("🔧") {
Style::default().fg(Color::Yellow)
} else if line.starts_with("💭") {
Style::default().fg(Color::Cyan)
} else if line.starts_with("📋") {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
Line::styled(line, style)
})
.collect()
})
.unwrap_or_else(|| {
vec![Line::raw(
" No running agents - press 'F' on a finding to launch one",
)]
});
let log_widget = Paragraph::new(log_lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Agent Output "),
)
.wrap(Wrap { trim: false });
f.render_widget(log_widget, chunks[1]);
}
fn render_list(f: &mut Frame, area: Rect, app: &mut App) {
let items: Vec<ListItem> =
app.findings
.iter()
.enumerate()
.map(|(i, finding)| {
let (severity_char, severity_color) = match finding.severity {
Severity::Critical => ("C", Color::Red),
Severity::High => ("H", Color::Yellow),
Severity::Medium => ("M", Color::Blue),
Severity::Low => ("L", Color::DarkGray),
Severity::Info => ("I", Color::DarkGray),
};
let file = finding
.affected_files
.first()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string());
let max_len = 40;
let file_display = if file.len() > max_len {
format!("...{}", &file[file.len() - max_len + 3..])
} else {
file
};
let agent_icon =
if app.agents.iter().any(|a| {
a.finding_index == i + 1 && matches!(a.status, AgentStatus::Running)
}) {
"🤖 "
} else {
""
};
let line = Line::from(vec![
Span::styled(
format!("{:>4} ", i + 1),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("[{}] ", severity_char),
Style::default().fg(severity_color).bold(),
),
Span::raw(agent_icon),
Span::raw(&finding.title),
Span::styled(
format!(" {}", file_display),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Findings "))
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White))
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.list_state);
}
fn render_detail(
f: &mut Frame,
area: Rect,
finding: &Finding,
code_snippet: Option<&Vec<(u32, String)>>,
) {
let severity_str = match finding.severity {
Severity::Critical => "CRITICAL",
Severity::High => "HIGH",
Severity::Medium => "MEDIUM",
Severity::Low => "LOW",
Severity::Info => "INFO",
};
let file = finding
.affected_files
.first()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string());
let line_start = finding.line_start.unwrap_or(0);
let line_end = finding.line_end.unwrap_or(line_start);
let line_info = match (finding.line_start, finding.line_end) {
(Some(start), Some(end)) if start != end => format!(":{}-{}", start, end),
(Some(start), _) => format!(":{}", start),
_ => String::new(),
};
let mut text = vec![
Line::from(vec![
Span::styled("Title: ", Style::default().bold()),
Span::raw(&finding.title),
]),
Line::from(vec![
Span::styled("Severity: ", Style::default().bold()),
Span::styled(
severity_str,
Style::default().fg(match finding.severity {
Severity::Critical => Color::Red,
Severity::High => Color::Yellow,
Severity::Medium => Color::Blue,
_ => Color::DarkGray,
}),
),
]),
Line::from(vec![
Span::styled("File: ", Style::default().bold()),
Span::raw(format!("{}{}", file, line_info)),
]),
Line::from(""),
];
if let Some(lines) = code_snippet {
text.push(Line::from(Span::styled("Code:", Style::default().bold())));
text.push(Line::from(""));
for (line_num, code) in lines {
let is_highlighted = *line_num >= line_start && *line_num <= line_end;
let line_style = if is_highlighted {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let display_code = if code.len() > 80 {
format!("{}...", &code[..77])
} else {
code.clone()
};
text.push(Line::from(vec![
Span::styled(
format!("{:>4} | ", line_num),
Style::default().fg(Color::DarkGray),
),
Span::styled(display_code, line_style),
]));
}
text.push(Line::from(""));
}
text.push(Line::from(Span::styled(
"Description:",
Style::default().bold(),
)));
for line in finding.description.lines().take(3) {
text.push(Line::from(format!(" {}", line)));
}
if let Some(fix) = &finding.suggested_fix {
text.push(Line::from(""));
text.push(Line::from(Span::styled("Fix:", Style::default().bold())));
for line in fix.lines().take(2) {
text.push(Line::from(format!(" {}", line)));
}
}
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title(" Details "))
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let running = app.running_agent_count();
if let Some((msg, is_error, _)) = &app.status_message {
let footer = Paragraph::new(Line::from(vec![Span::styled(
format!(" {} ", msg),
if *is_error {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
},
)]));
f.render_widget(footer, area);
return;
}
if running > 0 {
let footer_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
let keybinds = Line::from(vec![
Span::styled(" j/k", Style::default().fg(Color::Cyan)),
Span::raw(":Nav "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(":Details "),
Span::styled("f", Style::default().fg(Color::Yellow)),
Span::raw(":Fix "),
Span::styled("F", Style::default().fg(Color::Green).bold()),
Span::raw(":Agent "),
Span::styled("c", Style::default().fg(Color::Red)),
Span::raw(":Cancel "),
Span::styled("a", Style::default().fg(Color::Magenta)),
Span::raw(":Agents "),
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(":Quit"),
]);
f.render_widget(Paragraph::new(keybinds), footer_chunks[0]);
let spinner = app.spinner();
let agent_status = Line::from(vec![
Span::styled(format!(" {} ", spinner), Style::default().fg(Color::Cyan)),
Span::styled(
format!(
"🤖 {} agent{} running",
running,
if running > 1 { "s" } else { "" }
),
Style::default().fg(Color::Yellow),
),
]);
f.render_widget(Paragraph::new(agent_status), footer_chunks[1]);
} else {
let footer = Paragraph::new(Line::from(vec![
Span::styled(" j/k", Style::default().fg(Color::Cyan)),
Span::raw(":Nav "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(":Details "),
Span::styled("f", Style::default().fg(Color::Yellow)),
Span::raw(":Fix "),
Span::styled("F", Style::default().fg(Color::Green).bold()),
Span::raw(":Agent "),
Span::styled("c", Style::default().fg(Color::Red)),
Span::raw(":Cancel "),
Span::styled("a", Style::default().fg(Color::Magenta)),
Span::raw(":Agents "),
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(":Quit"),
]));
f.render_widget(footer, area);
}
}