use std::collections::HashSet;
use anyhow::Result;
use tracing::info;
use super::OpenCodeState;
use crate::backend::SessionBackend;
use super::transcript::read_transcript_tail;
use super::types::{
MIN_DISAMBIGUATION_SCORE, MIN_SCORE_LEAD_RATIO, OpenCodeTranscriptSource, SCREEN_TAIL_CHARS,
TRANSCRIPT_TAIL_CHARS,
};
pub(crate) async fn disambiguate_by_screen(
_state: &OpenCodeState,
backend: &dyn SessionBackend,
candidates: &[OpenCodeTranscriptSource],
) -> Result<Option<OpenCodeTranscriptSource>> {
if candidates.len() < 2 {
return Ok(candidates.first().cloned());
}
let screen = backend.capture_viewport().await.unwrap_or_default();
let screen_tail = normalize_for_scoring(&screen, SCREEN_TAIL_CHARS);
if screen_tail.is_empty() {
return Ok(None);
}
let mut scored: Vec<(f64, &OpenCodeTranscriptSource)> = Vec::with_capacity(candidates.len());
for candidate in candidates {
let transcript_text = read_transcript_tail(candidate).unwrap_or_default();
let transcript_tail = normalize_for_scoring(&transcript_text, TRANSCRIPT_TAIL_CHARS);
let score = score_texts(&screen_tail, &transcript_tail);
scored.push((score, candidate));
}
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let &(top_score, top_source) = &scored[0];
let score_too_low = top_score < MIN_DISAMBIGUATION_SCORE;
let score_gap_too_small = scored.len() > 1 && {
let second_score = scored[1].0;
second_score > 0.0 && top_score / second_score < MIN_SCORE_LEAD_RATIO
};
if score_too_low || score_gap_too_small {
info!(
adapter = "opencode",
candidate_count = candidates.len(),
"screen disambiguation rejected: top_score={:.3}<{:.3} or scores too close",
top_score,
MIN_DISAMBIGUATION_SCORE
);
return Ok(None);
}
info!(
adapter = "opencode",
candidate_count = candidates.len(),
transcript_session = %top_source.session_id,
"screen disambiguation selected (score={:.3}, lead={:.2}x)",
top_score,
top_score / scored.get(1).map(|s| s.0).unwrap_or(1.0)
);
Ok(Some(top_source.clone()))
}
pub(crate) fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\x1b' {
out.push(ch);
continue;
}
match chars.peek() {
Some('[') => {
chars.next(); while let Some(&c) = chars.peek() {
if c.is_ascii_alphanumeric() || c == ';' {
chars.next();
if c.is_ascii_alphabetic() {
break;
}
} else {
break;
}
}
}
Some(']') => {
chars.next(); while let Some(&c) = chars.peek() {
chars.next();
if c == '\x07' || (c == '\x1b' && chars.peek() == Some(&'\\')) {
if c == '\x1b' {
chars.next(); }
break;
}
}
}
_ => {
}
}
}
out
}
pub(crate) fn normalize_for_scoring(text: &str, tail_chars: usize) -> String {
let plain = strip_ansi(text);
let collapsed: String = plain.split_whitespace().collect::<Vec<_>>().join(" ");
let char_count = collapsed.chars().count();
if char_count <= tail_chars {
return collapsed;
}
let skip = char_count - tail_chars;
let tail: String = collapsed.chars().skip(skip).collect();
if let Some(space_pos) = tail.find(' ') {
tail[space_pos + 1..].to_string()
} else {
tail
}
}
fn score_texts(screen: &str, transcript: &str) -> f64 {
if screen.is_empty() || transcript.is_empty() {
return 0.0;
}
let sw: Vec<&str> = screen.split_whitespace().collect();
let tw: Vec<&str> = transcript.split_whitespace().collect();
if sw.is_empty() || tw.is_empty() {
return 0.0;
}
let screen_set: HashSet<&str> = sw.iter().copied().collect();
let transcript_set: HashSet<&str> = tw.iter().copied().collect();
let inter = screen_set.intersection(&transcript_set).count() as f64;
let union = screen_set.union(&transcript_set).count() as f64;
let jaccard = inter / union.max(1.0);
let screen_bigrams: HashSet<(&str, &str)> = sw.windows(2).map(|w| (w[0], w[1])).collect();
let transcript_bigrams: HashSet<(&str, &str)> = tw.windows(2).map(|w| (w[0], w[1])).collect();
let bg_inter = screen_bigrams.intersection(&transcript_bigrams).count() as f64;
let bg_union = screen_bigrams.union(&transcript_bigrams).count() as f64;
let bigram = bg_inter / bg_union.max(1.0);
(jaccard + bigram) / 2.0
}