diff --git a/crates/goose-tui/src/app.rs b/crates/goose-tui/src/app.rs index f30b338be8..9a7ede0940 100644 --- a/crates/goose-tui/src/app.rs +++ b/crates/goose-tui/src/app.rs @@ -1,11 +1,11 @@ use crate::components::chat::ChatComponent; -use crate::components::input::InputComponent; use crate::components::info::InfoComponent; +use crate::components::input::InputComponent; use crate::components::popups::builder::BuilderPopup; use crate::components::popups::help::HelpPopup; +use crate::components::popups::message::MessagePopup; use crate::components::popups::session::SessionPopup; use crate::components::popups::todo::TodoPopup; -use crate::components::popups::message::MessagePopup; use crate::components::status::StatusComponent; use crate::components::Component; use crate::services::events::Event; @@ -28,6 +28,12 @@ pub struct App<'a> { message_popup: MessagePopup, } +impl<'a> Default for App<'a> { + fn default() -> Self { + Self::new() + } +} + impl<'a> App<'a> { pub fn new() -> Self { Self { @@ -53,7 +59,7 @@ impl<'a> Component for App<'a> { return Ok(Some(Action::SessionsListLoaded(sessions.clone()))) } Event::SessionResumed(session) => { - return Ok(Some(Action::SessionResumed(session.clone()))) + return Ok(Some(Action::SessionResumed((*session).clone()))) } Event::ToolsLoaded(tools) => return Ok(Some(Action::ToolsLoaded(tools.clone()))), Event::Error(e) => return Ok(Some(Action::Error(e.clone()))), diff --git a/crates/goose-tui/src/components/chat.rs b/crates/goose-tui/src/components/chat.rs index f35d123fc1..5fb5fa21d8 100644 --- a/crates/goose-tui/src/components/chat.rs +++ b/crates/goose-tui/src/components/chat.rs @@ -25,6 +25,12 @@ pub struct ChatComponent { last_input_mode: InputMode, } +impl Default for ChatComponent { + fn default() -> Self { + Self::new() + } +} + impl ChatComponent { pub fn new() -> Self { Self { @@ -84,12 +90,15 @@ impl ChatComponent { let max_args = 50; let display_args = if tool_args.chars().count() > max_args { - format!("{}...", tool_args.chars().take(max_args).collect::()) + format!( + "{}...", + tool_args.chars().take(max_args).collect::() + ) } else { tool_args }; - let header = format!("{} {}", tool_name, display_args); + let header = format!("{tool_name} {display_args}"); let fixed = 5; let padding = width.saturating_sub(header.len() + fixed + 2); // +2 for borders roughly @@ -100,7 +109,7 @@ impl ChatComponent { Style::default().fg(color).add_modifier(Modifier::BOLD), ), Span::styled( - format!(" {} ", display_args), + format!(" {display_args} "), Style::default().fg(Color::Gray), ), Span::styled( @@ -197,7 +206,7 @@ impl ChatComponent { Style::default().fg(theme.status.warning), ), Span::styled( - format!("{} (args hidden)", name), + format!("{name} (args hidden)"), Style::default().fg(theme.base.foreground), ), ]); @@ -246,37 +255,12 @@ impl Component for ChatComponent { MouseEventKind::ScrollDown => { let cur = self.list_state.selected().unwrap_or(0); self.list_state.select(Some(cur + 3)); - // Check if at bottom? Hard to know without total length. - // We will check in render. } MouseEventKind::Down(crossterm::event::MouseButton::Left) => { - // On click, select the item at the clicked row? - // Ratatui List doesn't auto-select on click easily without calculating rows. - // But if we just want to open the *currently selected* item, we can assume - // the user selected it (maybe via scroll/hover if we implemented that, but we haven't). - - // Actually, standard TUI behavior for click is: - // 1. Calculate index based on y-coord. - // 2. Select it. - // 3. Trigger action. - - // Calculating index is hard here because we don't know the exact layout area offset in handle_event. - // However, if we just treat Click as "Open Selected" (if selection follows mouse?), - // or just "Open Selected" (Enter equivalent). - - // If the user clicks, they expect the thing under the cursor to open. - // Implementing full mouse picking requires `area` which we don't have in `handle_event`. - // But we can just map Click -> OpenMessageInfo of *selected* item if we assume selection is updated. - // But selection isn't updated by mouse hover in this code. - - // Compromise: Treat Left Click as "Open Details of Selected Item". - // The user can scroll to select (which updates selection) then click? - // No, scrolling updates selection. - if let Some(idx) = self.list_state.selected() { - if let Some(&msg_idx) = self.cached_mapping.get(idx) { - return Ok(Some(Action::OpenMessageInfo(msg_idx))); - } + if let Some(&msg_idx) = self.cached_mapping.get(idx) { + return Ok(Some(Action::OpenMessageInfo(msg_idx))); + } } } _ => {} @@ -290,19 +274,20 @@ impl Component for ChatComponent { let cur = self.list_state.selected().unwrap_or(0); self.list_state.select(Some(cur.saturating_sub(1))); } - KeyCode::Char('j') | KeyCode::Down => { - let cur = self.list_state.selected().unwrap_or(0); - self.list_state.select(Some(cur + 1)); - } - KeyCode::Enter => { - if let Some(idx) = self.list_state.selected() { - if let Some(&msg_idx) = self.cached_mapping.get(idx) { - return Ok(Some(Action::OpenMessageInfo(msg_idx))); - } - } - } - KeyCode::Esc => return Ok(Some(Action::ToggleInputMode)), - _ => {} } + KeyCode::Char('j') | KeyCode::Down => { + let cur = self.list_state.selected().unwrap_or(0); + self.list_state.select(Some(cur + 1)); + } + KeyCode::Enter => { + if let Some(idx) = self.list_state.selected() { + if let Some(&msg_idx) = self.cached_mapping.get(idx) { + return Ok(Some(Action::OpenMessageInfo(msg_idx))); + } + } + } + KeyCode::Esc => return Ok(Some(Action::ToggleInputMode)), + _ => {} + } } } _ => {} @@ -374,11 +359,10 @@ impl Component for ChatComponent { } // Auto-scroll logic - if self.stick_to_bottom { - if !display_items.is_empty() { + if self.stick_to_bottom + && !display_items.is_empty() { self.list_state.select(Some(display_items.len() - 1)); } - } // Render Logo if empty if display_items.is_empty() { @@ -391,7 +375,7 @@ impl Component for ChatComponent { } }; let (r, g, b) = Self::to_rgb(base_color); - + let (dr, dg, db) = if state.is_working { let t = self.frame_count as f32 * 0.1; let factor = 0.85 + 0.15 * t.sin(); @@ -409,21 +393,10 @@ impl Component for ChatComponent { .lines() .map(|l| Line::from(Span::styled(l, Style::default().fg(logo_color)))) .collect(); - let hints = vec![ - "Tips for getting started:", + let hints = ["Tips for getting started:", "1. Ask questions, edit files, or run commands.", "2. Be specific for the best results.", - "3. Type /help for more information.", - ]; - - // Render to a separate area or as list items? - // If I render as list items, I can just add them to display_items. - // But they shouldn't be selectable or part of history. - // Better to render Paragraph inside the block. - // But I am rendering List. - - // I'll render the List as empty block, and render Paragraph on top? - // Yes. + "3. Type /help for more information."]; let block = Block::default() .borders(Borders::ALL) @@ -439,7 +412,7 @@ impl Component for ChatComponent { let inner_area = block.inner(area); // Render at top-left, with 2 units of padding from left and top - let logo_start_y = inner_area.y + 1; + let logo_start_y = inner_area.y + 1; let logo_start_x = inner_area.x + 2; let logo_area = Rect::new( diff --git a/crates/goose-tui/src/components/info.rs b/crates/goose-tui/src/components/info.rs index e131eff421..f12761083d 100644 --- a/crates/goose-tui/src/components/info.rs +++ b/crates/goose-tui/src/components/info.rs @@ -14,6 +14,12 @@ pub struct InfoComponent { pun_index: usize, } +impl Default for InfoComponent { + fn default() -> Self { + Self::new() + } +} + impl InfoComponent { pub fn new() -> Self { Self { @@ -72,85 +78,79 @@ impl Component for InfoComponent { .fg(theme.status.warning) .add_modifier(Modifier::BOLD), )); - } else { - if state.is_working { - // Animated spinner - let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let spinner = spinner_frames[(self.frame_count / 4) % spinner_frames.len()]; + } else if state.is_working { + let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let spinner = spinner_frames[(self.frame_count / 4) % spinner_frames.len()]; + + spans.push(Span::styled( + format!("{spinner} "), + Style::default() + .fg(theme.status.thinking) + .add_modifier(Modifier::BOLD | Modifier::ITALIC), + )); + + if !state.todos.is_empty() { + let active_task = state + .todos + .iter() + .find(|item| !item.done) + .map(|item| item.text.as_str()) + .unwrap_or("Done!"); + let total = state.todos.len(); + let completed = state.todos.iter().filter(|item| item.done).count(); spans.push(Span::styled( - format!("{} ", spinner), + format!("{active_task} ({completed}/{total}) "), Style::default() - .fg(theme.status.thinking) - .add_modifier(Modifier::BOLD | Modifier::ITALIC), + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), )); - - if !state.todos.is_empty() { - // Show active todo - let active_task = state - .todos - .iter() - .find(|item| !item.done) - .map(|item| item.text.as_str()) - .unwrap_or("Done!"); - let total = state.todos.len(); - let completed = state.todos.iter().filter(|item| item.done).count(); - - spans.push(Span::styled( - format!("{} ({}/{}) ", active_task, completed, total), - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - )); - } else { - // Show pun - spans.push(Span::styled( - self.get_pun(), - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - )); - } } else { - // Static loading icon when not working spans.push(Span::styled( - "⠿ ", + self.get_pun(), Style::default() - .fg(theme.status.thinking) - .add_modifier(Modifier::BOLD), + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), )); + } + } else { + spans.push(Span::styled( + "⠿ ", + Style::default() + .fg(theme.status.thinking) + .add_modifier(Modifier::BOLD), + )); - if !state.todos.is_empty() { - let total = state.todos.len(); - let completed = state.todos.iter().filter(|item| item.done).count(); - let active_task = state - .todos - .iter() - .find(|item| !item.done) - .map(|item| item.text.as_str()) - .unwrap_or("All tasks completed!"); + if !state.todos.is_empty() { + let total = state.todos.len(); + let completed = state.todos.iter().filter(|item| item.done).count(); + let active_task = state + .todos + .iter() + .find(|item| !item.done) + .map(|item| item.text.as_str()) + .unwrap_or("All tasks completed!"); - spans.push(Span::styled( - format!("{} ({}/{}) ", active_task, completed, total), - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - )); - } else if !state.has_worked { - spans.push(Span::styled( - "goose 1.14.0", - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - )); - } else { - spans.push(Span::styled( - "Waiting for user input...", - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - )); - } + spans.push(Span::styled( + format!("{active_task} ({completed}/{total}) "), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), + )); + } else if !state.has_worked { + spans.push(Span::styled( + "goose 1.14.0", + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), + )); + } else { + spans.push(Span::styled( + "Waiting for user input...", + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), + )); } } diff --git a/crates/goose-tui/src/components/input.rs b/crates/goose-tui/src/components/input.rs index ded67e3161..2877565c16 100644 --- a/crates/goose-tui/src/components/input.rs +++ b/crates/goose-tui/src/components/input.rs @@ -6,8 +6,8 @@ use anyhow::Result; use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem}; use ratatui::text::Span; +use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem}; use ratatui::Frame; use tui_textarea::TextArea; @@ -17,6 +17,12 @@ pub struct InputComponent<'a> { last_is_empty: bool, } +impl<'a> Default for InputComponent<'a> { + fn default() -> Self { + Self::new() + } +} + impl<'a> InputComponent<'a> { pub fn new() -> Self { let mut textarea = TextArea::default(); @@ -105,7 +111,6 @@ impl<'a> Component for InputComponent<'a> { self.textarea.set_placeholder_text("Type a message..."); self.textarea.set_cursor_line_style(Style::default()); // Disable underline - // Slash Commands if trimmed.starts_with('/') { let parts: Vec<&str> = trimmed.split_whitespace().collect(); let cmd = parts[0]; @@ -118,7 +123,9 @@ impl<'a> Component for InputComponent<'a> { "/clear" => return Ok(Some(Action::ClearChat)), "/theme" => { if let Some(theme_name) = parts.get(1) { - return Ok(Some(Action::ChangeTheme(theme_name.to_string()))); + return Ok(Some(Action::ChangeTheme( + theme_name.to_string(), + ))); } return Ok(None); } @@ -131,21 +138,21 @@ impl<'a> Component for InputComponent<'a> { .iter() .find(|c| c.name == cmd_name) { - // Send a text message describing the tool call. - // The agent will then decide to call the tool. - // This matches the user's expectation of "templated 'Please run this tool' message". let message_text = format!( "Please run the tool '{}' with these arguments: {}", custom.tool, custom.args ); - let message = goose::conversation::message::Message::user().with_text(message_text); + let message = + goose::conversation::message::Message::user() + .with_text(message_text); return Ok(Some(Action::SendMessage(message))); } } } } - let message = goose::conversation::message::Message::user().with_text(&text); + let message = + goose::conversation::message::Message::user().with_text(&text); self.last_is_empty = true; return Ok(Some(Action::SendMessage(message))); } @@ -206,54 +213,68 @@ impl<'a> Component for InputComponent<'a> { .bg(theme.base.background), ); - // Sync cursor style self.textarea .set_cursor_style(Style::default().bg(theme.base.cursor)); f.render_widget(&self.textarea, area); - // Render Slash Commands Popup if state.input_mode == InputMode::Editing { if let Some(first_line) = self.textarea.lines().first() { if first_line.starts_with('/') { let mut commands = vec![ - "/exit", "/quit", "/help", "/todos", "/session", "/alias", "/clear", "/theme", + "/exit", "/quit", "/help", "/todos", "/session", "/alias", "/clear", + "/theme", ]; - let custom: Vec = state.config.custom_commands.iter().map(|c| format!("/{}", c.name)).collect(); + let custom: Vec = state + .config + .custom_commands + .iter() + .map(|c| format!("/{}", c.name)) + .collect(); let custom_refs: Vec<&str> = custom.iter().map(|s| s.as_str()).collect(); commands.extend(custom_refs); commands.sort(); - let filtered: Vec<&str> = commands.iter().filter(|c| c.starts_with(first_line)).cloned().collect(); + let filtered: Vec<&str> = commands + .iter() + .filter(|c| c.starts_with(first_line)) + .cloned() + .collect(); if !filtered.is_empty() { - // Taller popup: 50% of screen height let max_height = f.area().height / 2; let content_height = filtered.len() as u16 + 2; - let height = content_height.min(max_height).max(3); - + let height = content_height.min(max_height).max(3); + let width = 30; - let popup_area = Rect::new( - area.x, - area.y.saturating_sub(height), - width, - height, - ); + let popup_area = + Rect::new(area.x, area.y.saturating_sub(height), width, height); f.render_widget(Clear, popup_area); - let items: Vec = filtered.iter().map(|c| { - ListItem::new(Span::styled(*c, Style::default().fg(theme.base.foreground))) - }).collect(); + let items: Vec = filtered + .iter() + .map(|c| { + ListItem::new(Span::styled( + *c, + Style::default().fg(theme.base.foreground), + )) + }) + .collect(); let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title("Commands")) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title("Commands"), + ) .style(Style::default().bg(theme.base.background)); - + f.render_widget(list, popup_area); } } } } } -} \ No newline at end of file +} diff --git a/crates/goose-tui/src/components/mod.rs b/crates/goose-tui/src/components/mod.rs index 763f71ca50..7159a3f08a 100644 --- a/crates/goose-tui/src/components/mod.rs +++ b/crates/goose-tui/src/components/mod.rs @@ -1,8 +1,8 @@ pub mod chat; -pub mod input; -pub mod status; pub mod info; +pub mod input; pub mod popups; +pub mod status; use crate::services::events::Event; use crate::state::action::Action; diff --git a/crates/goose-tui/src/components/popups/builder.rs b/crates/goose-tui/src/components/popups/builder.rs index 925703b5a6..6bd741c7de 100644 --- a/crates/goose-tui/src/components/popups/builder.rs +++ b/crates/goose-tui/src/components/popups/builder.rs @@ -15,8 +15,8 @@ use std::collections::HashMap; use tui_textarea::TextArea; enum Step { - Menu, // Shows "Remove existing alias" and Tools list combined - DeleteSelect, // Secondary page to select alias to remove + Menu, // Shows "Remove existing alias" and Tools list combined + DeleteSelect, // Secondary page to select alias to remove ConfigureArg(usize), // Index of argument in tool.parameters NameCommand, } @@ -25,17 +25,21 @@ pub struct BuilderPopup<'a> { step: Step, list_state: ListState, input: TextArea<'a>, - - // Builder state selected_tool_idx: Option, arg_values: HashMap, } +impl<'a> Default for BuilderPopup<'a> { + fn default() -> Self { + Self::new() + } +} + impl<'a> BuilderPopup<'a> { pub fn new() -> Self { let mut input = TextArea::default(); input.set_block(Block::default().borders(Borders::ALL)); - + Self { step: Step::Menu, list_state: ListState::default(), @@ -47,7 +51,7 @@ impl<'a> BuilderPopup<'a> { fn reset(&mut self) { self.step = Step::Menu; - self.list_state.select(Some(0)); // Highlight "Remove existing alias..." by default + self.list_state.select(Some(0)); self.selected_tool_idx = None; self.arg_values.clear(); self.input = TextArea::default(); @@ -69,12 +73,13 @@ impl<'a> Component for BuilderPopup<'a> { KeyCode::Esc | KeyCode::Char('q') => { self.reset(); return Ok(Some(Action::ClosePopup)); - }, + } KeyCode::Char('j') | KeyCode::Down => { let idx = self.list_state.selected().unwrap_or(0); - let max = state.available_tools.len(); // Tools count - let total_items = max + 1; // +1 for "Remove existing alias..." item - self.list_state.select(Some((idx + 1).min(total_items.saturating_sub(1)))); + let max = state.available_tools.len(); + let total_items = max + 1; + self.list_state + .select(Some((idx + 1).min(total_items.saturating_sub(1)))); } KeyCode::Char('k') | KeyCode::Up => { let idx = self.list_state.selected().unwrap_or(0); @@ -83,14 +88,12 @@ impl<'a> Component for BuilderPopup<'a> { KeyCode::Enter => { let idx = self.list_state.selected().unwrap_or(0); if idx == 0 { - // Remove existing alias self.step = Step::DeleteSelect; self.list_state.select(Some(0)); self.selected_tool_idx = None; self.input = TextArea::default(); self.input.set_block(Block::default().borders(Borders::ALL)); } else { - // Tool selected (offset by 1) let tool_idx = idx - 1; if state.available_tools.get(tool_idx).is_some() { self.selected_tool_idx = Some(tool_idx); @@ -129,7 +132,7 @@ impl<'a> Component for BuilderPopup<'a> { KeyCode::Enter => { if let Some(idx) = self.list_state.selected() { if let Some(cmd) = state.config.custom_commands.get(idx) { - self.reset(); // Reset for next time + self.reset(); return Ok(Some(Action::DeleteCustomCommand(cmd.name.clone()))); } } @@ -141,16 +144,15 @@ impl<'a> Component for BuilderPopup<'a> { Step::ConfigureArg(arg_idx) => { match key.code { KeyCode::Esc => { - // Back to menu self.step = Step::Menu; } KeyCode::Enter => { let tool = &state.available_tools[self.selected_tool_idx.unwrap()]; let arg_name = &tool.parameters[arg_idx]; let value = self.input.lines().join("\n"); - + self.arg_values.insert(arg_name.clone(), value); - + if arg_idx + 1 < tool.parameters.len() { self.step = Step::ConfigureArg(arg_idx + 1); self.input = TextArea::default(); @@ -169,27 +171,27 @@ impl<'a> Component for BuilderPopup<'a> { Step::NameCommand => { match key.code { KeyCode::Esc => { - // Back to menu self.step = Step::Menu; } KeyCode::Enter => { let name = self.input.lines().join("\n").trim().to_string(); if !name.is_empty() { let tool = &state.available_tools[self.selected_tool_idx.unwrap()]; - + let mut args_map = serde_json::Map::new(); for (k, v) in &self.arg_values { - args_map.insert(k.clone(), serde_json::Value::String(v.clone())); + args_map + .insert(k.clone(), serde_json::Value::String(v.clone())); } - + let cmd = CustomCommand { name: name.replace("/", ""), description: format!("Alias for {}", tool.name), tool: tool.name.clone(), args: serde_json::Value::Object(args_map), }; - - self.reset(); // Reset for next time + + self.reset(); return Ok(Some(Action::SubmitCommandBuilder(cmd))); } } @@ -211,38 +213,51 @@ impl<'a> Component for BuilderPopup<'a> { let area = centered_rect(70, 60, area); f.render_widget(Clear, area); - let block = Block::default() + let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::default().bg(state.config.theme.base.background)); match self.step { Step::Menu => { - let mut items = vec![ - ListItem::new(Span::styled( - "Remove existing alias...", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - )), - ]; - + let mut items = vec![ListItem::new(Span::styled( + "Remove existing alias...", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))]; + for tool in &state.available_tools { items.push(ListItem::new(vec![ - Line::from(Span::styled(&tool.name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))), - Line::from(Span::styled(&tool.description, Style::default().fg(Color::Gray))), + Line::from(Span::styled( + &tool.name, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + &tool.description, + Style::default().fg(Color::Gray), + )), ])); } let list = List::new(items) .block(block.title("Command Builder: Select Tool (Enter to Select)")) - .highlight_style(Style::default().bg(state.config.theme.base.selection).add_modifier(Modifier::BOLD)); - + .highlight_style( + Style::default() + .bg(state.config.theme.base.selection) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, area, &mut self.list_state); } Step::DeleteSelect => { - let items: Vec = state.config.custom_commands.iter().map(|c| { - ListItem::new(format!("/{} -> {}", c.name, c.tool)) - }).collect(); - + let items: Vec = state + .config + .custom_commands + .iter() + .map(|c| ListItem::new(format!("/{} -> {}", c.name, c.tool))) + .collect(); + if items.is_empty() { let p = Paragraph::new("No aliases defined.") .block(block.title("Remove Alias")) @@ -251,26 +266,43 @@ impl<'a> Component for BuilderPopup<'a> { } else { let list = List::new(items) .block(block.title("Select Alias to Remove (Enter to Delete)")) - .highlight_style(Style::default().bg(state.config.theme.base.selection).add_modifier(Modifier::BOLD)); - + .highlight_style( + Style::default() + .bg(state.config.theme.base.selection) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, area, &mut self.list_state); } } Step::ConfigureArg(arg_idx) => { let tool = &state.available_tools[self.selected_tool_idx.unwrap()]; let arg_name = &tool.parameters[arg_idx]; - + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(3)]) .margin(1) .split(area); - - f.render_widget(block.title(format!("Configure '{}' ({}/{})", tool.name, arg_idx + 1, tool.parameters.len())), area); - + + f.render_widget( + block.title(format!( + "Configure '{}' ({}/{})", + tool.name, + arg_idx + 1, + tool.parameters.len() + )), + area, + ); + let mut lines = vec![Line::from("Enter value for argument:")]; - lines.push(Line::from(Span::styled(arg_name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))); - + lines.push(Line::from(Span::styled( + arg_name, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + f.render_widget(Paragraph::new(lines), chunks[0]); f.render_widget(&self.input, chunks[1]); } @@ -280,19 +312,23 @@ impl<'a> Component for BuilderPopup<'a> { .constraints([Constraint::Min(1), Constraint::Length(3)]) .margin(1) .split(area); - + f.render_widget(block.title("Final Step: Name Command"), area); - + let mut summary = vec![Line::from("Command Summary:")]; let tool = &state.available_tools[self.selected_tool_idx.unwrap()]; summary.push(Line::from(format!("Tool: {}", tool.name))); for (k, v) in &self.arg_values { - summary.push(Line::from(format!(" {}: {}", k, v))); + summary.push(Line::from(format!(" {k}: {v}"))); } - + f.render_widget(Paragraph::new(summary), chunks[0]); - - self.input.set_block(Block::default().borders(Borders::ALL).title("Alias Name (e.g. mycmd)")); + + self.input.set_block( + Block::default() + .borders(Borders::ALL) + .title("Alias Name (e.g. mycmd)"), + ); f.render_widget(&self.input, chunks[1]); } } diff --git a/crates/goose-tui/src/components/popups/message.rs b/crates/goose-tui/src/components/popups/message.rs index 4ecf5c747d..17dd837448 100644 --- a/crates/goose-tui/src/components/popups/message.rs +++ b/crates/goose-tui/src/components/popups/message.rs @@ -9,7 +9,10 @@ use goose::conversation::message::MessageContent; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}; +use ratatui::widgets::{ + Block, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, + Wrap, +}; use ratatui::Frame; use std::time::Instant; @@ -18,9 +21,15 @@ pub struct MessagePopup { last_scroll_time: Option, } +impl Default for MessagePopup { + fn default() -> Self { + Self::new() + } +} + impl MessagePopup { pub fn new() -> Self { - Self { + Self { scroll: 0, last_scroll_time: None, } @@ -30,33 +39,29 @@ impl MessagePopup { impl Component for MessagePopup { fn handle_event(&mut self, event: &Event, _state: &AppState) -> Result> { match event { - Event::Input(key) => { - match key.code { - KeyCode::Esc | KeyCode::Char('q') => return Ok(Some(Action::ClosePopup)), - KeyCode::Char('j') | KeyCode::Down => { - self.scroll = self.scroll.saturating_add(1); - self.last_scroll_time = Some(Instant::now()); - } - KeyCode::Char('k') | KeyCode::Up => { - self.scroll = self.scroll.saturating_sub(1); - self.last_scroll_time = Some(Instant::now()); - } - _ => {} + Event::Input(key) => match key.code { + KeyCode::Esc | KeyCode::Char('q') => return Ok(Some(Action::ClosePopup)), + KeyCode::Char('j') | KeyCode::Down => { + self.scroll = self.scroll.saturating_add(1); + self.last_scroll_time = Some(Instant::now()); } - } - Event::Mouse(m) => { - match m.kind { - MouseEventKind::ScrollDown => { - self.scroll = self.scroll.saturating_add(3); - self.last_scroll_time = Some(Instant::now()); - } - MouseEventKind::ScrollUp => { - self.scroll = self.scroll.saturating_sub(3); - self.last_scroll_time = Some(Instant::now()); - } - _ => {} + KeyCode::Char('k') | KeyCode::Up => { + self.scroll = self.scroll.saturating_sub(1); + self.last_scroll_time = Some(Instant::now()); } - } + _ => {} + }, + Event::Mouse(m) => match m.kind { + MouseEventKind::ScrollDown => { + self.scroll = self.scroll.saturating_add(3); + self.last_scroll_time = Some(Instant::now()); + } + MouseEventKind::ScrollUp => { + self.scroll = self.scroll.saturating_sub(3); + self.last_scroll_time = Some(Instant::now()); + } + _ => {} + }, _ => {} } Ok(None) @@ -77,8 +82,7 @@ impl Component for MessagePopup { f.render_widget(Clear, area); let mut lines = Vec::new(); - - // Render full content + for content in &message.content { match content { MessageContent::Text(t) => { @@ -87,7 +91,12 @@ impl Component for MessagePopup { } } MessageContent::ToolRequest(req) => { - lines.push(Line::from(Span::styled("Tool Request:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))); + lines.push(Line::from(Span::styled( + "Tool Request:", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); if let Ok(call) = &req.tool_call { lines.push(Line::from(format!("Name: {}", call.name))); if let Some(args) = &call.arguments { @@ -99,14 +108,25 @@ impl Component for MessagePopup { } } MessageContent::ToolResponse(resp) => { - lines.push(Line::from(Span::styled("Tool Output:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))); + lines.push(Line::from(Span::styled( + "Tool Output:", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); if let Ok(contents) = &resp.tool_result { for content in contents { - if let rmcp::model::Content { raw: rmcp::model::RawContent::Text(text_content), .. } = content { - // Pretty print JSON if possible + if let rmcp::model::Content { + raw: rmcp::model::RawContent::Text(text_content), + .. + } = content + { let text = &text_content.text; - let display_string = if let Ok(v) = serde_json::from_str::(text) { - serde_json::to_string_pretty(&v).unwrap_or_else(|_| text.to_string()) + let display_string = if let Ok(v) = + serde_json::from_str::(text) + { + serde_json::to_string_pretty(&v) + .unwrap_or_else(|_| text.to_string()) } else { text.to_string() }; @@ -141,7 +161,7 @@ impl Component for MessagePopup { let mut scrollbar_state = ScrollbarState::default() .content_length(content_height as usize) .position(self.scroll as usize); - + f.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) @@ -152,4 +172,4 @@ impl Component for MessagePopup { } } } -} \ No newline at end of file +} diff --git a/crates/goose-tui/src/components/popups/mod.rs b/crates/goose-tui/src/components/popups/mod.rs index c815abae4e..71d4196bee 100644 --- a/crates/goose-tui/src/components/popups/mod.rs +++ b/crates/goose-tui/src/components/popups/mod.rs @@ -1,5 +1,5 @@ pub mod builder; pub mod help; +pub mod message; pub mod session; pub mod todo; -pub mod message; diff --git a/crates/goose-tui/src/components/popups/session.rs b/crates/goose-tui/src/components/popups/session.rs index 36befdb04d..726271bd6d 100644 --- a/crates/goose-tui/src/components/popups/session.rs +++ b/crates/goose-tui/src/components/popups/session.rs @@ -19,6 +19,12 @@ pub struct SessionPopup { scroll_state: ScrollbarState, } +impl Default for SessionPopup { + fn default() -> Self { + Self::new() + } +} + impl SessionPopup { pub fn new() -> Self { Self { @@ -64,7 +70,7 @@ impl Component for SessionPopup { } _ => {} } - // Update scrollbar position + if let Some(idx) = self.list_state.selected() { self.scroll_state = self.scroll_state.position(idx); } @@ -82,7 +88,7 @@ impl Component for SessionPopup { } _ => {} } - // Update scrollbar position + if let Some(idx) = self.list_state.selected() { self.scroll_state = self.scroll_state.position(idx); } @@ -100,7 +106,6 @@ impl Component for SessionPopup { let area = centered_rect(60, 60, area); f.render_widget(Clear, area); - // Update scroll content length self.scroll_state = self .scroll_state .content_length(state.available_sessions.len()); @@ -110,7 +115,7 @@ impl Component for SessionPopup { .iter() .map(|s| { let id = Span::styled( - &s.id, // Full ID + &s.id, Style::default().fg(Color::Cyan), ); let count = Span::styled( diff --git a/crates/goose-tui/src/components/popups/todo.rs b/crates/goose-tui/src/components/popups/todo.rs index c44c0330a9..3a6e0d3b06 100644 --- a/crates/goose-tui/src/components/popups/todo.rs +++ b/crates/goose-tui/src/components/popups/todo.rs @@ -7,7 +7,9 @@ use crossterm::event::{KeyCode, MouseEventKind}; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}; +use ratatui::widgets::{ + Block, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, +}; use ratatui::Frame; use std::time::Instant; @@ -16,9 +18,15 @@ pub struct TodoPopup { last_scroll_time: Option, } +impl Default for TodoPopup { + fn default() -> Self { + Self::new() + } +} + impl TodoPopup { pub fn new() -> Self { - Self { + Self { scroll: 0, last_scroll_time: None, } @@ -48,34 +56,30 @@ impl TodoPopup { impl Component for TodoPopup { fn handle_event(&mut self, event: &Event, _state: &AppState) -> Result> { match event { - Event::Input(key) => { - match key.code { - KeyCode::Esc => return Ok(Some(Action::ClosePopup)), - KeyCode::Char('q') => return Ok(Some(Action::ClosePopup)), - KeyCode::Char('j') | KeyCode::Down => { - self.scroll = self.scroll.saturating_add(1); - self.last_scroll_time = Some(Instant::now()); - } - KeyCode::Char('k') | KeyCode::Up => { - self.scroll = self.scroll.saturating_sub(1); - self.last_scroll_time = Some(Instant::now()); - } - _ => {} + Event::Input(key) => match key.code { + KeyCode::Esc => return Ok(Some(Action::ClosePopup)), + KeyCode::Char('q') => return Ok(Some(Action::ClosePopup)), + KeyCode::Char('j') | KeyCode::Down => { + self.scroll = self.scroll.saturating_add(1); + self.last_scroll_time = Some(Instant::now()); } - } - Event::Mouse(m) => { - match m.kind { - MouseEventKind::ScrollDown => { - self.scroll = self.scroll.saturating_add(3); - self.last_scroll_time = Some(Instant::now()); - } - MouseEventKind::ScrollUp => { - self.scroll = self.scroll.saturating_sub(3); - self.last_scroll_time = Some(Instant::now()); - } - _ => {} + KeyCode::Char('k') | KeyCode::Up => { + self.scroll = self.scroll.saturating_sub(1); + self.last_scroll_time = Some(Instant::now()); } - } + _ => {} + }, + Event::Mouse(m) => match m.kind { + MouseEventKind::ScrollDown => { + self.scroll = self.scroll.saturating_add(3); + self.last_scroll_time = Some(Instant::now()); + } + MouseEventKind::ScrollUp => { + self.scroll = self.scroll.saturating_sub(3); + self.last_scroll_time = Some(Instant::now()); + } + _ => {} + }, _ => {} } Ok(None) @@ -130,7 +134,7 @@ impl Component for TodoPopup { let mut scrollbar_state = ScrollbarState::default() .content_length(content_height as usize) .position(self.scroll as usize); - + f.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) @@ -141,4 +145,4 @@ impl Component for TodoPopup { } } } -} \ No newline at end of file +} diff --git a/crates/goose-tui/src/components/status.rs b/crates/goose-tui/src/components/status.rs index 6afc29c86c..a04a18c7fd 100644 --- a/crates/goose-tui/src/components/status.rs +++ b/crates/goose-tui/src/components/status.rs @@ -8,6 +8,12 @@ use ratatui::Frame; pub struct StatusComponent; +impl Default for StatusComponent { + fn default() -> Self { + Self::new() + } +} + impl StatusComponent { pub fn new() -> Self { Self @@ -87,27 +93,52 @@ impl Component for StatusComponent { } } - // Hints let mut hints: Vec = Vec::new(); - // Ctrl+C Hint + if state.is_working { - hints.push(Span::styled("Ctrl+C Interrupt", Style::default().fg(theme.status.warning))); + hints.push(Span::styled( + "Ctrl+C Interrupt", + Style::default().fg(theme.status.warning), + )); } else if !state.input_text_is_empty { - hints.push(Span::styled("Ctrl+C Clear", Style::default().fg(Color::DarkGray))); + hints.push(Span::styled( + "Ctrl+C Clear", + Style::default().fg(Color::DarkGray), + )); } else { - hints.push(Span::styled("Ctrl+C Quit", Style::default().fg(Color::DarkGray))); + hints.push(Span::styled( + "Ctrl+C Quit", + Style::default().fg(Color::DarkGray), + )); } - // Mode-specific hints if state.input_mode == InputMode::Normal { - hints.push(Span::styled("i: Edit", Style::default().fg(Color::DarkGray))); - hints.push(Span::styled("Enter: View", Style::default().fg(Color::DarkGray))); - hints.push(Span::styled("j/k: Scroll", Style::default().fg(Color::DarkGray))); + hints.push(Span::styled( + "i: Edit", + Style::default().fg(Color::DarkGray), + )); + hints.push(Span::styled( + "Enter: View", + Style::default().fg(Color::DarkGray), + )); + hints.push(Span::styled( + "j/k: Scroll", + Style::default().fg(Color::DarkGray), + )); } else { - hints.push(Span::styled("Esc: Normal", Style::default().fg(Color::DarkGray))); - hints.push(Span::styled("Enter: Send", Style::default().fg(Color::DarkGray))); + hints.push(Span::styled( + "Esc: Normal", + Style::default().fg(Color::DarkGray), + )); + hints.push(Span::styled( + "Enter: Send", + Style::default().fg(Color::DarkGray), + )); } - hints.push(Span::styled("Ctrl+T: Todos", Style::default().fg(Color::DarkGray))); + hints.push(Span::styled( + "Ctrl+T: Todos", + Style::default().fg(Color::DarkGray), + )); for (i, hint_span) in hints.into_iter().enumerate() { if i > 0 { @@ -122,4 +153,4 @@ impl Component for StatusComponent { area, ); } -} \ No newline at end of file +} diff --git a/crates/goose-tui/src/main.rs b/crates/goose-tui/src/main.rs index 6360d0584d..168633737b 100644 --- a/crates/goose-tui/src/main.rs +++ b/crates/goose-tui/src/main.rs @@ -68,10 +68,9 @@ async fn main() -> Result<()> { let _guard = setup_tui_logging()?; info!("Starting Goose TUI..."); - // Generate a secure random token for this session let secret_key = Uuid::new_v4().to_string(); env::set_var("GOOSE_SERVER__SECRET_KEY", &secret_key); - env::set_var("GOOSE_PORT", "0"); // Ephemeral port + env::set_var("GOOSE_PORT", "0"); info!("Initializing embedded server..."); @@ -97,7 +96,6 @@ async fn main() -> Result<()> { .await? }; - // Configure Provider & Model let global_config = goose::config::Config::global(); let provider = global_config .get_goose_provider() @@ -111,7 +109,6 @@ async fn main() -> Result<()> { tracing::error!("Failed to update provider: {}", e); } - // Load Extensions for ext in goose::config::get_enabled_extensions() { if let Err(e) = client.add_extension(&initial_session.id, ext.clone()).await { tracing::error!("Failed to add extension {}: {}", ext.name(), e); @@ -121,7 +118,6 @@ async fn main() -> Result<()> { let config = TuiConfig::load()?; let mut state = AppState::new(initial_session.id.clone(), config); - // Calculate model context limit if let Some(ref model_name) = model { state.model_context_limit = goose::model::ModelConfig::new(model_name) .map(|config| config.context_limit()) @@ -130,33 +126,23 @@ async fn main() -> Result<()> { state.model_context_limit = 128_000; } - // Hydrate initial state state.messages = initial_session .conversation .map(|c| c.messages().clone()) .unwrap_or_default(); - // ... (reducer handles full hydration from SessionResumed, but here we do it manually or synthesize action) - // Let's manually hydrate for simplicity or dispatch SessionResumed immediately? - // Actually, we can just use the state initialized with `new`. - // AppState::new only sets ID. - // Let's manually set properties from initial_session. + state.token_state.total_tokens = initial_session.total_tokens.unwrap_or(0); state.token_state.input_tokens = initial_session.input_tokens.unwrap_or(0); state.token_state.output_tokens = initial_session.output_tokens.unwrap_or(0); - // Load Tools - // We should spawn a task to load tools let c_tools = client.clone(); let s_id = state.session_id.clone(); - // We need a channel to send tools back. - // We can use the main event loop channel. let mut terminal = tui::init()?; let mut app = App::new(); let mut event_handler = EventHandler::new(); let tx = event_handler.sender(); - // Initial Tools Load let tx_tools = tx.clone(); tokio::spawn(async move { if let Ok(tools) = c_tools.get_tools(&s_id).await { @@ -173,24 +159,25 @@ async fn main() -> Result<()> { let event = event_handler.next().await.unwrap(); - // Root component handles event -> Action if let Some(action) = app.handle_event(&event, &state)? { - // Side Effects match &action { - Action::SendMessage(message_to_send) => { - let client = client.clone(); - let tx = tx.clone(); - let mut messages_snapshot = state.messages.clone(); - messages_snapshot.push(message_to_send.clone()); - let session_id = state.session_id.clone(); - - let task = tokio::spawn(async move { - if let Err(e) = client.reply(messages_snapshot, session_id, tx.clone()).await { - let _ = tx.send(Event::Error(e.to_string())); - } - }); - reply_task = Some(task); - } + Action::SendMessage(message_to_send) => { + let client = client.clone(); + let tx = tx.clone(); + let mut messages_snapshot = state.messages.clone(); + messages_snapshot.push(message_to_send.clone()); + let session_id = state.session_id.clone(); + + let task = tokio::spawn(async move { + if let Err(e) = client + .reply(messages_snapshot, session_id, tx.clone()) + .await + { + let _ = tx.send(Event::Error(e.to_string())); + } + }); + reply_task = Some(task); + } Action::ResumeSession(id) => { let client = client.clone(); let tx = tx.clone(); @@ -198,7 +185,7 @@ async fn main() -> Result<()> { tokio::spawn(async move { match client.resume_agent(&id).await { Ok(s) => { - let _ = tx.send(Event::SessionResumed(s)); + let _ = tx.send(Event::SessionResumed(Box::new(s))); } Err(e) => { let _ = tx.send(Event::Error(e.to_string())); diff --git a/crates/goose-tui/src/services/client.rs b/crates/goose-tui/src/services/client.rs index d2395b6059..a753f94d23 100644 --- a/crates/goose-tui/src/services/client.rs +++ b/crates/goose-tui/src/services/client.rs @@ -1,15 +1,15 @@ +use crate::services::events::Event; +use crate::state::state::ToolInfo; use anyhow::{Context, Result}; use eventsource_stream::Eventsource; use goose::agents::ExtensionConfig; use goose::conversation::message::Message; use goose::session::Session; -use crate::state::state::ToolInfo; use goose_server::routes::reply::MessageEvent; use reqwest::Client as ReqwestClient; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tokio_stream::StreamExt; -use crate::services::events::Event; #[derive(Clone)] pub struct Client { @@ -53,7 +53,7 @@ struct SessionListResponse { impl Client { pub fn new(port: u16, secret_key: String) -> Self { Self { - base_url: format!("http://127.0.0.1:{}", port), + base_url: format!("http://127.0.0.1:{port}"), secret_key, http_client: ReqwestClient::new(), } @@ -214,7 +214,7 @@ impl Client { }) .send() .await? - .error_for_status()? // Check for 4xx/5xx responses + .error_for_status()? .bytes_stream() .eventsource(); diff --git a/crates/goose-tui/src/services/events.rs b/crates/goose-tui/src/services/events.rs index d60b196f4c..c2f296df98 100644 --- a/crates/goose-tui/src/services/events.rs +++ b/crates/goose-tui/src/services/events.rs @@ -16,7 +16,7 @@ pub enum Event { Resize, Server(Arc), SessionsList(Vec), - SessionResumed(Session), + SessionResumed(Box), ToolsLoaded(Vec), Error(String), } @@ -26,6 +26,12 @@ pub struct EventHandler { tx: mpsc::UnboundedSender, } +impl Default for EventHandler { + fn default() -> Self { + Self::new() + } +} + impl EventHandler { pub fn new() -> Self { let (tx, rx) = mpsc::unbounded_channel(); diff --git a/crates/goose-tui/src/state/action.rs b/crates/goose-tui/src/state/action.rs index dea2f13e86..e03e8350af 100644 --- a/crates/goose-tui/src/state/action.rs +++ b/crates/goose-tui/src/state/action.rs @@ -11,7 +11,7 @@ pub enum Action { Resize, ServerMessage(Arc), - SessionResumed(Session), + SessionResumed(Box), SessionsListLoaded(Vec), ToolsLoaded(Vec), Error(String), @@ -29,7 +29,7 @@ pub enum Action { ClosePopup, OpenMessageInfo(usize), SetInputEmpty(bool), - + DeleteCustomCommand(String), StartCommandBuilder, SubmitCommandBuilder(CustomCommand), diff --git a/crates/goose-tui/src/state/reducer.rs b/crates/goose-tui/src/state/reducer.rs index 11dab1e24b..927e35fce7 100644 --- a/crates/goose-tui/src/state/reducer.rs +++ b/crates/goose-tui/src/state/reducer.rs @@ -44,7 +44,7 @@ pub fn update(state: &mut AppState, action: Action) { } Action::Error(e) => { state.flash_message = Some(( - format!("Error: {}", e), + format!("Error: {e}"), Instant::now() + std::time::Duration::from_secs(5), )); state.is_working = false; @@ -53,7 +53,7 @@ pub fn update(state: &mut AppState, action: Action) { state.messages.push(message); state.is_working = true; state.has_worked = true; - }, + } Action::Interrupt => { state.is_working = false; state.flash_message = Some(( @@ -92,16 +92,16 @@ pub fn update(state: &mut AppState, action: Action) { state.showing_message_info = None; } Action::DeleteCustomCommand(name) => { - state.config.custom_commands.retain(|c| c.name != name); - let _ = state.config.save(); - state.showing_command_builder = false; // Close builder after delete - } - Action::StartCommandBuilder => state.showing_command_builder = true, - Action::SubmitCommandBuilder(cmd) => { - state.config.custom_commands.push(cmd); - let _ = state.config.save(); - state.showing_command_builder = false; - } + state.config.custom_commands.retain(|c| c.name != name); + let _ = state.config.save(); + state.showing_command_builder = false; + } + Action::StartCommandBuilder => state.showing_command_builder = true, + Action::SubmitCommandBuilder(cmd) => { + state.config.custom_commands.push(cmd); + let _ = state.config.save(); + state.showing_command_builder = false; + } } } @@ -176,7 +176,7 @@ fn handle_server_message(state: &mut AppState, msg: Arc) { } MessageEvent::Error { error } => { state.flash_message = Some(( - format!("Server Error: {}", error), + format!("Server Error: {error}"), Instant::now() + std::time::Duration::from_secs(5), )); state.is_working = false; diff --git a/crates/goose-tui/src/utils/markdown.rs b/crates/goose-tui/src/utils/markdown.rs index 3063dbbaf8..6c54b81890 100644 --- a/crates/goose-tui/src/utils/markdown.rs +++ b/crates/goose-tui/src/utils/markdown.rs @@ -56,32 +56,29 @@ impl<'a> MarkdownRenderer<'a> { // Syntax Highlighting for Code Blocks let ss = Self::get_syntax_set(); let ts = Self::get_theme_set(); - let theme = &ts.themes["base16-ocean.dark"]; // Good default dark theme - + let theme = &ts.themes["base16-ocean.dark"]; + let lang = code_block_lang.as_ref().unwrap(); - let syntax = ss.find_syntax_by_token(lang) + let syntax = ss + .find_syntax_by_token(lang) .unwrap_or_else(|| ss.find_syntax_plain_text()); - + let mut h = HighlightLines::new(syntax, theme); - - // Split content by lines because syntect expects lines - // Note: content might contain newlines. + for line in content.split_inclusive('\n') { - let ranges: Vec<(syntect::highlighting::Style, &str)> = h.highlight_line(line, ss).unwrap_or_default(); - - // If we have pending current_line (e.g. from previous events), push it? - // Code blocks usually start on new line. - // But if content is split, we might be continuing a line. - + let ranges: Vec<(syntect::highlighting::Style, &str)> = + h.highlight_line(line, ss).unwrap_or_default(); + for (style, text) in ranges { let fg = Self::syntect_to_ratatui(style.foreground); - let span_style = Style::default().fg(fg); // Ignore background for now - - // Handle newlines in text + let span_style = Style::default().fg(fg); + if text.ends_with('\n') { - let trimmed = text.trim_end_matches('\n').trim_end_matches('\r'); + let trimmed = + text.trim_end_matches('\n').trim_end_matches('\r'); if !trimmed.is_empty() { - current_line.push(Span::styled(trimmed.to_string(), span_style)); + current_line + .push(Span::styled(trimmed.to_string(), span_style)); } lines.push(Line::from(current_line.clone())); current_line.clear(); @@ -95,7 +92,6 @@ impl<'a> MarkdownRenderer<'a> { continue; } - // Standard Text Handling let mut style = current_style; if is_code_span { style = style.fg(Color::Yellow); @@ -138,43 +134,41 @@ impl<'a> MarkdownRenderer<'a> { } } } - Event::Start(tag) => { - match tag { - Tag::Emphasis => { - style_stack.push(current_style); - current_style = current_style.add_modifier(Modifier::ITALIC); - } - Tag::Strong => { - style_stack.push(current_style); - current_style = current_style.add_modifier(Modifier::BOLD); - } - Tag::CodeBlock(kind) => { - let lang = match kind { - CodeBlockKind::Fenced(l) => l.to_string(), - CodeBlockKind::Indented => "text".to_string(), - }; - code_block_lang = Some(lang); - - if !current_line.is_empty() { - lines.push(Line::from(current_line.clone())); - current_line.clear(); - current_width = 0; - } - } - Tag::List(_) | Tag::Item => { - if !current_line.is_empty() { - lines.push(Line::from(current_line.clone())); - current_line.clear(); - current_width = 0; - } - if matches!(tag, Tag::Item) { - current_line.push(Span::styled("• ", current_style)); - current_width += 2; - } - } - _ => {} + Event::Start(tag) => match tag { + Tag::Emphasis => { + style_stack.push(current_style); + current_style = current_style.add_modifier(Modifier::ITALIC); } - } + Tag::Strong => { + style_stack.push(current_style); + current_style = current_style.add_modifier(Modifier::BOLD); + } + Tag::CodeBlock(kind) => { + let lang = match kind { + CodeBlockKind::Fenced(l) => l.to_string(), + CodeBlockKind::Indented => "text".to_string(), + }; + code_block_lang = Some(lang); + + if !current_line.is_empty() { + lines.push(Line::from(current_line.clone())); + current_line.clear(); + current_width = 0; + } + } + Tag::List(_) | Tag::Item => { + if !current_line.is_empty() { + lines.push(Line::from(current_line.clone())); + current_line.clear(); + current_width = 0; + } + if matches!(tag, Tag::Item) { + current_line.push(Span::styled("• ", current_style)); + current_width += 2; + } + } + _ => {} + }, Event::End(tag) => match tag { TagEnd::Emphasis | TagEnd::Strong => { if let Some(s) = style_stack.pop() { @@ -221,4 +215,4 @@ impl<'a> MarkdownRenderer<'a> { lines } -} \ No newline at end of file +}