rho-coding-agent 0.24.1

A lightweight agent harness inspired by Pi
Documentation
use std::time::{Duration, Instant};

const PASTE_BURST_GAP: Duration = Duration::from_millis(12);
const PASTE_ENTER_SUPPRESSION: Duration = Duration::from_millis(120);
const PASTE_BURST_MIN_CHARS: usize = 2;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PasteBurstEnter {
    Buffered,
    InsertNewline,
    NotPaste,
}

#[derive(Default)]
pub(super) struct PasteBurst {
    pending_text: String,
    last_event_at: Option<Instant>,
    plain_char_count: usize,
    suppress_enter_until: Option<Instant>,
}

impl PasteBurst {
    pub(super) fn has_pending(&self) -> bool {
        !self.pending_text.is_empty()
    }

    pub(super) fn can_continue(&self, now: Instant) -> bool {
        if !self.has_pending() {
            return true;
        }

        self.last_event_at
            .is_some_and(|last| now.saturating_duration_since(last) <= PASTE_BURST_GAP)
    }

    pub(super) fn push_plain_char(&mut self, ch: char, now: Instant) {
        if !self.has_pending() {
            self.plain_char_count = 0;
            self.suppress_enter_until = None;
        }

        self.pending_text.push(ch);
        self.last_event_at = Some(now);
        self.plain_char_count = self.plain_char_count.saturating_add(1);
        if self.plain_char_count >= PASTE_BURST_MIN_CHARS {
            self.suppress_enter_until = now.checked_add(PASTE_ENTER_SUPPRESSION);
        }
    }

    pub(super) fn push_enter_if_paste(&mut self, now: Instant) -> PasteBurstEnter {
        let follows_pending_burst = self
            .last_event_at
            .is_some_and(|last| now.saturating_duration_since(last) <= PASTE_BURST_GAP)
            && self.plain_char_count >= PASTE_BURST_MIN_CHARS;
        let follows_plain_text_burst = self.suppresses_enter_at(now);
        if !follows_pending_burst && !follows_plain_text_burst {
            return PasteBurstEnter::NotPaste;
        }

        self.suppress_enter_until = now.checked_add(PASTE_ENTER_SUPPRESSION);
        if self.has_pending() {
            self.pending_text.push('\n');
            self.last_event_at = Some(now);
            PasteBurstEnter::Buffered
        } else {
            PasteBurstEnter::InsertNewline
        }
    }

    pub(super) fn is_due(&self, now: Instant) -> bool {
        self.deadline().is_some_and(|deadline| now >= deadline)
    }

    pub(super) fn poll_timeout(&self, now: Instant, idle_timeout: Duration) -> Duration {
        let Some(deadline) = self.deadline() else {
            return idle_timeout;
        };

        deadline
            .checked_duration_since(now)
            .unwrap_or_default()
            .min(idle_timeout)
    }

    pub(super) fn deadline(&self) -> Option<Instant> {
        self.last_event_at
            .and_then(|last| last.checked_add(PASTE_BURST_GAP))
    }

    pub(super) fn take_pending(&mut self) -> Option<String> {
        if self.pending_text.is_empty() {
            self.clear_pending_text();
            return None;
        }

        let text = std::mem::take(&mut self.pending_text);
        self.clear_pending_text();
        Some(text)
    }

    pub(super) fn clear(&mut self) {
        self.pending_text.clear();
        self.last_event_at = None;
        self.plain_char_count = 0;
        self.suppress_enter_until = None;
    }

    fn clear_pending_text(&mut self) {
        self.pending_text.clear();
        self.last_event_at = None;
        self.plain_char_count = 0;
    }

    fn suppresses_enter_at(&self, now: Instant) -> bool {
        self.suppress_enter_until
            .is_some_and(|deadline| now <= deadline)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn single_char_enter_is_not_buffered_as_paste() {
        let start = Instant::now();
        let mut burst = PasteBurst::default();

        burst.push_plain_char('y', start);

        assert_eq!(
            burst.push_enter_if_paste(start + Duration::from_millis(1)),
            PasteBurstEnter::NotPaste
        );
        assert_eq!(burst.take_pending().as_deref(), Some("y"));
    }

    #[test]
    fn enter_after_idle_gap_is_not_part_of_paste() {
        let start = Instant::now();
        let mut burst = PasteBurst::default();

        burst.push_plain_char('a', start);

        assert_eq!(
            burst.push_enter_if_paste(start + PASTE_BURST_GAP + Duration::from_millis(1)),
            PasteBurstEnter::NotPaste
        );
        assert_eq!(burst.take_pending().as_deref(), Some("a"));
    }

    #[test]
    fn rapid_plain_text_burst_extends_enter_suppression() {
        let start = Instant::now();
        let mut burst = PasteBurst::default();

        burst.push_plain_char('a', start);
        burst.push_plain_char('b', start + Duration::from_millis(1));

        assert_eq!(
            burst.push_enter_if_paste(start + Duration::from_millis(50)),
            PasteBurstEnter::Buffered
        );
        assert_eq!(burst.take_pending().as_deref(), Some("ab\n"));
    }

    #[test]
    fn enter_suppression_survives_literal_flush() {
        let start = Instant::now();
        let mut burst = PasteBurst::default();

        burst.push_plain_char('a', start);
        burst.push_plain_char('b', start + Duration::from_millis(1));
        assert_eq!(burst.take_pending().as_deref(), Some("ab"));

        assert_eq!(
            burst.push_enter_if_paste(start + Duration::from_millis(50)),
            PasteBurstEnter::InsertNewline
        );
    }
}