use super::{wrap_chat_message, CommandPalette, StatusLine, TuiPalette};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::Span,
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
#[derive(Debug, Clone, PartialEq)]
pub enum AppState {
Chatting,
RunningTask,
Palette,
FileBrowser,
Help,
Confirming(String),
GardenView,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
pub timestamp: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone)]
pub struct TaskProgress {
pub description: String,
pub current_step: usize,
pub total_steps: Option<usize>,
pub current_action: String,
pub elapsed_secs: u64,
}
pub const ANIMATION_SPEED_MIN: f64 = 0.25;
pub const ANIMATION_SPEED_MAX: f64 = 4.0;
pub const ANIMATION_SPEED_STEP: f64 = 0.25;
pub const ANIMATION_SPEED_DEFAULT: f64 = 1.0;
pub struct App {
pub state: AppState,
pub messages: Vec<ChatMessage>,
pub input: String,
pub cursor: usize,
pub palette: CommandPalette,
pub task_progress: Option<TaskProgress>,
pub status: String,
pub model: String,
pub connected: bool,
pub scroll: usize,
pub selected: usize,
pub animation_speed: f64,
pub verbose: bool,
pub garden_view: super::GardenView,
pub status_line: StatusLine,
pub skill_registry: Option<crate::skills::SkillRegistry>,
pub streaming_assistant: Option<String>,
}
impl App {
pub fn new(model: &str) -> Self {
Self {
state: AppState::Chatting,
messages: vec![ChatMessage {
role: MessageRole::System,
content: "Welcome to your workshop. How can I help you tend your garden today?"
.into(),
timestamp: chrono::Local::now().format("%H:%M").to_string(),
}],
input: String::new(),
cursor: 0,
palette: CommandPalette::new(),
task_progress: None,
status: "Ready".into(),
model: model.into(),
connected: false,
scroll: 0,
selected: 0,
animation_speed: ANIMATION_SPEED_DEFAULT,
verbose: false,
garden_view: super::GardenView::new(),
status_line: StatusLine::with_session(model),
skill_registry: None,
streaming_assistant: None,
}
}
pub fn add_user_message(&mut self, content: &str) {
self.messages.push(ChatMessage {
role: MessageRole::User,
content: content.into(),
timestamp: chrono::Local::now().format("%H:%M").to_string(),
});
}
pub fn append_streaming(&mut self, text: &str) {
self.connected = true;
self.streaming_assistant
.get_or_insert_with(String::new)
.push_str(text);
}
pub fn clear_streaming(&mut self) {
self.streaming_assistant = None;
}
pub fn commit_streaming(&mut self) {
if let Some(text) = self.streaming_assistant.take() {
let trimmed = text.trim();
if !trimmed.is_empty() {
self.add_assistant_message(trimmed);
}
}
}
pub fn add_assistant_message(&mut self, content: &str) {
self.connected = true;
self.messages.push(ChatMessage {
role: MessageRole::Assistant,
content: content.into(),
timestamp: chrono::Local::now().format("%H:%M").to_string(),
});
}
pub fn add_system_message(&mut self, content: &str) {
self.messages.push(ChatMessage {
role: MessageRole::System,
content: content.into(),
timestamp: chrono::Local::now().format("%H:%M").to_string(),
});
}
pub fn add_tool_message(&mut self, tool_name: &str, output: &str) {
self.messages.push(ChatMessage {
role: MessageRole::Tool,
content: format!("[{}] {}", tool_name, output),
timestamp: chrono::Local::now().format("%H:%M").to_string(),
});
}
pub fn clear_chat(&mut self) {
self.messages.clear();
self.clear_streaming();
self.task_progress = None;
self.state = AppState::Chatting;
self.status_line.reset_usage();
self.messages.push(ChatMessage {
role: MessageRole::System,
content: "Chat cleared.".into(),
timestamp: chrono::Local::now().format("%H:%M").to_string(),
});
self.scroll = 0;
}
pub fn set_progress(&mut self, progress: TaskProgress) {
self.task_progress = Some(progress);
self.state = AppState::RunningTask;
}
pub fn update_step_progress(&mut self, current_step: usize, total_steps: usize) {
match self.task_progress.as_mut() {
Some(p) => {
p.current_step = current_step;
p.total_steps = Some(total_steps);
}
None => {
self.set_progress(TaskProgress {
description: "Processing...".into(),
current_step,
total_steps: Some(total_steps),
current_action: "Working".into(),
elapsed_secs: 0,
});
}
}
}
pub fn clear_progress(&mut self) {
self.task_progress = None;
self.state = AppState::Chatting;
}
pub fn toggle_palette(&mut self) {
self.state = if self.state == AppState::Palette {
AppState::Chatting
} else {
AppState::Palette
};
}
pub fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
if self.state == AppState::GardenView {
self.render_header(
frame,
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3)])
.split(area)[0],
);
let main_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), ])
.split(area)[1];
self.garden_view.render(frame, main_area);
let status_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(1),
])
.split(area)[2];
self.render_status_bar(frame, status_area);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(1), ])
.split(area);
self.render_header(frame, chunks[0]);
self.render_messages(frame, chunks[1]);
self.render_input(frame, chunks[2]);
self.render_status_bar(frame, chunks[3]);
if self.state == AppState::Palette {
self.render_palette(frame, area);
}
}
fn header_title(&self) -> String {
match (&self.state, self.task_progress.as_ref()) {
(AppState::RunningTask, Some(p)) => {
let steps = match p.total_steps {
Some(total) => format!("{}/{}", p.current_step, total),
None => p.current_step.to_string(),
};
format!(
" 🦊 Selfware Workshop — {} · step {} · {} ",
self.model, steps, p.current_action
)
}
_ => format!(" 🦊 Selfware Workshop — {} ", self.model),
}
}
fn render_header(&self, frame: &mut Frame, area: Rect) {
let title = self.header_title();
let block = Block::default()
.borders(Borders::ALL)
.border_style(TuiPalette::border_style())
.title(Span::styled(title, TuiPalette::title_style()));
frame.render_widget(block, area);
}
fn render_messages(&self, frame: &mut Frame, area: Rect) {
let is_focused = self.state == AppState::Chatting || self.state == AppState::RunningTask;
let border_style = if is_focused {
TuiPalette::border_style()
} else {
TuiPalette::muted_style()
};
let title = if is_focused {
" Messages ".into()
} else {
format!(" Messages [{}] ", self.state_label())
};
let inner = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(title, border_style));
let inner_area = inner.inner(area);
frame.render_widget(inner, area);
let msg_width = inner_area.width as usize;
let items: Vec<ListItem> = self
.messages
.iter()
.rev()
.skip(self.scroll)
.map(|msg| {
let style = match msg.role {
MessageRole::User => Style::default().fg(TuiPalette::AMBER),
MessageRole::Assistant => Style::default().fg(TuiPalette::GARDEN_GREEN),
MessageRole::System => TuiPalette::muted_style(),
MessageRole::Tool => Style::default().fg(TuiPalette::COPPER),
};
let prefix = match msg.role {
MessageRole::User => "You",
MessageRole::Assistant => "🦊",
MessageRole::System => "📋",
MessageRole::Tool => "🔧",
};
let prefix_str = format!("{} {} ", msg.timestamp, prefix);
wrap_chat_message(&prefix_str, &msg.content, style, msg_width)
})
.collect();
let messages = List::new(items);
frame.render_widget(messages, inner_area);
}
fn render_input(&self, frame: &mut Frame, area: Rect) {
let is_focused = self.state == AppState::Chatting;
let border_style = if is_focused {
Style::default()
.fg(TuiPalette::AMBER)
.add_modifier(Modifier::BOLD)
} else {
TuiPalette::muted_style()
};
let title = if is_focused {
" Input ".into()
} else {
format!(" Input [{}] ", self.state_label())
};
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(title, border_style));
let inner = input_block.inner(area);
frame.render_widget(input_block, area);
let input_text = if is_focused {
Paragraph::new(format!("❯ {}", self.input))
.style(Style::default().fg(TuiPalette::PARCHMENT))
} else {
Paragraph::new(" — ".to_string()).style(TuiPalette::muted_style())
};
frame.render_widget(input_text, inner);
if is_focused {
frame.set_cursor_position((inner.x + 2 + self.cursor as u16, inner.y));
}
}
fn state_label(&self) -> &'static str {
match self.state {
AppState::Chatting => "chat",
AppState::RunningTask => "task",
AppState::Palette => "palette",
AppState::FileBrowser => "files",
AppState::Help => "help",
AppState::Confirming(_) => "confirm",
AppState::GardenView => "garden",
}
}
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
self.status_line.render(frame, area);
}
pub fn refresh_status_line(&mut self) {
self.status_line.connected = self.connected;
self.status_line.status_message = Some(self.status.clone());
}
fn render_palette(&self, frame: &mut Frame, area: Rect) {
let palette_width = 60.min(area.width - 4);
let palette_height = 15.min(area.height - 4);
let x = (area.width - palette_width) / 2;
let y = (area.height - palette_height) / 3;
let palette_area = Rect::new(x, y, palette_width, palette_height);
let clear = Block::default().style(Style::default().bg(TuiPalette::INK));
frame.render_widget(clear, palette_area);
self.palette.render(frame, palette_area, self.selected);
}
fn cursor_byte_offset(&self) -> usize {
self.input
.char_indices()
.nth(self.cursor)
.map(|(i, _)| i)
.unwrap_or(self.input.len())
}
pub fn on_char(&mut self, c: char) {
if self.state == AppState::Chatting {
let byte_idx = self.cursor_byte_offset();
self.input.insert(byte_idx, c);
self.cursor += 1;
} else if self.state == AppState::Palette {
self.palette.on_char(c);
}
}
pub fn on_backspace(&mut self) {
if self.state == AppState::Chatting && self.cursor > 0 {
self.cursor -= 1;
let byte_idx = self.cursor_byte_offset();
self.input.remove(byte_idx);
} else if self.state == AppState::Palette {
self.palette.on_backspace();
}
}
pub fn on_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn on_right(&mut self) {
if self.cursor < self.input.chars().count() {
self.cursor += 1;
}
}
pub fn on_up(&mut self) {
if self.state == AppState::Palette {
self.palette.previous();
} else if self.scroll + 1 < self.messages.len() {
self.scroll += 1;
}
}
pub fn on_down(&mut self) {
if self.state == AppState::Palette {
self.palette.next();
} else if self.scroll > 0 {
self.scroll -= 1;
}
}
pub fn on_enter(&mut self) -> Option<String> {
if self.state == AppState::Palette {
if let Some(cmd) = self.palette.selected_command() {
self.state = AppState::Chatting;
return Some(cmd);
}
None
} else if !self.input.is_empty() {
let input = std::mem::take(&mut self.input);
self.cursor = 0;
Some(input)
} else {
None
}
}
pub fn on_escape(&mut self) {
match self.state {
AppState::Palette => {
self.palette.reset();
self.state = AppState::Chatting;
self.status = "Command palette closed".into();
}
AppState::Confirming(_) => {
self.state = AppState::Chatting;
self.status = "Cancelled".into();
}
AppState::RunningTask => {
self.task_progress = None;
self.state = AppState::Chatting;
self.status = "Task view closed".into();
}
AppState::FileBrowser => {
self.state = AppState::Chatting;
self.status = "File browser closed".into();
}
AppState::Help => {
self.state = AppState::Chatting;
self.status = "Help closed".into();
}
AppState::GardenView => {
self.state = AppState::Chatting;
self.status = "Garden view closed".into();
}
AppState::Chatting => {
if !self.input.is_empty() {
self.input.clear();
self.cursor = 0;
self.status = "Input cleared".into();
} else {
self.state = AppState::Confirming("Press Enter to exit, Esc to cancel".into());
self.status = "Exit?".into();
}
}
}
}
pub fn on_plus(&mut self) {
self.animation_speed =
(self.animation_speed + ANIMATION_SPEED_STEP).min(ANIMATION_SPEED_MAX);
self.status = format!("Animation speed: {:.0}%", self.animation_speed * 100.0);
}
pub fn on_minus(&mut self) {
self.animation_speed =
(self.animation_speed - ANIMATION_SPEED_STEP).max(ANIMATION_SPEED_MIN);
self.status = format!("Animation speed: {:.0}%", self.animation_speed * 100.0);
}
pub fn animation_delay_ms(&self) -> u64 {
let base_delay = 100.0;
(base_delay / self.animation_speed) as u64
}
pub fn animation_speed_display(&self) -> String {
format!("{:.0}%", self.animation_speed * 100.0)
}
}
#[cfg(test)]
#[path = "../../../tests/unit/ui/tui/app/app_test.rs"]
mod tests;