use color_eyre::Result;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::Text;
use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState};
use super::Component;
use crate::action::{Action, Pane};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolSource {
Local,
Dynamic,
}
impl ToolSource {
#[must_use]
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Local => "local",
Self::Dynamic => "dynamic",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ToolView {
pub name: String,
pub source: ToolSource,
pub enabled: bool,
pub description: Option<String>,
pub requires_approval: bool,
}
#[derive(Default)]
pub(crate) struct Tools {
pub conversation_id: Option<String>,
pub items: Vec<ToolView>,
pub selected: Option<usize>,
}
impl Tools {
pub(crate) fn set_items(&mut self, items: Vec<ToolView>) {
self.items = items;
self.selected = if self.items.is_empty() { None } else { Some(0) };
}
fn move_selection(&mut self, delta: isize) {
self.selected = super::step_selection(self.selected, self.items.len(), delta);
}
}
impl Component for Tools {
fn controls(&self) -> Vec<(&'static str, &'static str)> {
vec![("↑↓/jk", "move")]
}
fn handle(&mut self, action: &Action) -> Option<Action> {
match action {
Action::Select(id) => {
self.conversation_id = Some(id.clone());
None
}
Action::ToolsLoaded(items) => {
self.set_items(items.clone());
Some(Action::Render)
}
Action::Nav(Pane::Tools) => {
if self.selected.is_none() && !self.items.is_empty() {
self.selected = Some(0);
}
None
}
Action::Key(KeyEvent { code, .. }) => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.move_selection(-1);
Some(Action::Render)
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_selection(1);
Some(Action::Render)
}
_ => None,
},
_ => None,
}
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let title = self
.conversation_id
.as_ref()
.map_or_else(|| " tools ".to_string(), |id| format!(" tools — {id} "));
let block = Block::default().title(title).borders(Borders::ALL);
if self.items.is_empty() {
let body = Text::from("no tools enabled").dim();
frame.render_widget(ratatui::widgets::Paragraph::new(body).block(block), area);
return Ok(());
}
let header = Row::new([
Cell::from("name"),
Cell::from("source"),
Cell::from("approval"),
])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
let rows = self.items.iter().map(|t| {
let source_cell = match t.source {
ToolSource::Local => Cell::from(t.source.label()).cyan(),
ToolSource::Dynamic => Cell::from(t.source.label()).magenta(),
};
let approval = if t.requires_approval {
Cell::from("gated").yellow()
} else {
Cell::from("-").dim()
};
let name = if t.enabled {
Cell::from(t.name.clone())
} else {
Cell::from(t.name.clone()).dim()
};
Row::new([name, source_cell, approval])
});
let widths = [
Constraint::Percentage(60),
Constraint::Length(9),
Constraint::Length(9),
];
let table = Table::new(rows, widths)
.header(header)
.block(block)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD))
.highlight_symbol("› ");
let mut state = TableState::default().with_selected(self.selected);
frame.render_stateful_widget(table, area, &mut state);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn view(name: &str, source: ToolSource) -> ToolView {
ToolView {
name: name.to_string(),
source,
enabled: true,
description: None,
requires_approval: false,
}
}
#[test]
fn set_items_selects_first_and_clears_when_empty() {
let mut t = Tools::default();
t.set_items(vec![view("calculator", ToolSource::Local)]);
assert_eq!(t.selected, Some(0));
t.set_items(vec![]);
assert_eq!(t.selected, None);
}
#[test]
fn move_selection_clamps_to_range() {
let mut t = Tools::default();
t.set_items(vec![
view("calculator", ToolSource::Local),
view("slacksearch", ToolSource::Dynamic),
]);
t.move_selection(-1);
assert_eq!(t.selected, Some(0));
t.move_selection(5);
assert_eq!(t.selected, Some(1));
}
}