comment cleanup

This commit is contained in:
Tyler Longwell
2025-11-22 14:30:49 -05:00
parent ad4283bd2f
commit 4ff9f97b2e
17 changed files with 494 additions and 411 deletions
+9 -3
View File
@@ -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()))),
+36 -63
View File
@@ -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::<String>())
format!(
"{}...",
tool_args.chars().take(max_args).collect::<String>()
)
} 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(
+69 -69
View File
@@ -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),
));
}
}
+49 -28
View File
@@ -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<String> = state.config.custom_commands.iter().map(|c| format!("/{}", c.name)).collect();
let custom: Vec<String> = 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<ListItem> = filtered.iter().map(|c| {
ListItem::new(Span::styled(*c, Style::default().fg(theme.base.foreground)))
}).collect();
let items: Vec<ListItem> = 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);
}
}
}
}
}
}
}
+2 -2
View File
@@ -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;
@@ -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<usize>,
arg_values: HashMap<String, String>,
}
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<ListItem> = state.config.custom_commands.iter().map(|c| {
ListItem::new(format!("/{} -> {}", c.name, c.tool))
}).collect();
let items: Vec<ListItem> = 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]);
}
}
@@ -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<Instant>,
}
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<Option<Action>> {
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::<serde_json::Value>(text) {
serde_json::to_string_pretty(&v).unwrap_or_else(|_| text.to_string())
let display_string = if let Ok(v) =
serde_json::from_str::<serde_json::Value>(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 {
}
}
}
}
}
@@ -1,5 +1,5 @@
pub mod builder;
pub mod help;
pub mod message;
pub mod session;
pub mod todo;
pub mod message;
@@ -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(
+34 -30
View File
@@ -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<Instant>,
}
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<Option<Action>> {
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 {
}
}
}
}
}
+44 -13
View File
@@ -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<Span> = 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,
);
}
}
}
+20 -33
View File
@@ -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()));
+4 -4
View File
@@ -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();
+7 -1
View File
@@ -16,7 +16,7 @@ pub enum Event {
Resize,
Server(Arc<MessageEvent>),
SessionsList(Vec<Session>),
SessionResumed(Session),
SessionResumed(Box<Session>),
ToolsLoaded(Vec<ToolInfo>),
Error(String),
}
@@ -26,6 +26,12 @@ pub struct EventHandler {
tx: mpsc::UnboundedSender<Event>,
}
impl Default for EventHandler {
fn default() -> Self {
Self::new()
}
}
impl EventHandler {
pub fn new() -> Self {
let (tx, rx) = mpsc::unbounded_channel();
+2 -2
View File
@@ -11,7 +11,7 @@ pub enum Action {
Resize,
ServerMessage(Arc<MessageEvent>),
SessionResumed(Session),
SessionResumed(Box<Session>),
SessionsListLoaded(Vec<Session>),
ToolsLoaded(Vec<ToolInfo>),
Error(String),
@@ -29,7 +29,7 @@ pub enum Action {
ClosePopup,
OpenMessageInfo(usize),
SetInputEmpty(bool),
DeleteCustomCommand(String),
StartCommandBuilder,
SubmitCommandBuilder(CustomCommand),
+13 -13
View File
@@ -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>) {
}
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;
+50 -56
View File
@@ -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
}
}
}