use super::flow::{HeaderMarkup, StreamingState, humanize_duration, open_flow, refresh_flow};
use super::handler::escape_html;
use super::markdown::format_inline;
use crate::brain::agent::AgentService;
use crate::brain::goal::GoalManager;
use crate::tui::plan::TaskStatus;
use std::sync::Arc;
use teloxide::prelude::*;
use uuid::Uuid;
const SECTION_TEXT_CAP: usize = 60;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PlanKb {
#[default]
None,
ApproveDiscard,
DiscardOnly,
}
impl PlanKb {
pub(crate) fn keyboard(self) -> Option<teloxide::types::InlineKeyboardMarkup> {
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup};
match self {
PlanKb::None => None,
PlanKb::ApproveDiscard => Some(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback("✅ Approve plan", "plan:ok"),
InlineKeyboardButton::callback("🗑 Discard", "plan:no"),
]])),
PlanKb::DiscardOnly => Some(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback("🗑 Discard plan", "plan:no"),
]])),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ProseSection {
pub(crate) heading: Option<String>,
pub(crate) body: String,
}
pub(crate) fn split_plan_prose(md: &str) -> Vec<ProseSection> {
let mut raw: Vec<(Option<String>, Vec<&str>)> = vec![(None, Vec::new())];
let mut in_fence = false;
let mut seen_content = false;
let mut h1_stripped = false;
for line in md.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_fence = !in_fence;
seen_content = true;
raw.last_mut().expect("raw starts non-empty").1.push(line);
continue;
}
if !in_fence {
if !h1_stripped && !seen_content && trimmed.starts_with("# ") {
h1_stripped = true;
seen_content = true;
continue;
}
if let Some(h) = trimmed.strip_prefix("## ") {
let h = h.trim();
if !h.is_empty() {
raw.push((Some(h.to_string()), Vec::new()));
seen_content = true;
continue;
}
}
}
if !trimmed.is_empty() {
seen_content = true;
}
raw.last_mut().expect("raw starts non-empty").1.push(line);
}
raw.into_iter()
.filter_map(|(heading, lines)| {
let body = lines.join("\n").trim().to_string();
(!body.is_empty()).then_some(ProseSection { heading, body })
})
.collect()
}
fn prose_body_lines(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut in_fence = false;
for line in body.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
out.push(format!("<code>{}</code>", escape_html(line)));
continue;
}
if trimmed.is_empty() {
out.push(String::new());
continue;
}
if trimmed.starts_with('#') {
let content = trimmed.trim_start_matches('#').trim();
out.push(format!("<b>{}</b>", format_inline(&escape_html(content))));
continue;
}
if let Some(item) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
out.push(format!("• {}", format_inline(&escape_html(item))));
continue;
}
out.push(format_inline(&escape_html(line)));
}
out
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct GoalSection {
pub(crate) text: String,
pub(crate) completed: bool,
}
impl GoalSection {
fn prefix(&self, settled: bool) -> String {
let icon = if self.completed && settled {
"✅"
} else {
"🎯"
};
format!("<b>{icon} Goal:</b>")
}
}
#[derive(Default, Clone, PartialEq)]
pub(crate) struct FlowSections {
pub(crate) plan_state: Option<String>,
pub(crate) plan_kb: PlanKb,
pub(crate) plan_title: Option<String>,
pub(crate) prose: Option<Vec<ProseSection>>,
pub(crate) checklist: Option<Vec<String>>,
pub(crate) goal: Option<GoalSection>,
pub(crate) ctx: Option<String>,
}
impl FlowSections {
fn has_prose(&self) -> bool {
self.prose.as_ref().is_some_and(|p| !p.is_empty())
}
pub(crate) fn chrome_rich(&self, settled: bool) -> String {
let mut out = String::new();
if let Some(ref t) = self.plan_title {
out.push_str(&format!("<p>📋 <b>{}</b></p>", escape_html(t)));
}
if let Some(ref sections) = self.prose {
for sec in sections {
let body: String = prose_body_lines(&sec.body)
.into_iter()
.filter(|l| !l.is_empty())
.map(|l| format!("<p>{l}</p>"))
.collect();
match &sec.heading {
Some(h) => out.push_str(&format!(
"<details><summary>{}</summary>{body}</details>",
escape_html(h)
)),
None => out.push_str(&body),
}
}
}
if let Some(ref rows) = self.checklist {
if self.has_prose() {
out.push_str("<hr>");
}
for row in rows {
out.push_str(&format!("<p>{}</p>", escape_html(row)));
}
}
if let Some(ref g) = self.goal {
let paras: Vec<&str> = g
.text
.split("\n\n")
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
if !paras.is_empty() {
if self.checklist.is_some() || self.has_prose() {
out.push_str("<hr>");
}
let prefix = g.prefix(settled);
if let [one] = paras.as_slice() {
out.push_str(&format!("<p>{prefix} {}</p>", escape_html(one)));
} else {
let body: String = paras[1..]
.iter()
.map(|p| format!("<p>{}</p>", escape_html(p)))
.collect();
out.push_str(&format!(
"<details><summary>{prefix} {}</summary>{body}</details>",
escape_html(paras[0])
));
}
}
}
out
}
pub(crate) fn chrome_classic(&self, settled: bool) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(ref t) = self.plan_title {
parts.push(format!("📋 <b>{}</b>", escape_html(t)));
}
if let Some(ref sections) = self.prose {
for sec in sections {
let body = prose_body_lines(&sec.body).join("\n");
match &sec.heading {
Some(h) => parts.push(format!(
"<blockquote expandable><b>{}</b>\n{body}</blockquote>",
escape_html(h)
)),
None => parts.push(body),
}
}
}
if let Some(ref rows) = self.checklist {
if self.has_prose() {
parts.push(String::new());
}
for row in rows {
parts.push(escape_html(row));
}
}
if let Some(ref g) = self.goal {
let text = g.text.trim();
if !text.is_empty() {
if self.checklist.is_some() || self.has_prose() {
parts.push(String::new());
}
parts.push(format!(
"<blockquote expandable>{} {}</blockquote>",
g.prefix(settled),
escape_html(text)
));
}
}
parts.join("\n")
}
}
pub(crate) fn clock_glyph(secs: u64) -> String {
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("⏱ {h}:{m:02}:{s:02}")
} else {
format!("⏱ {m}:{s:02}")
}
}
pub(crate) struct FooterParts<'a> {
pub(crate) outcome: Option<(&'a str, &'a str)>,
pub(crate) plan_state: Option<&'a str>,
pub(crate) working_on: Option<&'a str>,
pub(crate) activity: Option<&'a str>,
pub(crate) tool_count: usize,
pub(crate) has_log: bool,
pub(crate) ctx: Option<&'a str>,
pub(crate) elapsed_secs: u64,
}
pub(crate) fn merged_footer(parts: &FooterParts, markup: HeaderMarkup) -> String {
let esc = |s: &str| match markup {
HeaderMarkup::Html => escape_html(s),
HeaderMarkup::Markdown => s.to_string(),
};
let settled = parts.outcome.is_some();
let mut segs: Vec<String> = Vec::new();
if let Some((icon, verb)) = parts.outcome {
segs.push(format!("{icon} {}", esc(verb)));
} else if let Some(ps) = parts.plan_state {
segs.push(esc(ps));
} else if let Some(w) = parts.working_on {
segs.push(esc(w));
}
if parts.has_log {
let mut seg2 = String::new();
if !settled && let Some(act) = parts.activity {
let act = act.trim_start_matches(['⚙', '\u{fe0f}']).trim_start();
if !act.is_empty() {
seg2 = format!("⚙️ {}", esc(act));
}
}
if parts.tool_count >= 1 {
let count = format!("{} tool calls", parts.tool_count);
if seg2.is_empty() {
seg2 = if settled {
count
} else {
format!("⚙️ {count}")
};
} else {
seg2 = format!("{seg2} • {count}");
}
} else if !settled && seg2.is_empty() {
seg2 = "⚙️".to_string();
}
if !seg2.is_empty() {
segs.push(seg2);
}
}
if let Some(c) = parts.ctx {
segs.push(esc(c));
}
segs.push(clock_glyph(parts.elapsed_secs));
segs.join(" • ")
}
pub(crate) async fn load_plan_sections(session_id: Uuid) -> (Option<String>, Option<Vec<String>>) {
let Some(plan) = crate::utils::plan_files::load_plan(session_id).await else {
return (None, None);
};
let title = {
let t = plan.title.trim();
(!t.is_empty()).then(|| crate::utils::truncate_str(t, SECTION_TEXT_CAP).to_string())
};
let checklist = (!plan.tasks.is_empty()).then(|| {
plan.tasks
.iter()
.map(|t| {
let mark = if matches!(t.status, TaskStatus::Completed) {
'☑'
} else {
'☐'
};
let title = crate::utils::truncate_str(t.title.trim(), SECTION_TEXT_CAP);
format!("{mark} {title}")
})
.collect()
});
(title, checklist)
}
pub(crate) async fn load_plan_prose(session_id: Uuid) -> Option<Vec<ProseSection>> {
let path = crate::utils::plan_files::plan_md_path(session_id).await;
let body = match tokio::fs::read_to_string(&path).await {
Ok(body) => body,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::debug!(
"Telegram flow chrome: plan prose read failed for {}: {e}",
path.display()
);
}
return None;
}
};
let sections = split_plan_prose(&body);
(!sections.is_empty()).then_some(sections)
}
pub(crate) async fn load_goal_section(
agent: &AgentService,
session_id: Uuid,
) -> Option<(String, bool)> {
let mgr = GoalManager::new(agent.context().clone());
match mgr.get_goal(session_id).await {
Ok(Some(goal)) if goal.state == "active" || goal.state == "completed" => {
let text = goal.goal_text.trim().to_string();
(!text.is_empty()).then_some((text, goal.state == "completed"))
}
Ok(_) => None,
Err(e) => {
tracing::debug!("Telegram flow chrome: goal lookup failed: {e}");
None
}
}
}
pub(crate) async fn load_plan_state_section(
session_id: Uuid,
turn_active: bool,
) -> (Option<String>, PlanKb) {
use crate::utils::plan_files::{PlanModeState, plan_mode_state};
match plan_mode_state(session_id).await {
PlanModeState::NoPlan => (None, PlanKb::None),
PlanModeState::PreInitEditing => (Some("📝 Discussing plan".to_string()), PlanKb::None),
PlanModeState::PostInitEditing => {
(Some("✍️ Editing plan".to_string()), PlanKb::ApproveDiscard)
}
PlanModeState::Active => {
if crate::utils::plan_mode::in_seed_window(session_id).await {
if turn_active {
(
Some("⏳ Building checklist…".to_string()),
PlanKb::DiscardOnly,
)
} else {
(
Some("⚠️ Checklist seed incomplete • retry: /execute".to_string()),
PlanKb::DiscardOnly,
)
}
} else {
(None, PlanKb::DiscardOnly)
}
}
}
}
pub(crate) async fn refresh_sections(
streaming: &Arc<std::sync::Mutex<StreamingState>>,
agent: &AgentService,
session_id: Uuid,
) -> bool {
use crate::utils::plan_files::{PlanModeState, plan_mode_state};
let (plan_title, checklist) = load_plan_sections(session_id).await;
let prose = load_plan_prose(session_id).await;
let mode = plan_mode_state(session_id).await;
let editing = matches!(
mode,
PlanModeState::PreInitEditing | PlanModeState::PostInitEditing
);
let live_goal = if editing {
None
} else {
load_goal_section(agent, session_id).await
};
let turn_active = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.flow_outcome.is_none()
};
let (plan_state, plan_kb) = load_plan_state_section(session_id, turn_active).await;
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
let goal = match live_goal {
Some((text, false)) => {
s.retained_goal = Some(text.clone());
Some(GoalSection {
text,
completed: false,
})
}
Some((text, true)) => s.retained_goal.is_some().then_some(GoalSection {
text,
completed: true,
}),
None if mode == PlanModeState::Active => s.retained_goal.clone().map(|text| GoalSection {
text,
completed: true,
}),
None => None,
};
let next = FlowSections {
plan_state,
plan_kb,
plan_title,
prose,
checklist,
goal,
ctx: s.sections.ctx.clone(),
};
if s.sections == next {
false
} else {
s.sections = next;
true
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn tick_flow_header(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
agent: &AgentService,
session_id: Uuid,
show_status: bool,
turn_done: bool,
preview: Option<String>,
mut needs_refresh: bool,
) {
let open_block = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.open_group_msg_id
};
if open_block.is_some() {
if show_status {
let changed = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
let elapsed = s.turn_started_at.elapsed().as_secs();
let mut changed = false;
let duration = (elapsed > 0).then(|| humanize_duration(elapsed));
if duration.is_some() && s.flow_status != duration {
s.flow_status = duration;
changed = true;
}
if s.header_preview != preview {
s.header_preview = preview;
changed = true;
}
changed
};
needs_refresh |= changed;
needs_refresh |= refresh_sections(streaming, agent, session_id).await;
}
if needs_refresh {
refresh_flow(bot, chat, streaming).await;
}
} else {
if needs_refresh {
refresh_flow(bot, chat, streaming).await;
}
if show_status && !turn_done {
{
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.header_preview = preview;
let elapsed = s.turn_started_at.elapsed().as_secs();
if elapsed > 0 {
s.flow_status = Some(humanize_duration(elapsed));
}
}
refresh_sections(streaming, agent, session_id).await;
open_flow(bot, chat, thread_id, streaming).await;
}
}
}