use crate::config_panel::{hint_line, render_panel, RowView};
use crate::list_cursor::ListCursor;
use crate::panel::{Flow, Key, Screen};
use newt_core::ToolExposureConfig;
const FIXED_ROWS: usize = 2;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ToolsChoice {
pub(crate) tools: Option<String>,
pub(crate) read_only: Option<&'static str>,
}
pub(crate) struct ToolsPanel {
names: Vec<String>,
checked: Vec<bool>,
all: bool,
read_only: bool,
opened_as: ToolExposureConfig,
cursor: ListCursor,
choice: Option<ToolsChoice>,
}
impl ToolsPanel {
pub(crate) fn new(exposure: &ToolExposureConfig) -> Self {
let mut names: Vec<String> = newt_core::agentic::builtin_tool_names()
.iter()
.map(|s| s.to_string())
.collect();
for extra in exposure.allow_list.iter().flatten() {
if !names.contains(extra) {
names.push(extra.clone());
}
}
let checked = names
.iter()
.map(|name| {
exposure
.allow_list
.as_ref()
.is_none_or(|list| list.contains(name))
})
.collect();
Self {
cursor: ListCursor::new(names.len() + FIXED_ROWS, VISIBLE, 0),
names,
checked,
all: exposure.allow_list.is_none(),
read_only: exposure.read_only,
opened_as: exposure.clone(),
choice: None,
}
}
fn draft(&self) -> ToolExposureConfig {
ToolExposureConfig {
allow_list: (!self.all).then(|| {
self.names
.iter()
.zip(&self.checked)
.filter(|(_, on)| **on)
.map(|(name, _)| name.clone())
.collect()
}),
read_only: self.read_only,
..self.opened_as.clone()
}
}
fn preset_label(&self) -> String {
let draft = self.draft();
if self.all {
"all".to_string()
} else if draft.is_pi_preset() {
"pi".to_string()
} else {
format!(
"custom ({} of {})",
self.checked.iter().filter(|on| **on).count(),
self.names.len()
)
}
}
fn cycle_preset(&mut self) {
if self.all {
self.all = false;
for (name, on) in self.names.iter().zip(self.checked.iter_mut()) {
*on = newt_core::PI_PRESET_TOOLS.contains(&name.as_str());
}
} else {
self.all = true;
self.checked.iter_mut().for_each(|on| *on = true);
}
}
fn toggle_at(&mut self, row: usize) {
match row {
0 => self.read_only = !self.read_only,
1 => self.cycle_preset(),
i => {
let i = i - FIXED_ROWS;
self.all = false;
self.checked[i] = !self.checked[i];
}
}
}
fn changes(&self) -> Option<ToolsChoice> {
let draft = self.draft();
let tools = (draft.allow_list != self.opened_as.allow_list)
.then(|| crate::settings_form::tools_token(&draft));
let read_only = (draft.read_only != self.opened_as.read_only)
.then_some(if draft.read_only { "on" } else { "off" });
(tools.is_some() || read_only.is_some()).then_some(ToolsChoice { tools, read_only })
}
fn take_choice(&mut self) -> Option<ToolsChoice> {
self.choice.take()
}
fn rows(&self) -> Vec<RowView> {
let at = self.cursor.at();
let mut rows = vec![
RowView {
label: "",
value: format!("read-only: {}", if self.read_only { "on" } else { "off" }),
provenance: "hides write_file · edit_file · delete_file and clamps fs_write"
.to_string(),
selected: at == 0,
editable: true,
},
RowView {
label: "",
value: format!("preset: {}", self.preset_label()),
provenance: "←→ all / pi · tick boxes below for a custom list".to_string(),
selected: at == 1,
editable: true,
},
];
rows.extend(
self.names
.iter()
.zip(&self.checked)
.enumerate()
.map(|(i, (name, on))| RowView {
label: "",
value: format!("[{}] {name}", if *on { "x" } else { " " }),
provenance: if self.read_only && newt_core::agentic::read_only_hidden_tool(name)
{
"hidden by read-only".to_string()
} else {
String::new()
},
selected: at == i + FIXED_ROWS,
editable: true,
}),
);
rows
}
}
impl Screen for ToolsPanel {
fn draw(&self, frame: &mut ratatui::Frame) {
render_panel(
frame,
"tools",
&self.rows(),
hint_line("↑↓ select · Space/←→ toggle · Enter apply · Esc back"),
0,
48,
);
}
fn key(&mut self, key: Key) -> Flow {
match key {
Key::Esc | Key::Char('q') | Key::Ctrl('c') | Key::Ctrl('d') => {
self.choice = None;
return Flow::Close(false);
}
Key::Up | Key::Char('k') => self.cursor.step(-1),
Key::Down | Key::Char('j') => self.cursor.step(1),
Key::Ctrl('u') => self.cursor.step(-(self.cursor.page() as isize)),
Key::Char('g') => self.cursor.home(),
Key::Char('G') => self.cursor.end(),
Key::Char(' ') | Key::Left | Key::Right => self.toggle_at(self.cursor.at()),
Key::Enter => {
self.choice = self.changes();
return Flow::Close(self.choice.is_some());
}
_ => {}
}
Flow::Stay
}
}
const VISIBLE: usize = 12;
pub(crate) fn height() -> u16 {
crate::lines_panel::LinesPanel::height()
}
pub(crate) fn run(
panel: &mut ToolsPanel,
window: Option<crate::session_worker::PanelWindow>,
) -> std::io::Result<Option<ToolsChoice>> {
panel.choice = None;
crate::panel::drive(panel, height(), window.as_ref())?;
Ok(panel.take_choice())
}
#[cfg(test)]
mod tests {
use super::*;
fn open() -> ToolsPanel {
ToolsPanel::new(&ToolExposureConfig::default())
}
fn row_of(panel: &ToolsPanel, name: &str) -> usize {
panel
.names
.iter()
.position(|n| n == name)
.expect("in the catalog")
+ FIXED_ROWS
}
fn go_to(panel: &mut ToolsPanel, row: usize) {
panel.cursor.home();
for _ in 0..row {
panel.key(Key::Down);
}
assert_eq!(panel.cursor.at(), row);
}
#[test]
fn a_visit_that_changes_nothing_reports_nothing() {
let mut panel = open();
panel.key(Key::Down);
panel.key(Key::Down);
assert_eq!(panel.key(Key::Enter), Flow::Close(false));
assert_eq!(panel.take_choice(), None);
let mut panel = open();
panel.key(Key::Char(' '));
assert!(panel.read_only);
assert_eq!(panel.key(Key::Esc), Flow::Close(false));
assert_eq!(panel.take_choice(), None);
}
#[test]
fn the_preset_row_cycles_between_all_and_pi() {
let mut panel = open();
assert_eq!(panel.preset_label(), "all");
go_to(&mut panel, 1);
panel.key(Key::Right);
assert_eq!(panel.preset_label(), "pi");
assert!(panel.draft().is_pi_preset());
assert_eq!(panel.key(Key::Enter), Flow::Close(true));
assert_eq!(
panel.take_choice(),
Some(ToolsChoice {
tools: Some("pi".to_string()),
read_only: None,
})
);
assert!(
panel.take_choice().is_none(),
"a choice is consumed exactly once"
);
let mut panel = ToolsPanel::new(&ToolExposureConfig {
allow_list: Some(
newt_core::PI_PRESET_TOOLS
.iter()
.map(|s| s.to_string())
.collect(),
),
..Default::default()
});
assert_eq!(panel.preset_label(), "pi");
go_to(&mut panel, 1);
panel.key(Key::Left);
assert_eq!(panel.preset_label(), "all");
panel.key(Key::Enter);
assert_eq!(panel.take_choice().unwrap().tools.as_deref(), Some("all"));
}
#[test]
fn ticking_boxes_builds_an_explicit_list() {
let mut panel = open();
let git = row_of(&panel, "git");
go_to(&mut panel, git);
panel.key(Key::Char(' '));
assert!(!panel.all);
assert!(panel.preset_label().starts_with("custom ("));
let token = panel.changes().unwrap().tools.unwrap();
assert!(!token.split(',').any(|n| n == "git"), "{token}");
assert!(token.split(',').any(|n| n == "read_file"), "{token}");
panel.key(Key::Char(' '));
assert!(panel.checked.iter().all(|on| *on));
assert!(
!panel.all,
"every box ticked is an explicit list, not `all`"
);
assert!(panel.changes().is_some());
}
#[test]
fn the_read_only_row_is_its_own_field() {
let mut panel = open();
panel.key(Key::Right);
assert!(panel.read_only);
let rows = panel.rows();
assert!(rows[0].value.ends_with("on"));
let write = row_of(&panel, "write_file");
assert_eq!(rows[write].provenance, "hidden by read-only");
assert_eq!(rows[row_of(&panel, "read_file")].provenance, "");
assert_eq!(panel.key(Key::Enter), Flow::Close(true));
assert_eq!(
panel.take_choice(),
Some(ToolsChoice {
tools: None,
read_only: Some("on"),
})
);
}
#[test]
fn unknown_names_from_the_allow_list_are_kept() {
let mut panel = ToolsPanel::new(&ToolExposureConfig {
allow_list: Some(vec!["read_file".to_string(), "github__search".to_string()]),
..Default::default()
});
let mcp = row_of(&panel, "github__search");
assert!(panel.checked[mcp - FIXED_ROWS]);
go_to(&mut panel, 0);
panel.key(Key::Char(' '));
panel.key(Key::Enter);
let choice = panel.take_choice().unwrap();
assert_eq!(choice.tools, None, "the list did not move");
assert_eq!(choice.read_only, Some("on"));
let read = row_of(&panel, "read_file");
go_to(&mut panel, read);
panel.key(Key::Char(' '));
assert_eq!(
panel.changes().unwrap().tools.as_deref(),
Some("github__search")
);
}
}