use super::panels::{ChangelogState, ChatState, Focus, TreeState};
use crate::manifest::Manifest;
use crate::poll::UiEvent;
use crate::types::LogEntry;
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
const MIN_WIDTH: u16 = 80;
const MIN_HEIGHT: u16 = 24;
pub struct App {
_store_root: PathBuf,
pub manifest: Arc<Mutex<Manifest>>,
pub tree: TreeState,
pub changelog: ChangelogState,
pub chat: ChatState,
pub focus: Focus,
pub ui_rx: tokio::sync::mpsc::Receiver<UiEvent>,
pub should_quit: bool,
}
impl App {
pub fn new(
store_root: PathBuf,
manifest: Arc<Mutex<Manifest>>,
initial_log: Vec<LogEntry>,
command_history: Vec<String>,
ui_rx: tokio::sync::mpsc::Receiver<UiEvent>,
) -> Self {
let tree = {
let m = manifest.lock().unwrap();
TreeState::new(&m)
};
Self {
_store_root: store_root,
manifest,
tree,
changelog: ChangelogState::new(initial_log),
chat: ChatState::new(command_history),
focus: Focus::Chat,
ui_rx,
should_quit: false,
}
}
pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
loop {
terminal.draw(|f| self.render(f))?;
if crossterm::event::poll(std::time::Duration::from_millis(33))? {
if let Event::Key(key) = crossterm::event::read()? {
self.handle_key(key);
}
}
while let Ok(event) = self.ui_rx.try_recv() {
self.handle_ui_event(event);
}
if self.should_quit {
break;
}
}
Ok(())
}
fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
match key.code {
KeyCode::Char('q') if self.focus != Focus::Chat => {
self.should_quit = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
KeyCode::Tab => {
self.focus = self.focus.next();
}
KeyCode::Up => match self.focus {
Focus::Tree => self.tree.scroll_up(),
Focus::Changelog => self.changelog.scroll_up(),
Focus::Chat => self.chat.history_up(),
},
KeyCode::Down => match self.focus {
Focus::Tree => self.tree.scroll_down(),
Focus::Changelog => self.changelog.scroll_down(),
Focus::Chat => self.chat.history_down(),
},
KeyCode::Char(c) if self.focus == Focus::Chat => {
self.chat.push_char(c);
}
KeyCode::Backspace if self.focus == Focus::Chat => {
self.chat.backspace();
}
KeyCode::Enter if self.focus == Focus::Chat => {
let input = self.chat.take_input();
if !input.trim().is_empty() {
self.execute_command(&input);
}
}
KeyCode::Esc => {
self.chat.output = None;
}
_ => {}
}
}
fn handle_ui_event(&mut self, event: UiEvent) {
match event {
UiEvent::NewCommit(entry) => {
self.changelog.push(entry);
if let Ok(m) = self.manifest.lock() {
self.tree.update(&m);
}
}
UiEvent::Violation(msg) => {
self.chat.output = Some(msg);
}
}
}
fn execute_command(&mut self, input: &str) {
let parts: Vec<&str> = input.split_whitespace().collect();
match parts.as_slice() {
["ls"] | ["ls", ..] => {
let m = self.manifest.lock().unwrap();
let lines: Vec<String> = m
.documents()
.iter()
.map(|d| format!("[{}] {}", d.doc_type.indicator(), d.path.display()))
.collect();
self.chat.output = Some(if lines.is_empty() {
"No documents tracked.".into()
} else {
lines.join("\n")
});
}
["q"] | ["quit"] | ["exit"] => {
self.should_quit = true;
}
_ => {
self.chat.output = Some(format!(
"Unknown command: '{input}'. Type 'ls' to list documents, 'q' to quit."
));
}
}
}
pub fn render(&mut self, f: &mut Frame<'_>) {
let size = f.area();
if size.width < MIN_WIDTH || size.height < MIN_HEIGHT {
let msg = Paragraph::new("Terminal too small. Please resize to at least 80x24.")
.style(Style::default().fg(Color::Red));
f.render_widget(msg, size);
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(size);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(rows[0]);
self.render_tree(f, cols[0]);
self.render_changelog(f, cols[1]);
self.render_chat(f, rows[1]);
}
fn render_tree(&mut self, f: &mut Frame<'_>, area: Rect) {
let focused = self.focus == Focus::Tree;
let (list, state) = self.tree.render_widget();
let list = list.block(
Block::default()
.title("Documents")
.borders(Borders::ALL)
.border_style(if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}),
);
f.render_stateful_widget(list, area, state);
}
fn render_changelog(&mut self, f: &mut Frame<'_>, area: Rect) {
let focused = self.focus == Focus::Changelog;
if let Some(output) = &self.chat.output {
let para = Paragraph::new(output.clone())
.block(
Block::default()
.title("Output")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(para, area);
return;
}
let visible_height = area.height.saturating_sub(2) as usize;
let entries = &self.changelog.entries;
let start = self.changelog.scroll.min(entries.len().saturating_sub(1));
let visible = entries.iter().skip(start).take(visible_height);
let lines: Vec<Line> = visible
.map(|entry| {
let time = entry.timestamp.format("%H:%M:%S").to_string();
let actor_color = if entry.actor.is_agent() {
Color::Magenta
} else {
Color::White
};
Line::from(vec![
Span::styled(time, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(entry.actor.to_string(), Style::default().fg(actor_color)),
Span::raw(" "),
Span::raw(entry.summary.clone()),
])
})
.collect();
let para = Paragraph::new(lines).block(
Block::default()
.title("Changelog")
.borders(Borders::ALL)
.border_style(if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}),
);
f.render_widget(para, area);
}
fn render_chat(&mut self, f: &mut Frame<'_>, area: Rect) {
let focused = self.focus == Focus::Chat;
let prompt = format!("> {}", self.chat.input);
let para = Paragraph::new(prompt).block(
Block::default()
.title("Command")
.borders(Borders::ALL)
.border_style(if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}),
);
f.render_widget(para, area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreInfo;
use crate::manifest::Manifest;
use crate::poll::UiEvent;
use crate::types::{Action, Actor, CommitId, LogEntry};
use ratatui::backend::TestBackend;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
fn make_app(tmp: &TempDir) -> (App, tokio::sync::mpsc::Sender<UiEvent>) {
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
let info = StoreInfo::new("test".into());
let manifest = Manifest::create_empty(info, &root).unwrap();
let manifest = Arc::new(Mutex::new(manifest));
let (tx, rx) = tokio::sync::mpsc::channel(10);
let app = App::new(root, manifest, vec![], vec![], rx);
(app, tx)
}
#[test]
fn test_app_renders_without_panic() {
let tmp = TempDir::new().unwrap();
let (mut app, _tx) = make_app(&tmp);
let backend = TestBackend::new(100, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.render(f)).unwrap();
}
#[test]
fn test_app_renders_too_small() {
let tmp = TempDir::new().unwrap();
let (mut app, _tx) = make_app(&tmp);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.render(f)).unwrap();
}
#[test]
fn test_tab_cycles_focus() {
let tmp = TempDir::new().unwrap();
let (mut app, _tx) = make_app(&tmp);
assert_eq!(app.focus, Focus::Chat);
let key = crossterm::event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
app.handle_key(key);
assert_eq!(app.focus, Focus::Tree);
}
#[test]
fn test_quit_with_ctrl_c() {
let tmp = TempDir::new().unwrap();
let (mut app, _tx) = make_app(&tmp);
let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
app.handle_key(key);
assert!(app.should_quit);
}
#[test]
fn test_new_commit_refreshes_tree() {
let tmp = TempDir::new().unwrap();
let (mut app, tx) = make_app(&tmp);
assert!(app.tree.documents.is_empty());
{
let mut m = app.manifest.lock().unwrap();
m.register(&PathBuf::from("added.md"), crate::types::DocType::Plan, "")
.unwrap();
}
let entry = LogEntry {
commit_id: CommitId("abc123".into()),
timestamp: chrono::Utc::now(),
action: Action::Create,
actor: Actor::Agent {
name: "claude".into(),
},
agent_name: Some("claude".into()),
files: vec![(
PathBuf::from("added.md"),
Action::Create,
crate::types::DocType::Plan,
)],
summary: "mcp write: added.md".into(),
};
tx.blocking_send(UiEvent::NewCommit(entry)).unwrap();
while let Ok(event) = app.ui_rx.try_recv() {
app.handle_ui_event(event);
}
assert_eq!(app.tree.documents.len(), 1);
assert_eq!(app.tree.documents[0].path, PathBuf::from("added.md"));
assert_eq!(app.changelog.entries.len(), 1);
}
}