use crate::agent::log_line::LogLine;
use crate::task::Task;
use ratatui::widgets::ListState;
use std::path::Path;
use super::ui::task_display_height;
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Panel {
#[default]
Tasks,
Logs,
}
pub struct TuiApp {
pub focus: Panel,
pub task_list_state: ListState,
pub tasks: Vec<Task>,
pub log_content: Vec<LogLine>,
pub log_scroll: usize,
pub log_viewport_height: usize,
pub log_follow: bool,
pub log_wrapped_line_count: usize,
pub server_messages: Vec<(chrono::DateTime<chrono::Local>, String)>,
pub workers_active: usize,
pub workers_total: usize,
pub should_quit: bool,
pub task_panel_width: u16,
pub task_list_top: u16,
pub task_scrollbar_drag: bool,
pub task_list_height: u16,
}
impl TuiApp {
pub fn new(workers_total: usize) -> Self {
let mut task_list_state = ListState::default();
task_list_state.select(Some(0));
Self {
focus: Panel::default(),
task_list_state,
tasks: Vec::new(),
log_content: Vec::new(),
log_scroll: 0,
log_viewport_height: 0,
log_follow: true,
log_wrapped_line_count: 0,
server_messages: Vec::new(),
workers_active: 0,
workers_total,
should_quit: false,
task_panel_width: 0,
task_list_top: 0,
task_scrollbar_drag: false,
task_list_height: 0,
}
}
pub fn select_next_task(&mut self) {
if self.tasks.is_empty() {
return;
}
let current = self.task_list_state.selected().unwrap_or(0);
let next = (current + 1).min(self.tasks.len() - 1);
self.task_list_state.select(Some(next));
}
pub fn select_task(&mut self, index: usize) {
if index < self.tasks.len() {
self.task_list_state.select(Some(index));
}
}
pub fn select_previous_task(&mut self) {
if self.tasks.is_empty() {
return;
}
let current = self.task_list_state.selected().unwrap_or(0);
let prev = current.saturating_sub(1);
self.task_list_state.select(Some(prev));
}
pub fn task_viewport_items(&self) -> usize {
self.task_list_height as usize / 2
}
pub fn scroll_task_list_to_offset(&mut self, offset: usize) {
*self.task_list_state.offset_mut() = offset;
let last_visible = self.last_visible_task_from(offset);
if let Some(selected) = self.task_list_state.selected() {
if selected < offset {
self.task_list_state.select(Some(offset));
} else if let Some(last) = last_visible
&& selected > last
{
self.task_list_state.select(Some(last));
}
}
}
fn last_visible_task_from(&self, offset: usize) -> Option<usize> {
let height = self.task_list_height as usize;
let mut accumulated = 0;
let mut last = None;
for (i, task) in self.tasks.iter().enumerate().skip(offset) {
let h = task_display_height(task, &self.tasks) as usize;
if accumulated + h > height {
break;
}
accumulated += h;
last = Some(i);
}
last
}
pub fn max_log_scroll(&self) -> usize {
self.log_wrapped_line_count
.saturating_sub(self.log_viewport_height)
}
pub fn clamp_log_scroll(&mut self) {
self.log_scroll = self.log_scroll.min(self.max_log_scroll());
}
pub fn scroll_logs_up(&mut self, amount: usize) {
self.log_scroll = self.log_scroll.saturating_sub(amount);
self.log_follow = self.log_scroll >= self.max_log_scroll();
}
pub fn scroll_logs_down(&mut self, amount: usize) {
self.log_scroll = self.log_scroll.saturating_add(amount);
self.clamp_log_scroll();
self.log_follow = self.log_scroll >= self.max_log_scroll();
}
pub fn load_logs_for_selected_task(&mut self, data_dir: &Path) {
self.reload_logs(data_dir);
self.log_follow = true;
}
pub fn refresh_logs(&mut self, data_dir: &Path) {
self.reload_logs(data_dir);
}
fn reload_logs(&mut self, data_dir: &Path) {
let selected = match self.task_list_state.selected() {
Some(idx) if idx < self.tasks.len() => idx,
_ => {
self.log_content.clear();
return;
}
};
let task = &self.tasks[selected];
let log_path = data_dir
.join("tasks")
.join(&task.id)
.join("output")
.join("agent.log");
match std::fs::read_to_string(&log_path) {
Ok(content) => {
self.log_content = content
.lines()
.filter(|line| !line.is_empty())
.map(|line| {
serde_json::from_str::<LogLine>(line).unwrap_or_else(|_| {
LogLine::message(vec![], None, line.to_string())
})
})
.collect();
}
Err(_) => {
self.log_content.clear();
}
}
}
pub fn update_tasks(&mut self, tasks: Vec<Task>) {
let prev_selected = self
.task_list_state
.selected()
.and_then(|idx| self.tasks.get(idx).map(|t| (idx, t.id.clone())));
self.tasks = tasks;
if let Some((prev_idx, prev_id)) = prev_selected {
let new_idx = self.tasks.iter().position(|t| t.id == prev_id);
match new_idx {
Some(idx) => self.task_list_state.select(Some(idx)),
None if !self.tasks.is_empty() => {
self.task_list_state
.select(Some(prev_idx.min(self.tasks.len() - 1)));
}
None => self.task_list_state.select(None),
}
} else if !self.tasks.is_empty() {
self.task_list_state.select(Some(0));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::log_line::LogLine;
use crate::task::Task;
use std::fs;
fn make_log_lines(count: usize) -> Vec<LogLine> {
(0..count)
.map(|i| LogLine::message(vec![], None, format!("line {i}")))
.collect()
}
#[test]
fn test_new_defaults() {
let app = TuiApp::new(4);
assert_eq!(app.focus, Panel::Tasks);
assert_eq!(app.workers_total, 4);
assert_eq!(app.workers_active, 0);
assert!(!app.should_quit);
assert!(app.server_messages.is_empty());
assert!(app.tasks.is_empty());
assert_eq!(app.log_viewport_height, 0);
assert!(app.log_follow);
assert_eq!(app.log_wrapped_line_count, 0);
let mut app = app;
app.focus = Panel::Logs;
assert_eq!(app.focus, Panel::Logs);
}
#[test]
fn test_select_next_previous_task() {
let mut app = TuiApp::new(2);
app.tasks = vec![
Task {
id: "t1".to_string(),
name: "task-1".to_string(),
branch_name: "tsk/feat/task-1/t1".to_string(),
..Task::test_default()
},
Task {
id: "t2".to_string(),
name: "task-2".to_string(),
branch_name: "tsk/feat/task-2/t2".to_string(),
..Task::test_default()
},
Task {
id: "t3".to_string(),
name: "task-3".to_string(),
branch_name: "tsk/feat/task-3/t3".to_string(),
..Task::test_default()
},
];
assert_eq!(app.task_list_state.selected(), Some(0));
app.select_next_task();
assert_eq!(app.task_list_state.selected(), Some(1));
app.select_next_task();
assert_eq!(app.task_list_state.selected(), Some(2));
app.select_next_task();
assert_eq!(app.task_list_state.selected(), Some(2));
app.select_previous_task();
assert_eq!(app.task_list_state.selected(), Some(1));
app.select_previous_task();
assert_eq!(app.task_list_state.selected(), Some(0));
app.select_previous_task();
assert_eq!(app.task_list_state.selected(), Some(0));
}
#[test]
fn test_scroll_logs() {
let mut app = TuiApp::new(1);
app.log_content = make_log_lines(20);
app.log_viewport_height = 10;
app.log_wrapped_line_count = 20;
assert_eq!(app.log_scroll, 0);
app.scroll_logs_down(5);
assert_eq!(app.log_scroll, 5);
app.scroll_logs_down(3);
assert_eq!(app.log_scroll, 8);
app.scroll_logs_up(3);
assert_eq!(app.log_scroll, 5);
app.scroll_logs_up(10);
assert_eq!(app.log_scroll, 0);
app.scroll_logs_down(100);
assert_eq!(app.log_scroll, 10);
}
#[test]
fn test_update_tasks_preserves_selection() {
let mut app = TuiApp::new(1);
let tasks_v1 = vec![
Task {
id: "t1".to_string(),
name: "task-1".to_string(),
branch_name: "tsk/feat/task-1/t1".to_string(),
..Task::test_default()
},
Task {
id: "t2".to_string(),
name: "task-2".to_string(),
branch_name: "tsk/feat/task-2/t2".to_string(),
..Task::test_default()
},
];
app.update_tasks(tasks_v1);
app.task_list_state.select(Some(1));
assert_eq!(app.tasks[1].id, "t2");
let tasks_v2 = vec![
Task {
id: "t2".to_string(),
name: "task-2".to_string(),
branch_name: "tsk/feat/task-2/t2".to_string(),
..Task::test_default()
},
Task {
id: "t3".to_string(),
name: "task-3".to_string(),
branch_name: "tsk/feat/task-3/t3".to_string(),
..Task::test_default()
},
];
app.update_tasks(tasks_v2);
assert_eq!(app.task_list_state.selected(), Some(0));
assert_eq!(app.tasks[0].id, "t2");
}
#[test]
fn test_load_logs_for_selected_task() {
let tmp_dir = tempfile::tempdir().unwrap();
let data_dir = tmp_dir.path();
let task_id = "test-task-123";
let log_dir = data_dir.join("tasks").join(task_id).join("output");
fs::create_dir_all(&log_dir).unwrap();
let lines = [
serde_json::to_string(&LogLine::message(vec![], None, "line 1".into())).unwrap(),
serde_json::to_string(&LogLine::message(vec![], None, "line 2".into())).unwrap(),
serde_json::to_string(&LogLine::message(vec![], None, "line 3".into())).unwrap(),
];
fs::write(log_dir.join("agent.log"), lines.join("\n") + "\n").unwrap();
let mut app = TuiApp::new(1);
app.log_viewport_height = 2;
app.tasks = vec![Task {
id: task_id.to_string(),
name: "test-task".to_string(),
branch_name: "tsk/feat/test-task/test-task-123".to_string(),
..Task::test_default()
}];
app.log_follow = false;
app.load_logs_for_selected_task(data_dir);
assert_eq!(app.log_content.len(), 3);
assert!(app.log_follow);
app.task_list_state.select(None);
app.load_logs_for_selected_task(data_dir);
assert!(app.log_content.is_empty());
}
#[test]
fn test_follow_mode() {
let mut app = TuiApp::new(1);
app.log_content = make_log_lines(20);
app.log_viewport_height = 10;
app.log_wrapped_line_count = 20;
app.log_scroll = app.max_log_scroll();
app.log_follow = true;
assert_eq!(app.log_scroll, 10);
app.scroll_logs_up(5);
assert!(!app.log_follow);
assert_eq!(app.log_scroll, 5);
app.scroll_logs_down(5);
assert!(app.log_follow);
assert_eq!(app.log_scroll, 10);
}
#[test]
fn test_scroll_clamping() {
let mut app = TuiApp::new(1);
app.log_content = make_log_lines(5);
app.log_viewport_height = 10;
app.log_wrapped_line_count = 5;
assert_eq!(app.max_log_scroll(), 0);
app.scroll_logs_down(100);
assert_eq!(app.log_scroll, 0);
assert!(app.log_follow);
}
#[test]
fn test_max_log_scroll() {
let mut app = TuiApp::new(1);
app.log_content = make_log_lines(20);
app.log_viewport_height = 10;
app.log_wrapped_line_count = 20;
assert_eq!(app.max_log_scroll(), 10);
app.log_content = make_log_lines(5);
app.log_wrapped_line_count = 5;
assert_eq!(app.max_log_scroll(), 0);
app.log_content.clear();
app.log_wrapped_line_count = 0;
assert_eq!(app.max_log_scroll(), 0);
}
#[test]
fn test_reload_logs_json_lines() {
let tmp_dir = tempfile::tempdir().unwrap();
let data_dir = tmp_dir.path();
let task_id = "json-test";
let log_dir = data_dir.join("tasks").join(task_id).join("output");
fs::create_dir_all(&log_dir).unwrap();
let json_line = serde_json::to_string(&LogLine::message(
vec![],
Some("Bash".into()),
"cargo test".into(),
))
.unwrap();
let content = format!("{json_line}\nplain text line\n");
fs::write(log_dir.join("agent.log"), content).unwrap();
let mut app = TuiApp::new(1);
app.tasks = vec![Task {
id: task_id.to_string(),
name: "json-test".to_string(),
branch_name: "tsk/feat/json-test/json-test".to_string(),
..Task::test_default()
}];
app.load_logs_for_selected_task(data_dir);
assert_eq!(app.log_content.len(), 2);
if let LogLine::Message { tool, message, .. } = &app.log_content[0] {
assert_eq!(tool.as_deref(), Some("Bash"));
assert_eq!(message, "cargo test");
} else {
panic!("Expected Message variant");
}
if let LogLine::Message { message, .. } = &app.log_content[1] {
assert_eq!(message, "plain text line");
} else {
panic!("Expected Message variant");
}
}
}