use std::time::Instant;
use slack_morphism::prelude::*;
use uuid::Uuid;
use super::SlackState;
#[derive(Debug, Clone)]
pub(crate) enum GroupEntry {
Tool {
name: String,
context: String,
status: Option<bool>,
},
Note(String),
}
impl GroupEntry {
fn is_running(&self) -> bool {
matches!(self, Self::Tool { status: None, .. })
}
fn is_failed(&self) -> bool {
matches!(
self,
Self::Tool {
status: Some(false),
..
}
)
}
fn is_tool(&self) -> bool {
matches!(self, Self::Tool { .. })
}
}
#[derive(Debug, Clone)]
pub(crate) struct GroupState {
pub channel: SlackChannelId,
pub entries: Vec<GroupEntry>,
pub expanded: bool,
pub started_at: Instant,
pub settled: Option<SettledStatus>,
}
impl GroupState {
pub(crate) fn new(channel: SlackChannelId, entries: Vec<GroupEntry>) -> Self {
Self {
channel,
entries,
expanded: false,
started_at: Instant::now(),
settled: None,
}
}
pub(crate) fn settle(
&mut self,
outcome: TurnOutcome,
waiting: Option<String>,
ctx: Option<String>,
) {
let is_waiting = outcome == TurnOutcome::Finished && waiting.is_some();
let prev_ctx = self.settled.as_ref().and_then(|s| s.ctx.clone());
let (icon, verb) = match waiting {
Some(verb) if is_waiting => ("⏳", verb),
_ => {
let (icon, verb) = outcome.icon_verb();
(icon, verb.to_string())
}
};
self.settled = Some(SettledStatus {
icon,
verb,
ctx: ctx.or(prev_ctx),
waiting: is_waiting,
});
}
pub(crate) fn settled_terminal(&self) -> bool {
self.settled.as_ref().is_some_and(|s| !s.waiting)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum TurnOutcome {
Finished,
Failed,
TimedOut,
Cancelled,
}
impl TurnOutcome {
fn icon_verb(self) -> (&'static str, &'static str) {
match self {
Self::Finished => ("✅", "Finished"),
Self::Failed => ("❌", "Failed"),
Self::TimedOut => ("⏱", "Timed out"),
Self::Cancelled => ("❌", "Cancelled"),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SettledStatus {
pub icon: &'static str,
pub verb: String,
pub ctx: Option<String>,
pub waiting: bool,
}
fn entry_icon(status: Option<bool>) -> &'static str {
match status {
None => "⚙️",
Some(true) => "✅",
Some(false) => "❌",
}
}
pub(crate) fn notes_text(entries: &[GroupEntry]) -> Option<String> {
let joined = entries
.iter()
.filter_map(|e| match e {
GroupEntry::Note(text) => Some(text.trim()),
GroupEntry::Tool { .. } => None,
})
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
(!joined.is_empty()).then_some(joined)
}
pub(crate) fn consume_notes(entries: &[GroupEntry]) -> Vec<GroupEntry> {
entries
.iter()
.filter(|e| matches!(e, GroupEntry::Tool { .. }))
.cloned()
.collect()
}
fn entry_line(entry: &GroupEntry) -> String {
match entry {
GroupEntry::Tool {
name,
context,
status,
} => format!("{} *{}*{}", entry_icon(*status), name, context),
GroupEntry::Note(text) => format!("💭 _{}_", text.trim()),
}
}
fn latest_activity(group: &GroupState) -> Option<String> {
if let Some(text) = group.entries.iter().rev().find_map(|e| match e {
GroupEntry::Note(text) => crate::channels::telegram::flow::human_readable_preview(text),
_ => None,
}) {
return Some(text);
}
if let Some(comments) = group.entries.iter().rev().find_map(|e| match e {
GroupEntry::Tool { name, context, .. } if name == "bash" => {
crate::channels::telegram::flow::extract_status_from_text(context)
}
_ => None,
}) {
return Some(comments);
}
group.entries.iter().rev().find_map(|e| match e {
GroupEntry::Tool { name, context, .. } => {
let ctx = context.trim_start();
Some(if ctx.is_empty() {
name.clone()
} else {
format!("{name} {ctx}")
})
}
GroupEntry::Note(_) => None,
})
}
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 summary_line(group: &GroupState) -> String {
let tools = group.entries.iter().filter(|e| e.is_tool()).count();
let counts = format!("{tools} tool call{}", if tools == 1 { "" } else { "s" });
match &group.settled {
Some(s) => {
let mut segs = vec![format!("{} {}", s.icon, s.verb)];
if tools > 0 {
segs.push(counts);
}
if let Some(ctx) = &s.ctx {
segs.push(ctx.clone());
}
let glyph = if s.waiting { "🕒" } else { "⏱️" };
segs.push(format!("{glyph} {}", clock(group.started_at.elapsed())));
let rest = segs[1..].join(" · ");
format!("{} *{rest}*", segs[0])
}
None => {
let steps = group.entries.len();
let running = group.entries.iter().filter(|e| e.is_running()).count();
let failed = group.entries.iter().filter(|e| e.is_failed()).count();
let (icon, tail) = if running > 0 {
("⚙️", format!(" · {running} running"))
} else if failed > 0 {
("❌", format!(" · {failed} failed"))
} else {
("✅", String::new())
};
let counts = if steps == tools {
counts
} else {
format!(
"{steps} step{} · {counts}",
if steps == 1 { "" } else { "s" }
)
};
let lead = activity_segment(group)
.map(|a| format!("{a} · "))
.unwrap_or_default();
format!(
"{icon} {lead}*{counts}*{tail} · 🕒 {}",
clock(group.started_at.elapsed())
)
}
}
}
fn clock(elapsed: std::time::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}")
}
}
pub(crate) fn render(group: &GroupState, ts: &SlackTs) -> SlackMessageContent {
let lone_collapsed_note =
matches!(group.entries.as_slice(), [GroupEntry::Note(_)]) && !group.expanded;
let text = if lone_collapsed_note {
entry_line(&group.entries[0])
} else {
let mut lines = vec![summary_line(group)];
if group.expanded {
lines.extend(group.entries.iter().map(entry_line));
}
lines.join("\n")
};
let mut blocks = vec![SlackBlock::Section(SlackSectionBlock::new().with_text(
SlackBlockText::MarkDown(SlackBlockMarkDownText::new(text.clone())),
))];
if !lone_collapsed_note {
let label = if group.expanded {
"Collapse ▲"
} else {
"Expand ▼"
};
blocks.push(SlackBlock::Actions(SlackActionsBlock::new(vec![
SlackActionBlockElement::Button(SlackBlockButtonElement::new(
SlackActionId::new(format!("toolgroup:{}", ts)),
SlackBlockPlainTextOnly::from(SlackBlockPlainText::new(label.to_string())),
)),
])));
}
SlackMessageContent::new()
.with_text(text)
.with_blocks(blocks)
}
impl SlackState {
const TOOL_GROUP_CAP: usize = 20;
pub(crate) async fn upsert_tool_group(&self, ts: String, mut group: GroupState) -> GroupState {
let mut guard = self.tool_groups.lock().await;
let (order, map) = &mut *guard;
match map.get(&ts) {
Some(existing) => {
group.expanded = existing.expanded;
group.started_at = existing.started_at;
group.settled = existing.settled.clone();
}
None => {
order.push(ts.clone());
while order.len() > Self::TOOL_GROUP_CAP {
let oldest = order.remove(0);
map.remove(&oldest);
}
}
}
map.insert(ts, group.clone());
group
}
pub(crate) async fn toggle_tool_group(&self, ts: &str) -> Option<GroupState> {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(ts)?;
group.expanded = !group.expanded;
Some(group.clone())
}
pub(crate) async fn settle_tool_group(
&self,
ts: &str,
outcome: TurnOutcome,
waiting: Option<String>,
ctx: Option<String>,
) -> Option<GroupState> {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(ts)?;
group.settle(outcome, waiting, ctx);
Some(group.clone())
}
pub(crate) async fn tool_group_snapshot(&self, ts: &str) -> Option<GroupState> {
let guard = self.tool_groups.lock().await;
let (_, map) = &*guard;
map.get(ts).cloned()
}
pub(crate) async fn note_waiting_group(&self, channel: String, ts: String, session: Uuid) {
self.waiting_groups
.lock()
.await
.insert(channel, (ts, session));
}
pub(crate) async fn take_waiting_group_for(&self, channel: &str) -> Option<(String, Uuid)> {
self.waiting_groups.lock().await.remove(channel)
}
}