use std::path::Path;
use crate::preview::{ScreenFacts, SummaryAdapter};
pub fn select(command: &str) -> Option<&'static dyn SummaryAdapter> {
let first = command.split_whitespace().next()?;
match Path::new(first).file_name()?.to_str()? {
"claude" => Some(&ClaudeSummary),
"codex" => Some(&CodexSummary),
"grok" => Some(&GrokSummary),
_ => None,
}
}
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 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, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> {
let rows = screen.live_rows();
match claude_box_top(&rows) {
Some(top) => claude_spinner_status(&rows, top),
None => claude_approval(&rows),
}
}
fn model_label(&self, screen: &dyn ScreenFacts) -> Option<String> {
claude_welcome_label(&screen.live_rows())
}
fn normalize_title(&self, title: &str) -> Option<String> {
let mut chars = title.chars();
let frame = chars.next()?;
let framed = CLAUDE_SPINNER.contains(&frame) || ('\u{2800}'..='\u{28FF}').contains(&frame);
(framed && chars.next()? == ' ').then(|| format!("✻ {}", chars.as_str()))
}
}
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"))
}
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(model_effort) = head.strip_suffix(" effort")
&& let Some((model, effort)) = model_effort.rsplit_once(" with ")
&& !model.is_empty()
&& !effort.is_empty()
{
return Some(format!("{model} ({effort})"));
}
}
None
}
pub struct CodexSummary;
impl SummaryAdapter for CodexSummary {
fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> {
let rows = screen.live_rows();
if let Some(hit) = codex_approval(&rows) {
return Some(hit);
}
let composer = codex_composer(&rows)?;
codex_status(&rows, composer)
}
fn model_label(&self, screen: &dyn ScreenFacts) -> Option<String> {
let rows = screen.live_rows();
let token = codex_token_line(&rows)?;
Some(rows[token].trim().split(" · ").next()?.to_string())
}
}
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_menu_head(r))
.then(|| ("awaiting approval".to_string(), "codex:approval-menu"))
}
fn codex_token_line(rows: &[String]) -> Option<usize> {
let last = rows.iter().rposition(|r| !r.is_empty())?;
(last.saturating_sub(5)..=last).rev().find(|&i| {
let segs: Vec<&str> = rows[i].trim().split(" · ").collect();
segs.len() >= 3
&& !segs[0].is_empty()
&& segs[segs.len() - 2].ends_with(" in")
&& segs[segs.len() - 1].ends_with(" out")
})
}
fn codex_composer(rows: &[String]) -> Option<usize> {
rows.iter()
.rposition(|r| (r.as_str() == "›" || r.starts_with("› ")) && !codex_menu_head(r))
}
fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static str)> {
for row in rows[composer.saturating_sub(10)..composer].iter().rev() {
if row.is_empty() || row.starts_with(' ') {
continue;
}
if let Some(after_paren) = row.strip_prefix("• Working (") {
return Some((codex_working(after_paren), "codex:working"));
}
if let Some(cmd) = row.strip_prefix("• Ran ")
&& !cmd.is_empty()
{
return Some((format!("Ran {cmd}"), "codex:ran"));
}
return None;
}
None
}
fn codex_working(after_paren: &str) -> String {
let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]);
format!("Working{}", slow_segments(tail, |seg| seg.starts_with('/')))
}
pub struct GrokSummary;
impl SummaryAdapter for GrokSummary {
fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> {
let rows = screen.live_rows();
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, |c| ('\u{2800}'..='\u{28FF}').contains(&c)) {
return Some((text, "grok:spinner"));
}
grok_worked(t).then(|| (t.to_string(), "grok:worked"))
}
fn model_label(&self, screen: &dyn ScreenFacts) -> Option<String> {
let rows = screen.live_rows();
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_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())
}
#[cfg(test)]
#[path = "summary_tests.rs"]
mod tests;