use crate::agent_sessions::{AgentSession, Freshness, WorktreeAgents};
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DetailMode {
#[default]
List,
Input,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DetailKind {
#[default]
Agents,
CiChecks,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailRole {
Normal,
Active,
Muted,
Success,
Failure,
Running,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetailRow {
pub label: String,
pub value: String,
pub role: DetailRole,
pub meta: Option<String>,
pub extra: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct DetailOverlay {
pub kind: DetailKind,
pub title: String,
pub rows: Vec<DetailRow>,
pub selected: usize,
pub mode: DetailMode,
pub input: String,
pub input_selected: usize,
}
impl DetailOverlay {
pub fn open(&mut self, kind: DetailKind, title: String, rows: Vec<DetailRow>) {
self.kind = kind;
self.title = title;
self.rows = rows;
self.selected = 0;
self.mode = DetailMode::List;
self.input.clear();
self.input_selected = 0;
}
pub fn set_rows(&mut self, rows: Vec<DetailRow>) {
self.rows = rows;
self.selected = self.selected.min(self.rows.len().saturating_sub(1));
self.input_selected = self
.input_selected
.min(filter_rows(&self.rows, &self.input).len().saturating_sub(1));
}
pub fn select_next(&mut self) {
self.selected = (self.selected + 1).min(self.rows.len().saturating_sub(1));
}
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn selected_meta(&self) -> Option<&str> {
self.rows.get(self.selected).and_then(|r| r.meta.as_deref())
}
}
pub fn agent_detail_rows(agents: Option<&WorktreeAgents>, pinned: &[String], now: SystemTime) -> Vec<DetailRow> {
let sessions = agents.map(|a| a.sessions.as_slice()).unwrap_or_default();
if sessions.is_empty() {
return vec![DetailRow {
label: "agents".into(),
value: "no agent session found".into(),
role: DetailRole::Muted,
meta: None,
extra: None,
}];
}
sessions
.iter()
.map(|s| {
let freshness = Freshness::classify(s.last_activity, s.ended, now);
let (word, role) = match freshness {
Freshness::Active => ("active", DetailRole::Active),
Freshness::Idle => ("idle", DetailRole::Muted),
};
let ago = now
.duration_since(s.last_activity)
.map(crate::worktree::format_relative_duration)
.unwrap_or_else(|_| "now".into());
let identity = s.name.as_deref().unwrap_or(&s.id);
let pin_mark = if pinned.iter().any(|p| p == &s.id) {
" · pinned"
} else {
""
};
DetailRow {
label: s.kind.display().to_string(),
value: format!("{word} · {ago} ago · {identity}{pin_mark}"),
role,
meta: Some(s.id.clone()),
extra: None,
}
})
.collect()
}
pub fn ci_check_rows(checks: &[crate::github::PrCheck], now: SystemTime) -> Vec<DetailRow> {
use crate::github::CheckOutcome;
use crate::tui::ui::{CI_FAILING_ICON, CI_PASSING_ICON, CI_RUNNING_ICON, CI_UNKNOWN_ICON};
checks
.iter()
.map(|c| {
let (icon, word, role) = match c.outcome {
CheckOutcome::Passing => (CI_PASSING_ICON, "passing", DetailRole::Success),
CheckOutcome::Failing => (CI_FAILING_ICON, "failing", DetailRole::Failure),
CheckOutcome::Running => (CI_RUNNING_ICON, "running", DetailRole::Running),
CheckOutcome::Unknown => (CI_UNKNOWN_ICON, "unknown", DetailRole::Muted),
};
DetailRow {
label: format!("{icon} {word}"),
value: c.name.clone(),
role,
meta: c.url.clone(),
extra: check_extra(c, now),
}
})
.collect()
}
fn check_extra(c: &crate::github::PrCheck, now: SystemTime) -> Option<String> {
let parse = |s: &Option<String>| {
s.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&chrono::Utc))
};
let duration = match (parse(&c.started_at), parse(&c.completed_at)) {
(Some(start), Some(end)) => u64::try_from((end - start).num_seconds()).ok().map(format_run_duration),
(Some(start), None) if c.outcome == crate::github::CheckOutcome::Running => {
let now = chrono::DateTime::<chrono::Utc>::from(now);
u64::try_from((now - start).num_seconds())
.ok()
.map(|secs| format!("{}…", format_run_duration(secs)))
}
_ => None,
};
match (c.workflow_name.as_deref(), duration) {
(Some(w), Some(d)) => Some(format!("{w} · {d}")),
(Some(w), None) => Some(w.to_string()),
(None, Some(d)) => Some(d),
(None, None) => None,
}
}
fn format_run_duration(secs: u64) -> String {
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("{h}h{m:02}m")
} else if m > 0 && s > 0 {
format!("{m}m{s:02}s")
} else if m > 0 {
format!("{m}m")
} else {
format!("{s}s")
}
}
pub fn filter_rows(rows: &[DetailRow], query: &str) -> Vec<usize> {
let q = query.to_lowercase();
rows
.iter()
.enumerate()
.filter(|(_, r)| q.is_empty() || r.value.to_lowercase().contains(&q))
.map(|(i, _)| i)
.collect()
}
pub fn filter_sessions<'a>(all: &'a [AgentSession], query: &str) -> Vec<&'a AgentSession> {
let q = query.to_lowercase();
all
.iter()
.filter(|s| {
q.is_empty()
|| s.id.to_lowercase().contains(&q)
|| s.name.as_deref().is_some_and(|n| n.to_lowercase().contains(&q))
|| s.kind.display().contains(&q)
})
.collect()
}