mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
comment cleanup
This commit is contained in:
@@ -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()))),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user