use serenity::builder::{CreateActionRow, CreateButton};
use serenity::model::application::ButtonStyle;
use super::DiscordState;
#[derive(Debug, Clone)]
pub(crate) struct GroupEntry {
pub name: String,
pub context: String,
pub status: Option<bool>,
}
#[derive(Debug, Clone)]
pub(crate) struct GroupState {
pub entries: Vec<GroupEntry>,
pub notes: Vec<String>,
pub expanded: bool,
}
pub(crate) const NOTE_CAP: usize = 6;
pub(crate) const NOTE_MAX_CHARS: usize = 160;
pub(crate) fn clip_note(text: &str) -> String {
let line = text
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("");
let mut out: String = line.chars().take(NOTE_MAX_CHARS).collect();
if line.chars().count() > NOTE_MAX_CHARS {
out.push('…');
}
out
}
fn notes_block(notes: &[String]) -> String {
notes
.iter()
.map(|n| format!("-# {n}"))
.collect::<Vec<_>>()
.join("\n")
}
fn entry_icon(status: Option<bool>) -> &'static str {
match status {
None => "⚙️",
Some(true) => "✅",
Some(false) => "❌",
}
}
fn summary_line(entries: &[GroupEntry]) -> String {
let n = entries.len();
let running = entries.iter().filter(|e| e.status.is_none()).count();
let failed = entries.iter().filter(|e| e.status == Some(false)).count();
let (icon, tail) = if running > 0 {
("⚙️", format!(" · {running} running"))
} else if failed > 0 {
("❌", format!(" · {failed} failed"))
} else {
("✅", String::new())
};
format!(
"{icon} **{n} tool call{}**{tail}",
if n == 1 { "" } else { "s" }
)
}
pub(crate) fn render_content(group: &GroupState) -> String {
let tools_part = if group.entries.len() == 1 && !group.expanded {
let e = &group.entries[0];
format!("{} **{}**{}", entry_icon(e.status), e.name, e.context)
} else if group.expanded {
let lines: Vec<String> = group
.entries
.iter()
.map(|e| format!("{} **{}**{}", entry_icon(e.status), e.name, e.context))
.collect();
format!("{}\n{}", summary_line(&group.entries), lines.join("\n"))
} else {
summary_line(&group.entries)
};
if group.notes.is_empty() {
tools_part
} else {
format!("{tools_part}\n{}", notes_block(&group.notes))
}
}
pub(crate) fn render_components(group: &GroupState, message_id: u64) -> Vec<CreateActionRow> {
if group.entries.len() < 2 {
return Vec::new();
}
let label = if group.expanded {
"Collapse ▲"
} else {
"Expand ▼"
};
vec![CreateActionRow::Buttons(vec![
CreateButton::new(format!("toolgroup:{message_id}"))
.label(label)
.style(ButtonStyle::Secondary),
])]
}
impl DiscordState {
const TOOL_GROUP_CAP: usize = 20;
pub(crate) async fn upsert_tool_group(
&self,
message_id: u64,
mut group: GroupState,
) -> GroupState {
let mut guard = self.tool_groups.lock().await;
let (order, map) = &mut *guard;
match map.get(&message_id) {
Some(existing) => {
group.expanded = existing.expanded;
group.notes = existing.notes.clone();
}
None => {
order.push(message_id);
while order.len() > Self::TOOL_GROUP_CAP {
let oldest = order.remove(0);
map.remove(&oldest);
}
}
}
map.insert(message_id, group.clone());
group
}
pub(crate) async fn toggle_tool_group(&self, message_id: u64) -> Option<GroupState> {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
group.expanded = !group.expanded;
Some(group.clone())
}
pub(crate) async fn append_note(&self, message_id: u64, note: String) -> Option<GroupState> {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
group.notes.push(note);
if group.notes.len() > NOTE_CAP {
group.notes.remove(0);
}
Some(group.clone())
}
pub(crate) async fn drop_note_if<F>(&self, message_id: u64, pred: F) -> Option<GroupState>
where
F: Fn(&str) -> bool,
{
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
let idx = group.notes.iter().rposition(|n| pred(n))?;
group.notes.remove(idx);
Some(group.clone())
}
}