Skip to main content

winx_code_agent/state/
line_ring.rs

1//! Bounded, line-oriented scrollback buffer fed raw PTY chunks.
2//!
3//! The PTY reader appends decoded chunks; this keeps the most recent
4//! `capacity` complete lines plus the in-flight (unterminated) partial line.
5//! Crucially it *remembers* what it had to throw away — older lines evicted
6//! once the ring filled, and bytes clipped off a pathologically long
7//! newline-less line — so [`LineRing::scrollback_notice`] can tell the caller
8//! their scrollback is incomplete instead of silently handing back a truncated
9//! view. Extracted from `PtyShell` so the ring logic is unit-testable without
10//! spawning a real shell.
11
12use std::collections::VecDeque;
13
14/// A ring of complete lines plus a pending partial line, with drop accounting.
15#[derive(Debug)]
16pub struct LineRing {
17    /// Fully-emitted lines, newest at the back. Capped at `capacity`.
18    lines: VecDeque<String>,
19    /// Carries the unterminated tail across chunks so a partial line isn't
20    /// double-counted when the rest of it arrives.
21    partial: String,
22    /// Max complete lines retained before the oldest is evicted.
23    capacity: usize,
24    /// Max bytes retained in `partial` before its head is clipped.
25    max_partial_bytes: usize,
26    /// How many complete lines have been evicted over this ring's lifetime.
27    dropped_lines: u64,
28    /// Whether the partial line ever had its head clipped (a long, unterminated
29    /// stream: a `\r`-only progress bar, a binary blob, `yes | tr -d '\n'`).
30    partial_clipped: bool,
31}
32
33impl LineRing {
34    pub fn new(capacity: usize, max_partial_bytes: usize) -> Self {
35        Self {
36            lines: VecDeque::with_capacity(capacity),
37            partial: String::new(),
38            capacity,
39            max_partial_bytes,
40            dropped_lines: 0,
41            partial_clipped: false,
42        }
43    }
44
45    /// Append a freshly-arrived chunk, splitting on newlines into complete
46    /// lines and retaining the unterminated tail as `partial`.
47    pub fn push_chunk(&mut self, chunk: &str) {
48        let combined = if self.partial.is_empty() {
49            chunk.to_string()
50        } else {
51            let mut s = std::mem::take(&mut self.partial);
52            s.push_str(chunk);
53            s
54        };
55
56        let mut last_nl_end: Option<usize> = None;
57        for (idx, ch) in combined.char_indices() {
58            if ch == '\n' {
59                let end = idx + ch.len_utf8();
60                let start = last_nl_end.unwrap_or(0);
61                // Keep the raw line (CR/cursor moves intact); the emulator
62                // replays them on collect. Only drop a trailing CR (CRLF).
63                let line = combined[start..idx].trim_end_matches('\r').to_string();
64                if self.lines.len() == self.capacity {
65                    self.lines.pop_front();
66                    self.dropped_lines += 1;
67                }
68                self.lines.push_back(line);
69                last_nl_end = Some(end);
70            }
71        }
72
73        if let Some(end) = last_nl_end {
74            self.partial = combined[end..].to_string();
75        } else {
76            self.partial = combined;
77        }
78
79        // A stream that never emits a newline would grow `partial` without
80        // bound. Keep only the tail; older bytes of an unterminated line aren't
81        // useful as scrollback. Snap to a char boundary so we never split a
82        // code point.
83        if self.partial.len() > self.max_partial_bytes {
84            let cut = self.partial.len() - self.max_partial_bytes;
85            let cut = crate::utils::floor_char_boundary(&self.partial, cut);
86            self.partial.drain(..cut);
87            self.partial_clipped = true;
88        }
89    }
90
91    /// The last `lines` complete lines plus the partial, oldest first, joined
92    /// with `\n`. Raw — no terminal rendering, no truncation notice.
93    pub fn raw(&self, lines: usize) -> String {
94        if lines == 0 {
95            return String::new();
96        }
97        let start = self.lines.len().saturating_sub(lines);
98        let mut out = String::new();
99        for line in self.lines.iter().skip(start) {
100            out.push_str(line);
101            out.push('\n');
102        }
103        if !self.partial.is_empty() {
104            out.push_str(&self.partial);
105        }
106        out
107    }
108
109    /// A one-line notice when the scrollback handed back for a request of
110    /// `lines` lines is incomplete — older lines were evicted and/or the
111    /// partial line was clipped. `None` when nothing relevant was lost.
112    pub fn scrollback_notice(&self, lines: usize) -> Option<String> {
113        let mut notes: Vec<String> = Vec::new();
114        // Only flag dropped lines if the request actually reaches the oldest
115        // line we still hold — a request for the last N (< ring size) loses
116        // nothing the caller asked for.
117        if self.dropped_lines > 0 && lines >= self.lines.len() {
118            notes.push(format!(
119                "{} earlier line(s) dropped — scrollback keeps only the most recent {}",
120                self.dropped_lines, self.capacity
121            ));
122        }
123        if self.partial_clipped && lines > 0 {
124            notes.push(format!(
125                "a long unterminated line was clipped to its last {} bytes",
126                self.max_partial_bytes
127            ));
128        }
129        if notes.is_empty() {
130            None
131        } else {
132            Some(format!("[winx: {}]", notes.join("; ")))
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn feed_lines(ring: &mut LineRing, n: usize) {
142        for i in 0..n {
143            ring.push_chunk(&format!("line {i}\n"));
144        }
145    }
146
147    #[test]
148    fn push_chunk_splits_lines_and_keeps_partial() {
149        let mut ring = LineRing::new(10, 1024);
150        ring.push_chunk("a\nb\nc"); // "c" is the unterminated partial
151        assert_eq!(ring.raw(10), "a\nb\nc");
152        ring.push_chunk("cc\n"); // partial completes -> "ccc"
153        assert_eq!(ring.raw(10), "a\nb\nccc\n");
154    }
155
156    #[test]
157    fn no_notice_when_within_capacity() {
158        let mut ring = LineRing::new(2000, 1024);
159        feed_lines(&mut ring, 50);
160        assert_eq!(ring.scrollback_notice(2000), None);
161    }
162
163    #[test]
164    fn notice_when_older_lines_were_dropped() {
165        // Ring holds 100; we feed 250 and ask for everything. 150 were evicted,
166        // so the caller must be told their scrollback is incomplete.
167        let mut ring = LineRing::new(100, 1024);
168        feed_lines(&mut ring, 250);
169        let notice = ring.scrollback_notice(100).unwrap_or_default();
170        assert!(notice.contains("150"), "notice should report 150 dropped lines: {notice}");
171        assert!(notice.to_lowercase().contains("scrollback"), "notice text: {notice}");
172    }
173
174    #[test]
175    fn notice_reports_clipped_partial_line() {
176        let mut ring = LineRing::new(100, 64);
177        // A newline-less stream larger than max_partial_bytes gets head-clipped.
178        ring.push_chunk(&"x".repeat(200));
179        let notice = ring.scrollback_notice(100).unwrap_or_default();
180        assert!(notice.to_lowercase().contains("clip"), "notice text: {notice}");
181    }
182}