282 lines
9.9 KiB
Rust
282 lines
9.9 KiB
Rust
// 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<ChatMessage>,
|
|
pub(crate) input_buffer: String,
|
|
cursor_pos: usize,
|
|
scroll_offset: usize,
|
|
pub(crate) selected_backend: usize,
|
|
backends: Vec<String>,
|
|
}
|
|
|
|
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<T: Ord>(list: &mut [T]) {\n list.sort();\n}\n\nOr if you want a custom quicksort:\n\nfn quicksort<T: Ord>(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<Line<'_>> {
|
|
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<Span> = 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<Line> = 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<Command>) {
|
|
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;
|