polyc-tui 2026.9.0

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Tools pane: the per-conversation enabled-tool catalogue, rendered as a
//! table of `name | source | approval`.
//!
//! [`ToolView`] is the view type the tools adapter ([`crate::data::tools`])
//! produces. The enabled-tool set for a conversation comes from
//! `ConversationSpec.tools_enabled`; each name is classified into a
//! [`ToolSource`] (a local in-process pure tool, or a dynamic tool served by a
//! remote Model Context Protocol source). This slice is read-only: navigation
//! moves the highlight but performs no mutation.

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};

/// Where a tool's implementation lives in the harness registry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolSource {
    /// In-process pure tool (deterministic, no external network reach).
    Local,
    /// Served by a remote Model Context Protocol source composed into the
    /// harness registry.
    Dynamic,
}

impl ToolSource {
    /// Short column label for the table.
    #[must_use]
    pub(crate) const fn label(self) -> &'static str {
        match self {
            Self::Local => "local",
            Self::Dynamic => "dynamic",
        }
    }
}

/// One tool as rendered in the pane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ToolView {
    /// The tool/function name (the id the model calls).
    pub name: String,
    /// Where the tool's implementation lives (local pure tool vs dynamic
    /// remote source).
    pub source: ToolSource,
    /// Whether this tool is enabled for the selected conversation
    /// (`ConversationSpec.tools_enabled`).
    pub enabled: bool,
    /// Optional human description, when a registry surface provides one.
    pub description: Option<String>,
    /// Whether invoking this tool requires an approval (HITL gated).
    pub requires_approval: bool,
}

/// The tools component.
#[derive(Default)]
pub(crate) struct Tools {
    /// The conversation whose tools are shown, if any.
    pub conversation_id: Option<String>,
    /// Tool rows in display order.
    pub items: Vec<ToolView>,
    /// Index of the highlighted tool, if any.
    pub selected: Option<usize>,
}

impl Tools {
    /// Replace the rows shown and reset/clamp the highlight.
    pub(crate) fn set_items(&mut self, items: Vec<ToolView>) {
        self.items = items;
        self.selected = if self.items.is_empty() { None } else { Some(0) };
    }

    /// Move the highlight by `delta`, clamped to the row range. No-op when
    /// there are no rows.
    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
            }
            // Read-only highlight movement while the pane is focused. The app
            // routes focused key input here; we only move the cursor.
            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));
    }
}