use std::path::Path;
use crate::preview::ScreenFacts;
pub trait SummaryAdapter: Sync {
fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)>;
fn model_label(&self, screen: &dyn ScreenFacts) -> Option<String>;
fn exit_preview(&self, _retained_text: &str) -> Option<String> {
None
}
fn normalize_title(&self, _title: &str) -> Option<String> {
None
}
}
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
}
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)> {
for i in (top.saturating_sub(CLAUDE_STATUS_WINDOW)..top).rev() {
let row = &rows[i];
if row.is_empty() || row.starts_with(' ') {
continue;
}
if let Some(verb) = claude_spinner_text(row) {
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-agents"));
}
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 n = text.strip_prefix("Waiting for ")?;
let digits = n.chars().take_while(char::is_ascii_digit).count();
let tail = &n[digits..];
(digits >= 1
&& (tail == " background agent to finish" || tail == " background agents to finish"))
.then(|| text.to_string())
}
fn claude_spinner_text(row: &str) -> Option<String> {
let mut chars = row.chars();
if !CLAUDE_SPINNER.contains(&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 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);
let mut out = String::new();
for seg in inner.split(" · ") {
let seg = seg.trim();
if seg.is_empty() || claude_ticker_segment(seg) {
continue;
}
out.push_str(" · ");
out.push_str(seg);
}
out
}
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 mut out = String::from("Working");
let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]);
for seg in tail.split(" · ") {
let seg = seg.trim();
if seg.is_empty() || seg.starts_with('/') {
continue;
}
out.push_str(" · ");
out.push_str(seg);
}
out
}
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) = grok_spinner_text(t) {
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_spinner_text(t: &str) -> Option<String> {
let mut chars = t.chars();
if !('\u{2800}'..='\u{28FF}').contains(&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 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)]
mod tests {
use std::time::Instant;
use super::*;
use crate::{
emulator::Emulator,
preview::{MARKER, Preview, PreviewSource, PreviewState},
};
struct RowsScreen {
rows: Vec<String>,
}
fn rs(rows: &[&str]) -> RowsScreen {
RowsScreen {
rows: rows.iter().map(|s| s.to_string()).collect(),
}
}
impl ScreenFacts for RowsScreen {
fn revision(&self) -> u64 {
1
}
fn alt_epoch(&self) -> u64 {
0
}
fn alternate_screen(&self) -> bool {
false
}
fn title(&self) -> Option<&str> {
None
}
fn live_floor(&self) -> String {
self.rows
.iter()
.rev()
.find(|r| !r.is_empty())
.cloned()
.unwrap_or_default()
}
fn live_rows(&self) -> Vec<String> {
self.rows.clone()
}
fn alt_leave_floor(&self) -> Option<&str> {
None
}
}
fn resolve_corpus(bytes: &[u8], adapter: &dyn SummaryAdapter, rows: u16, cols: u16) -> Preview {
let mut emu = Emulator::new(rows, cols, 2000);
emu.process(bytes);
let mut st = PreviewState::new();
st.resolve(Instant::now(), false, &emu, Some(adapter))
.clone()
}
fn anchor(text: &str, rule: &'static str) -> (String, PreviewSource, Option<&'static str>) {
(text.to_string(), PreviewSource::Anchor, Some(rule))
}
fn parts(p: &Preview) -> (String, PreviewSource, Option<&'static str>) {
(p.text.clone(), p.source, p.rule)
}
#[test]
fn select_matches_first_word_basenames_only() {
for cmd in [
"claude",
"claude --model opus",
"/usr/local/bin/claude --resume abc",
"codex resume 'not-checked-here'",
"grok",
] {
assert!(select(cmd).is_some(), "{cmd:?} must select an adapter");
}
for cmd in ["vim", "FOO=bar claude", "claude|tee log", "codex; ls", ""] {
assert!(select(cmd).is_none(), "{cmd:?} must select nothing");
}
}
#[test]
fn select_routes_to_the_matching_adapter() {
let sep = "─".repeat(80);
let claude = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep]);
assert_eq!(
select("claude").unwrap().live_preview(&claude).unwrap().1,
"claude:spinner"
);
let codex = rs(&[
"• Working (2s • esc to interrupt)",
"",
"› Write tests",
"",
" gpt-5.6-sol high · 0 in · 0 out",
]);
assert_eq!(
select("codex").unwrap().live_preview(&codex).unwrap().1,
"codex:working"
);
let grok = rs(&[
" ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]",
"",
" ╭──────────────────────╮",
" │ ❯ │",
" ╰── Grok 4.5 (xhigh) · always-approve ─╯",
]);
assert_eq!(
select("grok").unwrap().live_preview(&grok).unwrap().1,
"grok:spinner"
);
}
#[test]
fn claude_spinner_and_action_row() {
let sep = "─".repeat(120);
let spin = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep, " status"]);
assert_eq!(
ClaudeSummary.live_preview(&spin),
Some(("Hashing…".to_string(), "claude:spinner"))
);
let action = rs(&[
"⏺ Running 1 shell command…",
"",
"· Hashing… (3s · ↓ 52 tokens)",
&sep,
"❯",
&sep,
]);
assert_eq!(
ClaudeSummary.live_preview(&action),
Some(("Running 1 shell command…".to_string(), "claude:action-row"))
);
let attach = rs(&[
" Running 1 shell command…",
" ⎿ $ sleep 5 && echo ok",
"✻ Hashing… (6s)",
&sep,
"❯",
&sep,
]);
assert_eq!(
ClaudeSummary.live_preview(&attach),
Some(("Hashing…".to_string(), "claude:spinner"))
);
let reply = rs(&["⏺ ok", "", "✻ Hashing… (2s)", &sep, "❯", &sep]);
assert_eq!(
ClaudeSummary.live_preview(&reply),
Some(("Hashing…".to_string(), "claude:spinner"))
);
}
#[test]
fn claude_spinner_extracts_task_derived_phrases() {
let sep = "─".repeat(120);
let s = rs(&[
"✳ Overseeing phase 4 (adapters)… (54s · almost done thinking with high effort)",
&sep,
"❯",
&sep,
]);
assert_eq!(
ClaudeSummary.live_preview(&s),
Some((
"Overseeing phase 4 (adapters)… · almost done thinking with high effort"
.to_string(),
"claude:spinner"
))
);
}
#[test]
fn claude_parenthetical_keeps_slow_segments_and_drops_tickers() {
let sep = "─".repeat(120);
let spin = |row: &str| {
let rows = [row, &sep, "❯", &sep];
ClaudeSummary.live_preview(&rs(&rows))
};
assert_eq!(
spin("✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)"),
Some((
"Envisioning… · thinking with high effort".to_string(),
"claude:spinner"
))
);
for ticker in [
"6s",
"1m 8s",
"2h 3m",
"8.7s",
"↓ 87 tokens",
"↑ 1.2k tokens",
"↓ 2.1k",
"2.1k tokens",
"esc to interrupt",
] {
assert_eq!(
spin(&format!("✻ Hashing… ({ticker})")),
Some(("Hashing…".to_string(), "claude:spinner")),
"{ticker:?} must drop"
);
assert_eq!(
spin(&format!("✻ Hashing… ({ticker} · thinking)")),
Some(("Hashing… · thinking".to_string(), "claude:spinner")),
"{ticker:?} must drop beside a kept segment"
);
}
assert_eq!(
spin("✽ Concocting…"),
Some(("Concocting…".to_string(), "claude:spinner")),
"a row with no parenthetical is unchanged"
);
}
#[test]
fn claude_action_row_carries_the_spinner_rows_semantic_tail() {
let sep = "─".repeat(120);
let rows = [
"⏺ Running 1 shell command…",
"",
"✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)",
&sep,
"❯",
&sep,
];
assert_eq!(
ClaudeSummary.live_preview(&rs(&rows)),
Some((
"Running 1 shell command… · thinking with high effort".to_string(),
"claude:action-row"
))
);
}
#[test]
fn claude_waiting_row_matches_exactly_and_never_probes() {
let sep = "─".repeat(120);
let spin = |row: &str| {
let rows = [row, &sep, "❯", &sep];
ClaudeSummary.live_preview(&rs(&rows))
};
for row in [
"✻ Waiting for 1 background agent to finish",
"· Waiting for 1 background agent to finish",
] {
assert_eq!(
spin(row),
Some((
"Waiting for 1 background agent to finish".to_string(),
"claude:waiting-agents"
)),
"{row:?}"
);
}
assert_eq!(
spin("✻ Waiting for 3 background agents to finish"),
Some((
"Waiting for 3 background agents to finish".to_string(),
"claude:waiting-agents"
))
);
assert_eq!(spin("✻ Waiting patiently"), None);
assert_eq!(spin("· Waiting for review comments to land"), None);
let rows = [
"⏺ Running 1 shell command…",
"",
"✻ Waiting for 1 background agent to finish",
&sep,
"❯",
&sep,
];
assert_eq!(
ClaudeSummary.live_preview(&rs(&rows)),
Some((
"Waiting for 1 background agent to finish".to_string(),
"claude:waiting-agents"
))
);
}
#[test]
fn claude_title_frames_canonicalize_to_constant_text() {
for frame in CLAUDE_SPINNER {
assert_eq!(
ClaudeSummary.normalize_title(&format!("{frame} Claude Code")),
Some("✻ Claude Code".to_string()),
"{frame:?}"
);
}
let a = ClaudeSummary.normalize_title("✢ Claude Code");
let b = ClaudeSummary.normalize_title("✽ Claude Code");
assert_eq!(a, b, "two frames must normalize identically");
assert_eq!(
ClaudeSummary.normalize_title("⠐ Review fleetcom preview design document"),
Some("✻ Review fleetcom preview design document".to_string())
);
assert_eq!(
ClaudeSummary.normalize_title("⠴ Review fleetcom preview design document"),
Some("✻ Review fleetcom preview design document".to_string()),
"mid-block braille frame"
);
assert_eq!(ClaudeSummary.normalize_title("zellij: main"), None);
assert_eq!(ClaudeSummary.normalize_title("✻"), None, "frame alone");
}
#[test]
fn title_tier_renders_the_normalized_title() {
let mut emu = Emulator::new(24, 80, 100);
emu.process(b"\x1b[?1049h\x1b]0;\xe2\x9c\xa2 Claude Code\x07conversation body");
let mut st = PreviewState::new();
let p = st
.resolve(Instant::now(), false, &emu, Some(&ClaudeSummary))
.clone();
assert_eq!(
(p.text.as_str(), p.source, p.rule),
("✻ Claude Code", PreviewSource::Title, None)
);
let mut st = PreviewState::new();
let p = st.resolve(Instant::now(), false, &emu, None).clone();
assert_eq!(
(p.text.as_str(), p.source),
("✢ Claude Code", PreviewSource::Title),
"no adapter: verbatim"
);
}
#[test]
fn claude_aborts_on_foreign_column_zero_rows() {
let sep = "─".repeat(120);
let wrapped = rs(&["✻ Hashing… (6s · ↓ 87 to", "kens)", &sep, "❯", &sep]);
assert_eq!(ClaudeSummary.live_preview(&wrapped), None);
let menu = rs(&["❯ 1. Yes", " 2. No", &sep, "❯", &sep]);
assert_eq!(ClaudeSummary.live_preview(&menu), None);
}
#[test]
fn claude_scan_crosses_task_list_gaps_within_the_window() {
let sep = "─".repeat(120);
let behind_gap = |status: &str, gap: usize| {
let mut rows = vec![status.to_string()];
rows.push(" ⎿ ✔ Phase 0: verify facts".to_string());
rows.extend((1..gap).map(|i| format!(" ◼ Phase {i}: generic step")));
rows.extend([sep.clone(), "❯".to_string(), sep.clone()]);
let refs: Vec<&str> = rows.iter().map(String::as_str).collect();
ClaudeSummary.live_preview(&rs(&refs))
};
for gap in [4, 15] {
assert_eq!(
behind_gap(
"✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)",
gap
),
Some((
"Running phase 1 (dashboard UI)…".to_string(),
"claude:spinner"
)),
"gap of {gap} indented rows"
);
}
for gap in [16, 17, 24] {
assert_eq!(
behind_gap(
"✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)",
gap
),
None,
"gap of {gap} indented rows must exhaust the window"
);
}
assert_eq!(
behind_gap("✻ Waiting for 2 background agents to finish", 5),
Some((
"Waiting for 2 background agents to finish".to_string(),
"claude:waiting-agents"
))
);
let prose = rs(&[
"✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)",
"⏺ The phase list below is queued, not running.",
" ⎿ ✔ Phase 0: verify facts",
" ◼ Phase 1: dashboard polish",
&sep,
"❯",
&sep,
]);
assert_eq!(ClaudeSummary.live_preview(&prose), None);
}
#[test]
fn claude_approval_requires_the_dialog_shape() {
let dialog = rs(&[
" Do you want to create word.txt?",
" ❯ 1. Yes",
" 2. Yes, allow all edits during this session (shift+tab)",
" 3. No",
"",
" Esc to cancel · Tab to amend",
]);
assert_eq!(
ClaudeSummary.live_preview(&dialog),
Some(("awaiting approval".to_string(), "claude:approval-menu"))
);
let lone = rs(&[" ❯ 1. Yes", "", " Esc to cancel"]);
assert_eq!(ClaudeSummary.live_preview(&lone), None);
}
#[test]
fn claude_label_reads_the_welcome_box() {
let boxed = rs(&[
"╭─── Claude Code v2.1.215 ────────────╮",
"│ Fable 5 with high effort · Claude Max · │ notes │",
"╰──────────────────────────────────────╯",
]);
assert_eq!(
ClaudeSummary.model_label(&boxed),
Some("Fable 5 (high)".to_string())
);
assert_eq!(ClaudeSummary.model_label(&rs(&["no box here"])), None);
}
#[test]
fn codex_working_normalization() {
let tail = [
"",
"› Write tests for @filename",
"",
" gpt-5.6-sol high · 0 in · 0 out",
];
let full = "• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close";
for (row, want) in [
(full, "Working · 1 background terminal running"),
("• Working (2s • esc to interrupt)", "Working"),
("• Working (7s • esc to…", "Working"),
(
"• Working (7s • esc to interrupt) · 1 background termi…",
"Working · 1 background termi…",
),
] {
let mut rows = vec![row];
rows.extend(tail);
assert_eq!(
CodexSummary.live_preview(&rs(&rows)),
Some((want.to_string(), "codex:working")),
"{row:?}"
);
}
assert_eq!(
CodexSummary.model_label(&rs(&tail[1..])),
Some("gpt-5.6-sol high".to_string())
);
}
#[test]
fn codex_ran_stops_at_foreign_rows() {
let transient = rs(&[
"• Ran sleep 5 && echo ok",
" └ ok",
"",
"› ",
"",
" gpt-5.6-sol high · 1 in · 2 out",
]);
assert_eq!(
CodexSummary.live_preview(&transient),
Some(("Ran sleep 5 && echo ok".to_string(), "codex:ran"))
);
let sep = "─".repeat(120);
let behind_reply = rs(&[
"• Ran sleep 5 && echo ok",
" └ ok",
"",
&sep,
"",
"• ok",
"",
"› ",
"",
" gpt-5.6-sol high · 1 in · 2 out",
]);
assert_eq!(CodexSummary.live_preview(&behind_reply), None);
}
#[test]
fn codex_hint_row_layout_anchors_without_a_token_bar() {
let hinted = rs(&[
"• Running cargo test --test daemon_env",
"",
"",
"• Working (10m 26s • esc to interrupt)",
"",
"›",
"",
" tab to queue message",
]);
assert_eq!(
CodexSummary.live_preview(&hinted),
Some(("Working".to_string(), "codex:working"))
);
assert_eq!(CodexSummary.model_label(&hinted), None);
}
#[test]
fn codex_approval_modal_synthesizes_on_any_selection() {
let on_first = rs(&[
" Would you like to run the following command?",
"",
" $ cargo test --test daemon_env",
"",
"› 1. Yes, proceed (y)",
" 2. Yes, and don't ask again for commands that start with `cargo test` (p)",
" 3. No, and tell Codex what to do differently (esc)",
"",
" Press enter to confirm or esc to cancel",
]);
assert_eq!(
CodexSummary.live_preview(&on_first),
Some(("awaiting approval".to_string(), "codex:approval-menu"))
);
let on_second = rs(&[
" 1. Yes, proceed (y)",
"› 2. Yes, and don't ask again (p)",
" 3. No (esc)",
"",
" Press enter to confirm or esc to cancel",
]);
assert_eq!(
CodexSummary.live_preview(&on_second),
Some(("awaiting approval".to_string(), "codex:approval-menu"))
);
}
#[test]
fn codex_quoted_menu_with_a_live_composer_is_not_a_modal() {
let quoted = rs(&[
"• I found these options in the doc:",
"",
"› 1. Yes, proceed (y)",
" 2. No, cancel (esc)",
"",
"›",
"",
" gpt-5.6-sol high · 0 in · 0 out",
]);
assert_eq!(CodexSummary.live_preview("ed), None);
}
#[test]
fn codex_requires_the_composer_pin() {
let exited = rs(&[
"• Ran sleep 5 && echo ok",
"",
"Token usage: total=14,353 input=14,063",
"To continue this session, run codex resume 0199-fake",
]);
assert_eq!(CodexSummary.live_preview(&exited), None);
assert_eq!(CodexSummary.model_label(&exited), None);
}
#[test]
fn grok_status_shapes() {
let boxed = [
" ╭──────────────────────╮",
" │ ❯ │",
" ╰── Grok 4.5 (xhigh) · always-approve ─╯",
];
let probe = |status: &str| {
let mut rows = vec![status, ""];
rows.extend(boxed);
GrokSummary.live_preview(&rs(&rows))
};
assert_eq!(
probe(" ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]"),
Some(("Sleep 5 seconds then echo ok…".to_string(), "grok:spinner"))
);
assert_eq!(
probe(" ⠋ Thinking… 0.2s"),
Some(("Thinking…".to_string(), "grok:spinner"))
);
assert_eq!(
probe(" Worked for 8.7s"),
Some(("Worked for 8.7s".to_string(), "grok:worked"))
);
assert_eq!(
probe(" Worked for 1m 24s"),
Some(("Worked for 1m 24s".to_string(), "grok:worked"))
);
assert_eq!(probe(" Worked for a while"), None);
assert_eq!(
probe(" Coming from Codex? Resume your session from 7m ago using ctrl+u"),
None
);
let mut rows = vec![" ⠋ Thinking… 0.2s", ""];
rows.extend(boxed);
assert_eq!(
GrokSummary.model_label(&rs(&rows)),
Some("Grok 4.5 (xhigh)".to_string())
);
let plain = rs(&[" ⠋ Thinking… 0.2s", "", "╭────╮", "│ ❯ │", "╰────╯"]);
assert_eq!(GrokSummary.model_label(&plain), None);
}
#[test]
fn corpus_positive_states_anchor_exactly() {
struct Case(
&'static str,
&'static [u8],
&'static dyn SummaryAdapter,
&'static str,
&'static str,
);
let cases = [
Case(
"preview_claude_working",
include_bytes!("../../tests/corpus/preview_claude_working.bin"),
&ClaudeSummary,
"Fable 5 (high) · Concocting…",
"claude:spinner",
),
Case(
"preview_claude_working_tool",
include_bytes!("../../tests/corpus/preview_claude_working_tool.bin"),
&ClaudeSummary,
"Fable 5 (high) · Hashing…",
"claude:spinner",
),
Case(
"preview_claude_action",
include_bytes!("../../tests/corpus/preview_claude_action.bin"),
&ClaudeSummary,
"Fable 5 (high) · Running 1 shell command…",
"claude:action-row",
),
Case(
"preview_claude_approval",
include_bytes!("../../tests/corpus/preview_claude_approval.bin"),
&ClaudeSummary,
"Fable 5 (high) · awaiting approval",
"claude:approval-menu",
),
Case(
"preview_claude_tasklist",
include_bytes!("../../tests/corpus/preview_claude_tasklist.bin"),
&ClaudeSummary,
"Running phase 1 (dashboard UI)…",
"claude:spinner",
),
Case(
"preview_codex_working",
include_bytes!("../../tests/corpus/preview_codex_working.bin"),
&CodexSummary,
"gpt-5.6-sol high · Working · 1 background terminal running",
"codex:working",
),
Case(
"preview_codex_ran",
include_bytes!("../../tests/corpus/preview_codex_ran.bin"),
&CodexSummary,
"gpt-5.6-sol high · Ran sleep 5 && echo ok",
"codex:ran",
),
Case(
"preview_claude_waiting",
include_bytes!("../../tests/corpus/preview_claude_waiting.bin"),
&ClaudeSummary,
"Waiting for 1 background agent to finish",
"claude:waiting-agents",
),
Case(
"preview_codex_hint_row",
include_bytes!("../../tests/corpus/preview_codex_hint_row.bin"),
&CodexSummary,
"Working",
"codex:working",
),
Case(
"preview_codex_approval",
include_bytes!("../../tests/corpus/preview_codex_approval.bin"),
&CodexSummary,
"awaiting approval",
"codex:approval-menu",
),
Case(
"preview_grok_working",
include_bytes!("../../tests/corpus/preview_grok_working.bin"),
&GrokSummary,
"Grok 4.5 (xhigh) · Sleep 5 seconds then echo ok…",
"grok:spinner",
),
Case(
"preview_grok_worked",
include_bytes!("../../tests/corpus/preview_grok_worked.bin"),
&GrokSummary,
"Grok 4.5 (xhigh) · Worked for 8.7s",
"grok:worked",
),
];
for Case(name, bytes, adapter, text, rule) in cases {
let p = resolve_corpus(bytes, adapter, 40, 120);
assert_eq!(parts(&p), anchor(text, rule), "{name}");
}
}
#[test]
fn corpus_idle_states_fall_through() {
let cases: [(&str, &[u8], &dyn SummaryAdapter); 4] = [
(
"preview_claude_idle",
include_bytes!("../../tests/corpus/preview_claude_idle.bin"),
&ClaudeSummary,
),
(
"preview_claude_done",
include_bytes!("../../tests/corpus/preview_claude_done.bin"),
&ClaudeSummary,
),
(
"preview_grok_idle",
include_bytes!("../../tests/corpus/preview_grok_idle.bin"),
&GrokSummary,
),
(
"preview_grok_splash",
include_bytes!("../../tests/corpus/preview_grok_splash.bin"),
&GrokSummary,
),
];
for (name, bytes, adapter) in cases {
let p = resolve_corpus(bytes, adapter, 40, 120);
assert_eq!(
parts(&p),
(MARKER.to_string(), PreviewSource::Marker, None),
"{name}"
);
}
}
#[test]
fn corpus_body_shaped_text_never_extracts() {
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_claude_body_menu.bin"),
&ClaudeSummary,
40,
120,
);
assert_eq!(
parts(&p),
anchor("Fable 5 (high) · Hashing…", "claude:spinner")
);
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_claude_body_menu_idle.bin"),
&ClaudeSummary,
40,
120,
);
assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None));
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_claude_body_above_tasklist.bin"),
&ClaudeSummary,
40,
120,
);
assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None));
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_codex_scrollback.bin"),
&CodexSummary,
40,
120,
);
assert_eq!(
parts(&p),
(
"gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out".to_string(),
PreviewSource::Floor,
None
)
);
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_codex_body_menu.bin"),
&CodexSummary,
40,
120,
);
assert_eq!(
parts(&p),
(
"gpt-5.6-sol high · 0 in · 0 out".to_string(),
PreviewSource::Floor,
None
)
);
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_codex_working_over_ran.bin"),
&CodexSummary,
40,
120,
);
assert_eq!(
parts(&p),
anchor("gpt-5.6-sol high · Working", "codex:working")
);
}
#[test]
fn corpus_truncated_rows_still_anchor() {
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_trunc_claude.bin"),
&ClaudeSummary,
40,
80,
);
assert_eq!(parts(&p), anchor("Hashing…", "claude:spinner"));
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_trunc_codex.bin"),
&CodexSummary,
40,
80,
);
assert_eq!(
parts(&p),
anchor(
"gpt-5.6-sol high · Working · 1 background terminal running",
"codex:working"
)
);
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_trunc_grok.bin"),
&GrokSummary,
40,
80,
);
assert_eq!(
parts(&p),
anchor(
"Grok 4.5 (xhigh) · Sleep 5 seconds then echo…",
"grok:spinner"
)
);
}
#[test]
fn corpus_wrapped_ellipsis_falls_through() {
let p = resolve_corpus(
include_bytes!("../../tests/corpus/preview_wrap_grok.bin"),
&GrokSummary,
40,
30,
);
assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None));
}
#[test]
fn corpus_non_agent_tuis_keep_their_tiers() {
for (name, bytes) in [
(
"vim_session",
&include_bytes!("../../tests/corpus/vim_session.bin")[..],
),
(
"less_altscreen",
&include_bytes!("../../tests/corpus/less_altscreen.bin")[..],
),
] {
let cut = bytes
.windows(8)
.rposition(|w| w == b"\x1b[?1049l")
.expect("fixture exits the alt screen");
let mut emu = Emulator::new(40, 120, 2000);
emu.process(&bytes[..cut]);
assert!(emu.alternate_screen(), "{name}: alt screen active at cut");
let mut st = PreviewState::new();
let with = st
.resolve(Instant::now(), false, &emu, Some(&ClaudeSummary))
.clone();
let mut st = PreviewState::new();
let without = st.resolve(Instant::now(), false, &emu, None).clone();
assert_eq!(with, without, "{name}: the adapter must change nothing");
assert_eq!(with.source, PreviewSource::Marker, "{name}");
}
}
}