use serenity::builder::{CreateActionRow, CreateButton};
use serenity::model::application::ButtonStyle;
use std::time::{Duration, Instant};
use uuid::Uuid;
use super::DiscordState;
use crate::channels::background_work::FlowOutcome;
#[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 started_at: Instant,
pub settled: Option<SettledStatus>,
}
#[derive(Debug, Clone)]
pub(crate) struct SettledStatus {
pub elapsed: Duration,
pub ctx: Option<String>,
pub waiting: Option<String>,
pub outcome: Option<FlowOutcome>,
}
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 clock(elapsed: Duration) -> String {
let (h, m, s) = (
elapsed.as_secs() / 3600,
(elapsed.as_secs() % 3600) / 60,
elapsed.as_secs() % 60,
);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
const ACTIVITY_MAX_CHARS: usize = 100;
fn activity_segment(group: &GroupState) -> Option<String> {
let text = latest_activity(group)?;
let clipped: String = text.chars().take(ACTIVITY_MAX_CHARS).collect();
let out = if text.chars().count() > ACTIVITY_MAX_CHARS {
format!("{clipped}โฆ")
} else {
clipped
};
(!out.is_empty()).then_some(out)
}
fn latest_activity(group: &GroupState) -> Option<String> {
if let Some(text) = group
.notes
.iter()
.rev()
.find_map(|n| crate::channels::telegram::flow::human_readable_preview(n))
{
return Some(text);
}
if let Some(comments) = group.entries.iter().rev().find_map(|e| {
if e.name == "bash" {
crate::channels::telegram::flow::extract_status_from_text(&e.context)
} else {
None
}
}) {
return Some(comments);
}
group
.entries
.iter()
.rev()
.map(|e| {
let ctx = e.context.trim_start();
if ctx.is_empty() {
e.name.clone()
} else {
format!("{} {ctx}", e.name)
}
})
.next()
}
fn summary_line(group: &GroupState) -> String {
let n = group.entries.len();
let failed = group
.entries
.iter()
.filter(|e| e.status == Some(false))
.count();
let counts = format!("**{n} tool call{}**", if n == 1 { "" } else { "s" });
match &group.settled {
Some(s) => {
let mut line = if let Some(verb) = &s.waiting {
format!("โณ {verb} ยท {counts}")
} else if let Some(outcome) = s.outcome {
let (icon, verb) = outcome.icon_verb();
format!("{icon} {verb} ยท {counts}")
} else if failed > 0 {
format!("โ {counts} ยท {failed} failed")
} else {
format!("โ
{counts}")
};
if let Some(ctx) = &s.ctx {
line.push_str(&format!(" ยท {ctx}"));
}
line.push_str(&format!(" ยท โฑ๏ธ {}", clock(s.elapsed)));
line
}
None => {
let running = group.entries.iter().filter(|e| e.status.is_none()).count();
let (icon, tail) = if running > 0 {
("โ๏ธ", format!(" ยท {running} running"))
} else if failed > 0 {
("โ", format!(" ยท {failed} failed"))
} else {
("โ
", String::new())
};
let clock = format!("๐ {}", clock(group.started_at.elapsed()));
match activity_segment(group) {
Some(activity) => format!("{icon} {activity} ยท {counts}{tail} ยท {clock}"),
None => format!("{icon} {counts}{tail} ยท {clock}"),
}
}
}
}
pub(crate) const CONTENT_MAX_CHARS: usize = 2000;
const OMIT_MARKER_RESERVE: usize = 80;
fn clamp_rows(summary: String, rows: Vec<String>) -> String {
let budget = CONTENT_MAX_CHARS - OMIT_MARKER_RESERVE;
let mut used = summary.chars().count() + 1;
let mut kept: Vec<String> = Vec::new();
let mut dropped = 0usize;
for row in rows.into_iter().rev() {
let cost = row.chars().count() + 1;
if used + cost > budget {
dropped += 1;
continue;
}
used += cost;
kept.push(row);
}
let mut out = format!(
"{summary}\n{}",
kept.into_iter().rev().collect::<Vec<_>>().join("\n")
);
if dropped > 0 {
out.push_str(&format!(
"\n_{dropped} omitted to fit Discord's message limit_"
));
}
out
}
fn hard_clip(body: String) -> String {
if body.chars().count() <= CONTENT_MAX_CHARS {
return body;
}
let cut: String = body.chars().take(CONTENT_MAX_CHARS - 1).collect();
format!("{cut}โฆ")
}
pub(crate) fn render_content(group: &GroupState) -> String {
let tools_part = if group.entries.len() == 1 && !group.expanded && group.settled.is_none() {
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();
clamp_rows(summary_line(group), lines)
} else {
summary_line(group)
};
let mut body = if group.notes.is_empty() {
tools_part.clone()
} else {
format!("{tools_part}\n{}", notes_block(&group.notes))
};
let mut notes: Vec<String> = group.notes.clone();
while body.chars().count() > CONTENT_MAX_CHARS && notes.len() > 1 {
notes.remove(0);
body = format!("{tools_part}\n{}", notes_block(¬es));
}
hard_clip(body)
}
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();
group.started_at = existing.started_at;
group.settled = existing.settled.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 settle_tool_group(
&self,
message_id: u64,
ctx: Option<String>,
waiting: Option<String>,
outcome: Option<FlowOutcome>,
) -> Option<GroupState> {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
let prev_ctx = group.settled.as_ref().and_then(|s| s.ctx.clone());
group.settled = Some(SettledStatus {
elapsed: group.started_at.elapsed(),
ctx: ctx.or(prev_ctx),
waiting,
outcome,
});
Some(group.clone())
}
pub(crate) async fn refresh_waiting_line(
&self,
message_id: u64,
waiting: Option<String>,
) -> Option<GroupState> {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
group.settled.as_mut()?.waiting = waiting;
Some(group.clone())
}
pub(crate) async fn register_waiting_group(&self, session_id: Uuid, message_id: u64) {
self.waiting_groups
.lock()
.await
.insert(session_id, message_id);
}
pub(crate) async fn waiting_group_for(&self, session_id: Uuid) -> Option<u64> {
self.waiting_groups.lock().await.get(&session_id).copied()
}
pub(crate) async fn clear_waiting_group(&self, session_id: Uuid) -> Option<u64> {
self.waiting_groups.lock().await.remove(&session_id)
}
pub(crate) async fn tool_group_snapshot(&self, message_id: u64) -> Option<GroupState> {
let guard = self.tool_groups.lock().await;
let (_, map) = &*guard;
map.get(&message_id).cloned()
}
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())
}
}
#[cfg(test)]
mod cap_tests {
use super::*;
fn probe(entries: usize, notes: usize, expanded: bool) -> GroupState {
GroupState {
entries: (0..entries)
.map(|i| GroupEntry {
name: format!("tool{i}"),
context: format!(" (long context line to reach the cap sooner #{i})"),
status: Some(true),
})
.collect(),
notes: (0..notes)
.map(|i| clip_note(&"n".repeat(NOTE_MAX_CHARS + 40 + i)))
.collect(),
expanded,
started_at: Instant::now(),
settled: Some(SettledStatus {
elapsed: Duration::from_secs(3),
ctx: None,
waiting: None,
outcome: None,
}),
}
}
#[test]
fn huge_expansion_fits_the_cap_and_states_drops() {
let rendered = render_content(&probe(300, 6, true));
let chars = rendered.chars().count();
assert!(chars <= CONTENT_MAX_CHARS, "expansion overshot: {chars}");
assert!(
rendered.contains("_") && rendered.contains("omitted to fit"),
"silent drop: {rendered}"
);
assert!(
rendered.contains("tool299"),
"the newest entry must survive the clamp"
);
}
#[test]
fn small_expansion_is_untouched() {
let rendered = render_content(&probe(3, 0, true));
assert!(rendered.contains("tool0") && rendered.contains("tool2"));
assert!(!rendered.contains("omitted to fit"));
}
#[test]
fn collapsed_note_flood_fits_the_cap() {
let rendered = render_content(&probe(30, 6, false));
assert!(rendered.chars().count() <= CONTENT_MAX_CHARS);
}
#[test]
fn multibyte_context_never_splits_a_char() {
let mut g = probe(2, 0, true);
g.entries[0].context = " (".repeat(3000);
let rendered = render_content(&g);
assert!(rendered.chars().count() <= CONTENT_MAX_CHARS);
assert!(!rendered.ends_with('\u{FFFD}'));
}
#[test]
fn marker_counts_exactly_the_dropped_rows() {
let rendered = render_content(&probe(250, 0, true));
let shown = rendered
.lines()
.skip(1)
.filter(|l| l.starts_with(['โ
', 'โ', '\u{2699}']))
.count();
assert!(shown > 0 && shown < 250, "clamp kept {shown} rows");
assert!(
rendered.contains(&format!("_{} omitted to fit", 250 - shown)),
"marker disagrees with visible rows:\n{rendered}"
);
}
}