use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use std::path::PathBuf;
pub struct SkillRow {
pub name: String,
pub description: String,
pub triggers: Vec<String>,
pub narrows: Option<Vec<String>>,
pub body: String,
pub dir: PathBuf,
pub carried: bool,
pub loaded: bool,
pub error: Option<String>,
}
impl SkillRow {
fn badge(&self) -> &'static str {
if self.error.is_some() {
"failed"
} else if self.loaded {
"loaded"
} else if self.carried {
"carried"
} else {
"withheld"
}
}
fn colour(&self) -> Color {
match self.badge() {
"failed" => Color::Red,
"loaded" => Color::Green,
"withheld" => Color::DarkGray,
_ => Color::White,
}
}
}
pub struct SkillsModal {
pub rows: Vec<SkillRow>,
pub selected: usize,
pub detail: bool,
pub detail_scroll: u16,
pub dir: PathBuf,
}
impl SkillsModal {
pub fn move_by(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let len = self.rows.len() as isize;
self.selected = (((self.selected as isize + delta) % len + len) % len) as usize;
self.detail_scroll = 0;
}
pub fn scroll_detail(&mut self, delta: i16) {
self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
}
pub fn toggle_detail(&mut self) {
if self.rows.is_empty() {
return;
}
self.detail = !self.detail;
self.detail_scroll = 0;
}
pub fn draw(&self, frame: &mut Frame) {
if self.detail {
self.draw_detail(frame);
} else {
self.draw_list(frame);
}
}
fn title(&self) -> String {
if self.rows.is_empty() {
return format!(" no skills in {} ", self.dir.display());
}
let (loadable, carried) = self
.rows
.iter()
.filter(|r| r.error.is_none())
.fold((0, 0), |(n, c), r| (n + 1, c + usize::from(r.carried)));
let failed = self.rows.iter().filter(|r| r.error.is_some()).count();
let failed = if failed == 0 {
String::new()
} else {
format!(" · {failed} failed to load")
};
format!(
" {carried} of {loadable} skills carried{failed} · enter for the procedure, esc to close ",
)
}
fn draw_list(&self, frame: &mut Frame) {
if self.rows.is_empty() {
let body = vec![
Line::styled(
"a skill is a directory holding a SKILL.md — YAML frontmatter with",
Style::new().fg(Color::White),
),
Line::styled(
"`name` and `description`, then the procedure as markdown",
Style::new().fg(Color::White),
),
Line::raw(""),
Line::styled(
"skills are read once at startup, so a new one needs a restart",
Style::new().fg(Color::DarkGray),
),
];
let area = super::centered(frame.area(), 80, body.len() as u16 + 2);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(self.title()),
),
area,
);
return;
}
let body: Vec<Line> = self
.rows
.iter()
.enumerate()
.map(|(i, row)| {
let summary = row
.error
.as_deref()
.unwrap_or(&row.description)
.lines()
.next()
.unwrap_or("");
let text = format!(
"{} {:<22} [{:<8}] {}",
if i == self.selected { "›" } else { " " },
row.name,
row.badge(),
summary,
);
if i == self.selected {
Line::styled(text, Style::new().fg(Color::Black).bg(Color::Cyan))
} else {
Line::styled(text, Style::new().fg(row.colour()))
}
})
.collect();
let height = super::list_height(body.len() as u16, frame.area().height);
let area = super::centered(frame.area(), 100, height);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body)
.scroll((self.list_scroll(area.height.saturating_sub(2)), 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(self.title()),
),
area,
);
}
fn list_scroll(&self, visible: u16) -> u16 {
let visible = visible.max(1) as usize;
(self.selected + 1).saturating_sub(visible) as u16
}
fn draw_detail(&self, frame: &mut Frame) {
let Some(row) = self.rows.get(self.selected) else {
return;
};
let mut body: Vec<Line> = Vec::new();
if let Some(why) = &row.error {
body.push(Line::styled(
format!("this SKILL.md did not load — {why}"),
Style::new().fg(Color::Red),
));
body.push(Line::raw(""));
body.push(Line::styled(
row.dir.display().to_string(),
Style::new().fg(Color::DarkGray),
));
body.push(Line::raw(""));
body.push(Line::styled(
"unknown frontmatter keys are ignored so a skill written for another \
harness still loads; a known key with the wrong type is refused, because \
that is an authoring mistake rather than a portability one",
Style::new().fg(Color::DarkGray),
));
self.render_detail(frame, &row.name, body);
return;
}
body.push(Line::styled(
format!("[{}]", row.badge()),
Style::new().fg(Color::DarkGray),
));
body.push(Line::raw(""));
body.push(Line::styled(
row.description.clone(),
Style::new().fg(Color::White),
));
body.push(Line::raw(""));
if !row.triggers.is_empty() {
body.push(Line::styled(
format!("• keywords: {}", row.triggers.join(", ")),
Style::new().fg(Color::DarkGray),
));
}
if let Some(tools) = &row.narrows {
body.push(Line::styled(
format!("• narrows the tool surface to: {}", tools.join(", ")),
Style::new().fg(Color::Yellow),
));
}
if !row.carried {
body.push(Line::styled(
"• withheld from this run by [skills] or --skill — the model cannot load it",
Style::new().fg(Color::Yellow),
));
}
if row.loaded {
body.push(Line::styled(
"• loaded in this conversation: the procedure below is in context, and \
any narrowing above is in force until /clear",
Style::new().fg(Color::Green),
));
}
body.push(Line::raw(""));
for line in row.body.lines() {
body.push(Line::styled(
format!("│ {line}"),
Style::new().fg(Color::White),
));
}
self.render_detail(frame, &row.name, body);
}
fn render_detail(&self, frame: &mut Frame, name: &str, body: Vec<Line>) {
let area = super::centered(
frame.area(),
84,
(body.len() as u16 + 4).min(frame.area().height),
);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(format!(" {name} · ↑↓ scrolls · esc to go back ")),
),
area,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
fn row(name: &str) -> SkillRow {
SkillRow {
name: name.into(),
description: "does a thing".into(),
triggers: Vec::new(),
narrows: None,
body: "step one".into(),
dir: PathBuf::from("/skills").join(name),
carried: true,
loaded: false,
error: None,
}
}
#[test]
fn the_badge_names_the_furthest_stage_reached() {
assert_eq!(row("a").badge(), "carried");
let loaded = SkillRow {
loaded: true,
..row("a")
};
assert_eq!(loaded.badge(), "loaded");
let withheld = SkillRow {
carried: false,
..row("a")
};
assert_eq!(withheld.badge(), "withheld");
let failed = SkillRow {
error: Some("missing `description`".into()),
loaded: true,
..row("a")
};
assert_eq!(failed.badge(), "failed");
}
#[test]
fn the_title_counts_carried_out_of_loadable_and_names_failures_apart() {
let modal = SkillsModal {
rows: vec![
row("a"),
SkillRow {
carried: false,
..row("b")
},
SkillRow {
error: Some("bad yaml".into()),
..row("c")
},
],
selected: 0,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/skills"),
};
let title = modal.title();
assert!(
title.contains("1 of 2 skills carried"),
"a failed load is not a skill the run could have carried: {title}"
);
assert!(title.contains("1 failed to load"), "{title}");
}
#[test]
fn an_empty_store_says_so_in_the_title() {
let modal = SkillsModal {
rows: Vec::new(),
selected: 0,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/nowhere"),
};
assert!(modal.title().contains("no skills in /nowhere"));
}
#[test]
fn a_tiny_terminal_shrinks_the_list_rather_than_panicking() {
let modal = SkillsModal {
rows: vec![row("a"), row("b"), row("c")],
selected: 0,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/skills"),
};
for height in 0..=6u16 {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(100, height.max(1))).unwrap();
terminal.draw(|f| modal.draw(f)).unwrap();
}
}
#[test]
fn an_empty_store_cannot_be_toggled_into_an_invisible_detail() {
let mut modal = SkillsModal {
rows: Vec::new(),
selected: 0,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/skills"),
};
modal.toggle_detail();
assert!(!modal.detail, "a modal that is up must stay on screen");
}
#[test]
fn moving_resets_the_detail_scroll() {
let mut modal = SkillsModal {
rows: vec![row("a"), row("b")],
selected: 0,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/skills"),
};
modal.scroll_detail(12);
assert_eq!(modal.detail_scroll, 12);
modal.move_by(1);
assert_eq!(modal.detail_scroll, 0);
modal.scroll_detail(-5);
assert_eq!(modal.detail_scroll, 0);
}
#[test]
fn the_selection_wraps_and_an_empty_list_does_not_panic() {
let mut modal = SkillsModal {
rows: vec![row("a"), row("b")],
selected: 0,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/skills"),
};
modal.move_by(-1);
assert_eq!(modal.selected, 1, "did not wrap backwards");
modal.move_by(1);
assert_eq!(modal.selected, 0, "did not wrap forwards");
let mut empty = SkillsModal {
rows: Vec::new(),
selected: 0,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/skills"),
};
empty.move_by(1);
assert_eq!(empty.selected, 0);
}
#[test]
fn a_long_list_scrolls_to_keep_the_selection_visible() {
let rows: Vec<SkillRow> = (0..30).map(|i| row(&format!("s{i}"))).collect();
let modal = SkillsModal {
rows,
selected: 25,
detail: false,
detail_scroll: 0,
dir: PathBuf::from("/skills"),
};
assert_eq!(
modal.list_scroll(10),
16,
"selection stays on the last visible row"
);
}
}