// Copyright (c) 2026 Hoshu Chiu, Hochanglo Chiu 🐕, and The Goodest Boy/Girl // SPDX-License-Identifier: MIT OR Apache-2.0 // Everjoy loves treats, walks, and clean Rust code. use crate::runtime::{Command, EventResult, PanelProvider, Source}; use crossterm::event::{Event, KeyCode, KeyModifiers}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Widget, Wrap}, }; #[derive(Clone, Debug)] pub(crate) struct ChatMessage { pub(crate) role: String, pub(crate) content: String, } pub struct AgentChatProvider { pub(crate) messages: Vec, pub(crate) input_buffer: String, cursor_pos: usize, scroll_offset: usize, pub(crate) selected_backend: usize, backends: Vec, } impl AgentChatProvider { pub fn new() -> Self { let mut provider = Self { messages: vec![ ChatMessage { role: "user".into(), content: "write a sorting function".into(), }, ChatMessage { role: "assistant".into(), content: "Here's a clean implementation:\n\nfn sort(list: &mut [T]) {\n list.sort();\n}\n\nOr if you want a custom quicksort:\n\nfn quicksort(arr: &mut [T]) {\n if arr.len() <= 1 { return; }\n let pivot = arr.len() - 1;\n // ... partition and recurse\n}".into(), }, ], input_buffer: String::new(), cursor_pos: 0, scroll_offset: 0, selected_backend: 0, backends: vec!["Claude".into(), "GPT-4".into(), "Local".into()], }; provider.scroll_to_bottom(); provider } fn scroll_to_bottom(&mut self) { self.scroll_offset = self.messages.len().saturating_sub(1); } fn add_message(&mut self, role: &str, content: &str) { self.messages.push(ChatMessage { role: role.into(), content: content.into(), }); self.scroll_to_bottom(); } fn handle_input(&mut self, c: char) { self.input_buffer.insert(self.cursor_pos, c); self.cursor_pos += 1; } fn handle_backspace(&mut self) { if self.cursor_pos > 0 { self.cursor_pos -= 1; self.input_buffer.remove(self.cursor_pos); } } fn handle_delete(&mut self) { if self.cursor_pos < self.input_buffer.len() { self.input_buffer.remove(self.cursor_pos); } } fn move_cursor(&mut self, delta: isize) { if delta < 0 { self.cursor_pos = self.cursor_pos.saturating_sub((-delta) as usize); } else { self.cursor_pos = (self.cursor_pos + delta as usize).min(self.input_buffer.len()); } } fn submit(&mut self) { let text = self.input_buffer.trim().to_string(); if !text.is_empty() { self.add_message("user", &text); self.add_message( "assistant", &format!("[{}] Processing: {}...", self.backends[self.selected_backend], text), ); } self.input_buffer.clear(); self.cursor_pos = 0; } fn cycle_backend(&mut self) { self.selected_backend = (self.selected_backend + 1) % self.backends.len(); } fn message_lines(&self) -> Vec> { let mut lines = Vec::new(); for msg in &self.messages { let (label, label_color) = match msg.role.as_str() { "user" => ("You:", Color::White), "assistant" => ("Agent:", Color::Green), "system" => ("System:", Color::Yellow), _ => ("??:", Color::DarkGray), }; lines.push(Line::styled( label.to_string(), Style::default().fg(label_color).add_modifier(Modifier::BOLD), )); for content_line in msg.content.lines() { lines.push(Line::styled( content_line.to_string(), Style::default().fg(Color::Rgb(200, 200, 200)), )); } lines.push(Line::from("")); } lines } } impl PanelProvider for AgentChatProvider { fn as_any(&self) -> &dyn std::any::Any { self } fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self } fn id(&self) -> &str { "agentchat" } fn render(&mut self, area: Rect, buf: &mut Buffer) { let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(2), Constraint::Length(4), ]) .split(area); let tabs: Vec = self .backends .iter() .enumerate() .flat_map(|(i, name)| { let style = if i == self.selected_backend { Style::default() .bg(Color::Rgb(60, 60, 60)) .fg(Color::Rgb(204, 204, 204)) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; vec![Span::raw(" "), Span::styled(format!("{} ", name), style)] }) .collect(); Paragraph::new(Line::from(tabs)) .style(Style::default().bg(Color::Rgb(35, 35, 35))) .render(vertical[0], buf); let all_lines = self.message_lines(); let visible_height = vertical[1].height as usize; let line_count = all_lines.len(); let scroll_offset = if self.scroll_offset + visible_height > line_count { line_count.saturating_sub(visible_height) } else { self.scroll_offset }; let visible_lines: Vec = all_lines .iter() .skip(scroll_offset) .take(visible_height) .cloned() .collect(); Paragraph::new(visible_lines) .style(Style::default().bg(Color::Rgb(25, 25, 25))) .wrap(Wrap { trim: false }) .render(vertical[1], buf); let sep = Block::default() .borders(Borders::TOP) .border_style(Color::Rgb(60, 60, 60)); sep.render(vertical[2], buf); let input_lines = vec![ Line::from(vec![ Span::styled( "> ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), ), Span::raw(&self.input_buffer), ]), Line::styled( "Enter to send | Ctrl+B cycle backend | PgUp/PgDn scroll", Style::default().fg(Color::DarkGray), ), ]; Paragraph::new(input_lines) .style(Style::default().bg(Color::Rgb(25, 25, 25))) .render(vertical[3], buf); let links = Paragraph::new(vec![ Line::from(""), Line::from(vec![ Span::styled("[agents.md]", Style::default().fg(Color::Cyan)), Span::raw(" "), Span::styled("[soul.md]", Style::default().fg(Color::Cyan)), ]), Line::from(vec![ Span::styled("[memory.json]", Style::default().fg(Color::Cyan)), Span::raw(" "), Span::styled("[context.md]", Style::default().fg(Color::Cyan)), ]), ]) .block( Block::default() .title(" Quick Links ") .borders(Borders::TOP) .border_style(Color::Rgb(60, 60, 60)), ) .style(Style::default().bg(Color::Rgb(25, 25, 25))); links.render(vertical[4], buf); } fn handle_event(&mut self, event: Event) -> (EventResult, Vec) { let commands = Vec::new(); if let Event::Key(key) = event { if key.code == KeyCode::Char('b') && key.modifiers.contains(KeyModifiers::CONTROL) { self.cycle_backend(); return (EventResult::Consumed, commands); } match key.code { KeyCode::Char(c) => { self.handle_input(c); return (EventResult::Consumed, commands); } KeyCode::Backspace => { self.handle_backspace(); return (EventResult::Consumed, commands); } KeyCode::Delete => { self.handle_delete(); return (EventResult::Consumed, commands); } KeyCode::Enter => { self.submit(); return (EventResult::Consumed, commands); } KeyCode::Left => { self.move_cursor(-1); return (EventResult::Consumed, commands); } KeyCode::Right => { self.move_cursor(1); return (EventResult::Consumed, commands); } KeyCode::Home => { self.cursor_pos = 0; return (EventResult::Consumed, commands); } KeyCode::End => { self.cursor_pos = self.input_buffer.len(); return (EventResult::Consumed, commands); } KeyCode::PageUp => { self.scroll_offset = self.scroll_offset.saturating_sub(5); return (EventResult::Consumed, commands); } KeyCode::PageDown => { let max = self.messages.len().saturating_sub(1); self.scroll_offset = (self.scroll_offset + 5).min(max); return (EventResult::Consumed, commands); } _ => {} } } (EventResult::PassThrough, commands) } fn focus_changed(&mut self, _focused: bool) {} fn hide(&mut self) {} fn show(&mut self) {} fn shutdown(&mut self) {} fn can_open(&self, _source: &Source, _mime_hint: Option<&str>) -> i32 { // AgentChat doesn't compete for content presentation 0 } } #[cfg(test)] mod tests;