use std::path::Path;
use crate::preview::SummaryAdapter;
pub fn select(command: &str) -> Option<&'static dyn SummaryAdapter> {
let first = command.split_whitespace().next()?;
let name = Path::new(first).file_name()?.to_str()?;
super::AGENTS
.iter()
.find(|a| a.harness.shape().0 == name)
.map(|a| a.summary)
}
pub(crate) const AWAITING_APPROVAL: &str = "awaiting approval";
fn is_rule_row(row: &str) -> bool {
let mut n = 0usize;
for c in row.trim().chars() {
if c != '─' {
return false;
}
n += 1;
}
n >= 40
}
fn braille_frame(c: char) -> bool {
('\u{2800}'..='\u{28FF}').contains(&c)
}
fn spinner_text(row: &str, is_frame: impl Fn(char) -> bool) -> Option<String> {
let mut chars = row.chars();
if !is_frame(chars.next()?) || chars.next()? != ' ' {
return None;
}
let rest = chars.as_str();
let text = &rest[..rest.find('…')? + '…'.len_utf8()];
text.chars()
.next()?
.is_alphanumeric()
.then(|| text.to_string())
}
fn slow_segments(tail: &str, drop: impl Fn(&str) -> bool) -> String {
let mut out = String::new();
for seg in tail.split(" · ") {
let seg = seg.trim();
if seg.is_empty() || drop(seg) {
continue;
}
out.push_str(" · ");
out.push_str(seg);
}
out
}
const CLAUDE_SPINNER: &[char] = &['·', '✢', '✳', '✶', '✻', '✽'];
const CLAUDE_STATUS_WINDOW: usize = 16;
pub struct ClaudeSummary;
impl SummaryAdapter for ClaudeSummary {
fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> {
match claude_box_top(rows) {
Some(top) => claude_spinner_status(rows, top),
None => claude_approval(rows),
}
}
fn model_label(&self, rows: &[String]) -> Option<String> {
claude_welcome_label(rows)
}
fn normalize_title(&self, title: &str) -> Option<String> {
let mut chars = title.chars();
let frame = chars.next()?;
let framed = CLAUDE_SPINNER.contains(&frame)
|| braille_frame(frame)
|| ('\u{25D0}'..='\u{25D3}').contains(&frame);
(framed && chars.next()? == ' ' && !chars.as_str().is_empty())
.then(|| chars.as_str().to_string())
}
}
fn claude_box_top(rows: &[String]) -> Option<usize> {
let bottom = rows.iter().rposition(|r| is_rule_row(r))?;
let top = (bottom.saturating_sub(6)..bottom)
.rev()
.find(|&i| is_rule_row(&rows[i]))?;
rows[top + 1..bottom]
.iter()
.any(|r| r.starts_with('❯'))
.then_some(top)
}
fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> {
let mut content = 0usize;
for i in (0..top).rev() {
let row = &rows[i];
if row.is_empty() {
continue;
}
content += 1;
if content > CLAUDE_STATUS_WINDOW {
return None;
}
if row.starts_with(' ') {
continue;
}
if let Some(verb) = spinner_text(row, |c| CLAUDE_SPINNER.contains(&c)) {
let tail = claude_semantic_tail(row);
if let Some(action) = claude_action_row(rows, i) {
return Some((format!("{action}{tail}"), "claude:action-row"));
}
return Some((format!("{verb}{tail}"), "claude:spinner"));
}
if let Some(waiting) = claude_waiting_text(row) {
return Some((waiting, "claude:waiting"));
}
return None;
}
None
}
fn claude_waiting_text(row: &str) -> Option<String> {
let mut chars = row.chars();
if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' {
return None;
}
let text = chars.as_str();
let rest = text.strip_prefix("Waiting for ")?;
let digits = rest.chars().take_while(char::is_ascii_digit).count();
if digits == 0 {
return None;
}
let middle = rest[digits..]
.strip_prefix(' ')?
.strip_suffix(" to finish")?;
(1..=3)
.contains(&middle.split_whitespace().count())
.then(|| text.to_string())
}
fn claude_semantic_tail(row: &str) -> String {
let Some(open) = row.find("… (") else {
return String::new();
};
let inner = &row[open + "… (".len()..];
let inner = inner.strip_suffix(')').unwrap_or(inner);
slow_segments(inner, claude_ticker_segment)
}
fn claude_ticker_segment(seg: &str) -> bool {
if seg == "esc to interrupt"
|| seg == "tokens"
|| seg.starts_with('↓')
|| seg.starts_with('↑')
|| seg.ends_with(" tokens")
{
return true;
}
!seg.is_empty()
&& seg.split_whitespace().all(|tok| {
let Some((num, unit)) = tok.split_at_checked(tok.len() - 1) else {
return false;
};
matches!(unit, "s" | "m" | "h")
&& num.starts_with(|c: char| c.is_ascii_digit())
&& num.chars().all(|c| c.is_ascii_digit() || c == '.')
})
}
fn claude_action_row(rows: &[String], spinner: usize) -> Option<String> {
let row = rows[..spinner].iter().rev().find(|r| !r.is_empty())?;
let text = row.strip_prefix("⏺ ")?.trim();
let tail = text.len().checked_sub('…'.len_utf8())?;
(text.find('…') == Some(tail)).then(|| text.to_string())
}
fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> {
let last = rows.iter().rposition(|r| !r.is_empty())?;
let i = (last.saturating_sub(8)..=last).find(|&i| rows[i].trim_start().starts_with("❯ 1. "))?;
let next = rows[i + 1..].iter().find(|r| !r.is_empty())?;
next.trim_start()
.starts_with("2. ")
.then(|| (AWAITING_APPROVAL.to_string(), "claude:approval-menu"))
}
const CLAUDE_EFFORT: &[&str] = &["low", "medium", "high", "xhigh", "max"];
fn claude_welcome_label(rows: &[String]) -> Option<String> {
let start = rows
.iter()
.take(4)
.position(|r| r.trim_start().starts_with("╭─── Claude Code"))?;
for row in &rows[start + 1..] {
if row.trim_start().starts_with('╰') {
break;
}
let Some(cell) = row.split('│').nth(1) else {
continue;
};
let head = cell.trim().split(" · ").next().unwrap_or("");
if let Some(label) = claude_model_effort(head) {
return Some(label);
}
}
None
}
fn claude_model_effort(head: &str) -> Option<String> {
let (model, effort) = match head.strip_suffix(" effort") {
Some(full) => full.rsplit_once(" with ")?,
None => {
let (model, effort) = head.strip_suffix('…')?.rsplit_once(" with ")?;
CLAUDE_EFFORT.contains(&effort).then_some((model, effort))?
}
};
(!model.is_empty() && !effort.is_empty()).then(|| format!("{model} ({effort})"))
}
const CODEX_PROMPT: &[char] = &['›', '»', '!'];
const CODEX_QUEUED_HEADS: &[&str] = &[
"• Messages to be submitted after next tool call",
"• Messages to be submitted at end of turn",
"• Queued follow-up inputs",
];
const CODEX_EFFORT: &[&str] = &[
"minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default",
];
const CODEX_STATUS_WINDOW: usize = 10;
pub struct CodexSummary;
impl SummaryAdapter for CodexSummary {
fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> {
if let Some(hit) = codex_approval(rows) {
return Some(hit);
}
let composer = codex_composer(rows)?;
codex_status(rows, composer)
}
fn model_label(&self, rows: &[String]) -> Option<String> {
codex_model_label(rows)
}
fn normalize_title(&self, title: &str) -> Option<String> {
if let Some(rest) = title.strip_prefix("[ . ] ") {
return Some(format!("[ ! ] {rest}"));
}
let mut chars = title.chars();
let frame = chars.next()?;
(braille_frame(frame) && chars.next()? == ' ').then(|| format!("⠋ {}", chars.as_str()))
}
}
fn codex_menu_head(row: &str) -> bool {
row.strip_prefix("› ")
.and_then(|r| r.strip_prefix(|c: char| c.is_ascii_digit()))
.is_some_and(|r| r.starts_with(". "))
}
fn codex_numbered_option(row: &str) -> bool {
let t = row.trim_start();
let digits = t.chars().take_while(char::is_ascii_digit).count();
t.len() > digits && digits >= 1 && t[digits..].starts_with(". ")
}
fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> {
let last = rows.iter().rposition(|r| !r.is_empty())?;
let i = (last.saturating_sub(8)..=last).find(|&i| codex_menu_head(&rows[i]))?;
let sibling = rows[i + 1..].iter().find(|r| !r.is_empty())?;
if !(sibling.starts_with(' ') && codex_numbered_option(sibling)) {
return None;
}
rows[i + 1..]
.iter()
.all(|r| !r.starts_with(CODEX_PROMPT) || codex_menu_head(r))
.then(|| (AWAITING_APPROVAL.to_string(), "codex:approval-menu"))
}
fn codex_model_label(rows: &[String]) -> Option<String> {
let last = rows.iter().rposition(|r| !r.is_empty())?;
(last.saturating_sub(5)..=last).rev().find_map(|i| {
if !rows[i].starts_with(' ') {
return None;
}
let segs: Vec<&str> = rows[i].trim().split(" · ").collect();
if segs[0].is_empty() {
return None;
}
let in_out = segs.len() >= 3
&& segs[segs.len() - 2].ends_with(" in")
&& segs[segs.len() - 1].ends_with(" out");
(in_out || codex_model_with_reasoning(segs[0])).then(|| segs[0].to_string())
})
}
fn codex_model_with_reasoning(item: &str) -> bool {
let words: Vec<&str> = item.split_whitespace().collect();
matches!(words.len(), 2 | 3) && CODEX_EFFORT.contains(&words[1])
}
fn codex_composer(rows: &[String]) -> Option<usize> {
rows.iter().rposition(|r| {
let mut chars = r.chars();
chars.next().is_some_and(|c| CODEX_PROMPT.contains(&c))
&& matches!(chars.next(), None | Some(' '))
&& !codex_menu_head(r)
})
}
fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static str)> {
let mut indented = 0usize;
for row in rows[..composer].iter().rev() {
if row.is_empty() {
continue;
}
if row.starts_with(' ') {
indented += 1;
continue;
}
if CODEX_QUEUED_HEADS.iter().any(|h| row.starts_with(h)) {
indented = 0;
continue;
}
if indented > CODEX_STATUS_WINDOW {
return None;
}
if let Some(cmd) = row.strip_prefix("• Ran ")
&& !cmd.is_empty()
{
return Some((format!("Ran {cmd}"), "codex:ran"));
}
if let Some((header, after_paren)) = codex_status_head(row) {
return Some((codex_working(header, after_paren), "codex:working"));
}
return None;
}
None
}
fn codex_status_head(row: &str) -> Option<(&str, &str)> {
let rest = row
.strip_prefix("• ")
.or_else(|| row.strip_prefix("◦ "))
.unwrap_or(row);
if !rest.starts_with(char::is_alphanumeric) {
return None;
}
rest.match_indices(" (").find_map(|(i, _)| {
let after = &rest[i + " (".len()..];
codex_interrupt_paren(after).then(|| (&rest[..i], after))
})
}
fn codex_interrupt_paren(s: &str) -> bool {
let Some(hint) = codex_elapsed(s).and_then(|rest| rest.strip_prefix(" • ")) else {
return false;
};
match hint.find(')') {
Some(end) => hint[..end].ends_with(" to interrupt"),
None => hint.ends_with('…'),
}
}
fn codex_elapsed(s: &str) -> Option<&str> {
let mut rest = s;
let mut units = "hms";
loop {
let digits = rest.chars().take_while(char::is_ascii_digit).count();
if digits == 0 {
return None;
}
let tail = &rest[digits..];
let unit = tail.chars().next()?;
let at = units.find(unit)?;
units = &units[at + 1..];
let after = &tail[unit.len_utf8()..];
if unit == 's' {
return Some(after);
}
rest = after.strip_prefix(' ')?;
}
}
fn codex_working(header: &str, after_paren: &str) -> String {
let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]);
format!(
"{header}{}",
slow_segments(tail, |seg| seg.starts_with('/'))
)
}
pub struct GrokSummary;
impl SummaryAdapter for GrokSummary {
fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> {
let (top, _) = grok_input_box(rows)?;
let probe = rows[..top].iter().rev().find(|r| !r.is_empty())?;
let t = probe.trim_start();
if let Some(text) = spinner_text(t, braille_frame) {
return Some((text, "grok:spinner"));
}
grok_worked(t)
.then(|| (t.to_string(), "grok:worked"))
.or_else(|| grok_still_running(t).map(|text| (text, "grok:still-running")))
}
fn model_label(&self, rows: &[String]) -> Option<String> {
let (_, bottom) = grok_input_box(rows)?;
grok_border_label(&rows[bottom])
}
}
fn grok_input_box(rows: &[String]) -> Option<(usize, usize)> {
let bottom = rows.iter().rposition(|r| {
let t = r.trim();
t.starts_with('╰') && t.ends_with('╯')
})?;
let top = (bottom.saturating_sub(6)..bottom).rev().find(|&i| {
let t = rows[i].trim();
t.starts_with('╭') && t.ends_with('╮')
})?;
rows[top + 1..bottom]
.iter()
.any(|r| r.trim_start().starts_with('│'))
.then_some((top, bottom))
}
fn grok_worked(t: &str) -> bool {
t.strip_prefix("Worked for ")
.and_then(|r| r.strip_suffix('s'))
.is_some_and(|n| {
n.starts_with(|c: char| c.is_ascii_digit())
&& n.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '.' | ' ' | 'm' | 'h'))
})
}
fn grok_still_running(t: &str) -> Option<String> {
let rest = t.strip_prefix("◎ ")?;
let rest = rest
.strip_suffix(" · send a message to interrupt")
.unwrap_or(rest);
if rest == "waiting" {
return Some(rest.to_string());
}
let body = rest.strip_suffix(" still running")?;
body.split(" · ")
.all(grok_still_running_count)
.then(|| rest.to_string())
}
fn grok_still_running_count(seg: &str) -> bool {
let digits = seg.chars().take_while(char::is_ascii_digit).count();
if digits == 0 {
return false;
}
let Some(words) = seg[digits..].strip_prefix(' ') else {
return false;
};
(1..=3).contains(&words.split_whitespace().count())
}
fn grok_border_label(row: &str) -> Option<String> {
let t = row.trim().strip_suffix('╯')?;
let t = t.trim_end_matches(['─', ' ']);
let text = &t[t.rfind('─')? + '─'.len_utf8()..];
let label = text.trim().split(" · ").next()?.trim();
(!label.is_empty()).then(|| label.to_string())
}
const OMP_HINTS: &[&str] = &["⟦esc⟧", "⟨esc⟩"];
const OMP_CURSORS: &[&str] = &["❯", "\u{f054}", ">"];
pub struct OmpSummary;
impl SummaryAdapter for OmpSummary {
fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> {
match omp_input_box(rows) {
Some(top) => omp_spinner_status(rows, top),
None => omp_approval(rows),
}
}
fn model_label(&self, _rows: &[String]) -> Option<String> {
None
}
fn normalize_title(&self, title: &str) -> Option<String> {
if let Some(label) = title.strip_prefix("π: ") {
return (!label.is_empty()).then(|| label.to_string());
}
let mut chars = title.strip_prefix("π ")?.chars();
let sep = chars.next()?;
let label = match chars.next() {
None => "",
Some(' ') => chars.as_str(),
Some(_) => return None,
};
match sep {
'>' => (!label.is_empty()).then(|| label.to_string()),
'!' if label.is_empty() => Some("!".to_string()),
'!' => Some(format!("! {label}")),
f if braille_frame(f) && label.is_empty() => Some("⠋".to_string()),
f if braille_frame(f) => Some(format!("⠋ {label}")),
_ => None,
}
}
}
fn omp_input_box(rows: &[String]) -> Option<usize> {
let bottom = rows.iter().rposition(|r| {
let t = r.trim();
t.starts_with('╰') && t.ends_with('╯')
})?;
let t = rows[..bottom].last()?.trim();
(t.starts_with('╭') && t.ends_with('╮')).then(|| bottom - 1)
}
fn omp_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> {
let probe = rows[..top].iter().rev().find(|r| !r.is_empty())?;
let mut chars = probe.trim_start().chars();
if !braille_frame(chars.next()?) || chars.next()? != ' ' {
return None;
}
let rest = chars.as_str();
let text = OMP_HINTS
.iter()
.find_map(|h| rest.strip_suffix(h))?
.strip_suffix(' ')?;
text.chars()
.next()?
.is_alphanumeric()
.then(|| (text.to_string(), "omp:spinner"))
}
fn omp_approval(rows: &[String]) -> Option<(String, &'static str)> {
let last = rows.iter().rposition(|r| !r.is_empty())?;
let i = (last.saturating_sub(8)..=last).find(|&i| omp_approve_row(&rows[i]))?;
if rows[i + 1..].iter().find(|r| !r.is_empty())?.trim() != "Deny" {
return None;
}
rows[i.saturating_sub(6)..i]
.iter()
.any(|r| omp_allow_head(r))
.then(|| (AWAITING_APPROVAL.to_string(), "omp:approval-menu"))
}
fn omp_approve_row(row: &str) -> bool {
let t = row.trim();
OMP_CURSORS
.iter()
.any(|c| t.strip_prefix(c) == Some(" Approve"))
}
fn omp_allow_head(row: &str) -> bool {
row.trim().starts_with("Allow tool: ")
}
#[cfg(test)]
#[path = "summary_tests.rs"]
mod tests;