codex-wrangler 1.3.0

Linux/X11/i3 tray switcher for live coding-agent TUI sessions
use std::{cmp::Ordering, collections::HashSet, ops::Range};

use regex::{Regex, RegexBuilder};

use crate::{
    contract::{HistoryColumn, SortDirection},
    history::Session,
    model::Card,
};

#[derive(Debug)]
pub struct Hit {
    card: usize,
    spans: Vec<Range<usize>>,
}

impl Hit {
    pub const fn card(&self) -> usize {
        self.card
    }

    pub fn spans(&self) -> &[Range<usize>] {
        &self.spans
    }
}

pub struct Scry {
    query: String,
    matcher: Option<Regex>,
    valid: bool,
    hits: Vec<Hit>,
    label: String,
}

impl Default for Scry {
    fn default() -> Self {
        Self {
            query: String::new(),
            matcher: None,
            valid: true,
            hits: Vec::new(),
            label: "0 MANUAL THREADS".to_owned(),
        }
    }
}

impl Scry {
    pub fn query(&self) -> &str {
        &self.query
    }

    pub const fn valid(&self) -> bool {
        self.valid
    }

    pub fn edit(&mut self) -> &mut String {
        &mut self.query
    }

    pub fn hits(&self) -> &[Hit] {
        &self.hits
    }

    pub fn label(&self) -> &str {
        &self.label
    }

    pub fn revise(&mut self, cards: &[Card]) {
        (self.matcher, self.valid) = compile(&self.query);
        self.reconcile(cards);
    }

    pub fn clear(&mut self, cards: &[Card]) {
        self.query.clear();
        self.matcher = None;
        self.valid = true;
        self.reconcile(cards);
    }

    pub fn reconcile(&mut self, cards: &[Card]) {
        self.hits.clear();
        let Some(matcher) = self.matcher.as_ref() else {
            self.hits.extend((0..cards.len()).map(|card| Hit {
                card,
                spans: Vec::new(),
            }));
            self.label = if self.valid {
                census_label(cards.len())
            } else {
                "INVALID REGEXP".to_owned()
            };
            return;
        };

        for (card, model) in cards.iter().enumerate() {
            let haystack = model
                .name
                .as_deref()
                .filter(|name| !name.is_empty())
                .unwrap_or(&model.cwd);
            let mut findings = matcher.find_iter(haystack).peekable();
            if findings.peek().is_none() {
                continue;
            }
            let spans = findings
                .filter(|found| found.start() != found.end())
                .map(|found| found.start()..found.end())
                .collect();
            self.hits.push(Hit { card, spans });
        }
        self.label = format!("{} OF {} MANUAL THREADS", self.hits.len(), cards.len());
    }
}

fn census_label(count: usize) -> String {
    let noun = if count == 1 { "THREAD" } else { "THREADS" };
    format!("{count} MANUAL {noun}")
}

#[derive(Debug)]
pub struct HistoryHit {
    session: usize,
    id_spans: Vec<Range<usize>>,
    name_spans: Vec<Range<usize>>,
}

impl HistoryHit {
    pub const fn session(&self) -> usize {
        self.session
    }

    pub fn id_spans(&self) -> &[Range<usize>] {
        &self.id_spans
    }

    pub fn name_spans(&self) -> &[Range<usize>] {
        &self.name_spans
    }
}

pub struct HistoryScry {
    query: String,
    matcher: Option<Regex>,
    valid: bool,
    hits: Vec<HistoryHit>,
    sorts: Vec<HistorySort>,
    label: String,
}

#[derive(Clone, Copy)]
struct HistorySort {
    column: HistoryColumn,
    direction: SortDirection,
}

impl Default for HistoryScry {
    fn default() -> Self {
        Self {
            query: String::new(),
            matcher: None,
            valid: true,
            hits: Vec::new(),
            sorts: Vec::new(),
            label: "0 HISTORICAL SESSIONS".to_owned(),
        }
    }
}

impl HistoryScry {
    pub fn query(&self) -> &str {
        &self.query
    }

    pub const fn valid(&self) -> bool {
        self.valid
    }

    pub fn edit(&mut self) -> &mut String {
        &mut self.query
    }

    pub fn hits(&self) -> &[HistoryHit] {
        &self.hits
    }

    pub fn label(&self) -> &str {
        &self.label
    }

    pub fn direction(&self, column: HistoryColumn) -> Option<SortDirection> {
        self.sorts
            .iter()
            .find(|sort| sort.column == column)
            .map(|sort| sort.direction)
    }

    #[cfg(feature = "egui-test")]
    pub fn sorts(&self) -> impl Iterator<Item = (HistoryColumn, SortDirection)> + '_ {
        self.sorts.iter().map(|sort| (sort.column, sort.direction))
    }

    pub fn cycle(&mut self, column: HistoryColumn, sessions: &[Session]) {
        let direction = self.direction(column);
        self.sorts.retain(|sort| sort.column != column);
        match direction {
            None => self.sorts.push(HistorySort {
                column,
                direction: SortDirection::Ascending,
            }),
            Some(SortDirection::Ascending) => self.sorts.push(HistorySort {
                column,
                direction: SortDirection::Descending,
            }),
            Some(SortDirection::Descending) => {}
        }
        self.order(sessions);
    }

    pub fn revise(&mut self, sessions: &[Session], live: &HashSet<String>) {
        (self.matcher, self.valid) = compile(&self.query);
        self.reconcile(sessions, live);
    }

    pub fn clear(&mut self, sessions: &[Session], live: &HashSet<String>) {
        self.query.clear();
        self.matcher = None;
        self.valid = true;
        self.reconcile(sessions, live);
    }

    pub fn reconcile(&mut self, sessions: &[Session], live: &HashSet<String>) {
        self.hits.clear();
        let total = sessions
            .iter()
            .filter(|session| !live.contains(session.thread.as_str()))
            .count();
        let Some(matcher) = self.matcher.as_ref() else {
            self.hits.extend(
                sessions
                    .iter()
                    .enumerate()
                    .filter(|(_, session)| !live.contains(session.thread.as_str()))
                    .map(|(session, _)| HistoryHit {
                        session,
                        id_spans: Vec::new(),
                        name_spans: Vec::new(),
                    }),
            );
            self.label = if self.valid {
                history_label(total)
            } else {
                "INVALID REGEXP".to_owned()
            };
            self.order(sessions);
            return;
        };

        for (index, session) in sessions.iter().enumerate() {
            if live.contains(session.thread.as_str()) {
                continue;
            }
            let id_match = matcher.is_match(&session.thread);
            let name_match = session
                .name
                .as_deref()
                .is_some_and(|name| matcher.is_match(name));
            if !id_match && !name_match {
                continue;
            }
            let id_spans = spans(matcher, &session.thread);
            let name_spans = session
                .name
                .as_deref()
                .map_or_else(Vec::new, |name| spans(matcher, name));
            self.hits.push(HistoryHit {
                session: index,
                id_spans,
                name_spans,
            });
        }
        self.label = format!("{} OF {} HISTORICAL SESSIONS", self.hits.len(), total);
        self.order(sessions);
    }

    fn order(&mut self, sessions: &[Session]) {
        self.hits.sort_by_key(HistoryHit::session);
        for sort in &self.sorts {
            self.hits.sort_by(|left, right| {
                compare(&sessions[left.session], &sessions[right.session], *sort)
            });
        }
    }
}

fn spans(matcher: &Regex, text: &str) -> Vec<Range<usize>> {
    matcher
        .find_iter(text)
        .filter(|found| found.start() != found.end())
        .map(|found| found.start()..found.end())
        .collect()
}

fn history_label(count: usize) -> String {
    let noun = if count == 1 { "SESSION" } else { "SESSIONS" };
    format!("{count} HISTORICAL {noun}")
}

fn compile(query: &str) -> (Option<Regex>, bool) {
    if query.is_empty() {
        return (None, true);
    }
    match RegexBuilder::new(query).case_insensitive(true).build() {
        Ok(matcher) => (Some(matcher), true),
        Err(_) => (None, false),
    }
}

fn compare(left: &Session, right: &Session, sort: HistorySort) -> Ordering {
    let order = match sort.column {
        HistoryColumn::SessionId => left.thread.cmp(&right.thread),
        HistoryColumn::Name => {
            return optional(
                left.name.as_deref(),
                right.name.as_deref(),
                sort.direction,
                folded,
            );
        }
        HistoryColumn::LastTurn => left.updated_at_ms.cmp(&right.updated_at_ms),
        HistoryColumn::Turns => {
            return optional(left.turns, right.turns, sort.direction, Ord::cmp);
        }
        HistoryColumn::Size => left.bytes.cmp(&right.bytes),
        HistoryColumn::State => left.archived.cmp(&right.archived),
    };
    directed(order, sort.direction)
}

fn optional<T>(
    left: Option<T>,
    right: Option<T>,
    direction: SortDirection,
    compare: impl FnOnce(&T, &T) -> Ordering,
) -> Ordering {
    match (left, right) {
        (Some(left), Some(right)) => directed(compare(&left, &right), direction),
        (Some(_), None) => Ordering::Less,
        (None, Some(_)) => Ordering::Greater,
        (None, None) => Ordering::Equal,
    }
}

fn directed(order: Ordering, direction: SortDirection) -> Ordering {
    match direction {
        SortDirection::Ascending => order,
        SortDirection::Descending => order.reverse(),
    }
}

fn folded(left: &&str, right: &&str) -> Ordering {
    left.bytes()
        .map(|byte| byte.to_ascii_lowercase())
        .cmp(right.bytes().map(|byte| byte.to_ascii_lowercase()))
}