Skip to main content

missiond_core/pty/
extractor.rs

1//! Incremental Extractor - Frame-by-frame diff tracking for terminal buffer
2//!
3//! Extracts stable text operations from terminal screen changes,
4//! filtering out transient UI elements like spinners and status bars.
5
6use alacritty_terminal::grid::Dimensions;
7use alacritty_terminal::term::Term;
8use once_cell::sync::Lazy;
9use regex::Regex;
10
11// ========== Patterns ==========
12
13/// Spinner-only line (e.g., "· · ·")
14static SPINNER_ONLY_PATTERN: Lazy<Regex> =
15    Lazy::new(|| Regex::new(r"^[·✻✽✶✳✢⠐⠂⠈⠁⠉⠃⠋⠓⠒⠖⠦⠤]+$").unwrap());
16
17/// Separator line (e.g., "────")
18static SEPARATOR_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[─━═]+$").unwrap());
19
20/// Status bar pattern
21static STATUSBAR_PATTERN: Lazy<Regex> =
22    Lazy::new(|| Regex::new(r"(?i)esc to interrupt").unwrap());
23
24/// Prompt-only line (e.g., "> " or "❯ ")
25static PROMPT_ONLY_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[❯>]\s*$").unwrap());
26
27// ========== Types ==========
28
29/// Data for a single terminal line
30#[derive(Debug, Clone)]
31pub struct LineData {
32    pub text: String,
33    pub is_wrapped: bool,
34}
35
36/// Snapshot of the terminal screen
37#[derive(Debug, Clone)]
38pub struct ScreenSnapshot {
39    pub start_y: usize,
40    pub end_y: usize,
41    pub lines: Vec<LineData>,
42    pub cursor_x: usize,
43    pub cursor_y: usize,
44    pub base_y: usize,
45    pub timestamp: i64,
46}
47
48/// A stable text operation extracted from frame diff
49#[derive(Debug, Clone)]
50pub enum StableTextOp {
51    /// New complete line
52    Line {
53        y: usize,
54        text: String,
55        is_wrapped: bool,
56    },
57    /// Line content replaced (e.g., spinner -> actual text)
58    Replace {
59        y: usize,
60        text: String,
61        is_wrapped: bool,
62    },
63    /// Text appended to existing line (streaming)
64    Append { y: usize, text: String },
65}
66
67impl StableTextOp {
68    pub fn y(&self) -> usize {
69        match self {
70            StableTextOp::Line { y, .. } => *y,
71            StableTextOp::Replace { y, .. } => *y,
72            StableTextOp::Append { y, .. } => *y,
73        }
74    }
75
76    pub fn text(&self) -> &str {
77        match self {
78            StableTextOp::Line { text, .. } => text,
79            StableTextOp::Replace { text, .. } => text,
80            StableTextOp::Append { text, .. } => text,
81        }
82    }
83
84    pub fn kind(&self) -> &'static str {
85        match self {
86            StableTextOp::Line { .. } => "line",
87            StableTextOp::Replace { .. } => "replace",
88            StableTextOp::Append { .. } => "append",
89        }
90    }
91
92    pub fn is_wrapped(&self) -> bool {
93        match self {
94            StableTextOp::Line { is_wrapped, .. } => *is_wrapped,
95            StableTextOp::Replace { is_wrapped, .. } => *is_wrapped,
96            StableTextOp::Append { .. } => false,
97        }
98    }
99}
100
101/// Added line info
102#[derive(Debug, Clone)]
103pub struct AddedLine {
104    pub y: usize,
105    pub text: String,
106    pub is_wrapped: bool,
107}
108
109/// Modified line info
110#[derive(Debug, Clone)]
111pub struct ModifiedLine {
112    pub y: usize,
113    pub old_text: String,
114    pub new_text: String,
115    pub is_wrapped: bool,
116}
117
118/// Frame delta containing all changes since last extraction
119#[derive(Debug, Clone)]
120pub struct FrameDelta {
121    pub timestamp: i64,
122    pub added_lines: Vec<AddedLine>,
123    pub modified_lines: Vec<ModifiedLine>,
124    pub scrolled_lines: i32,
125    pub stable_ops: Vec<StableTextOp>,
126    pub cursor_position: (usize, usize),
127    pub window: (usize, usize),
128}
129
130// ========== Extractor ==========
131
132/// Incremental extractor for terminal text
133///
134/// Tracks frame-by-frame changes in the terminal buffer and extracts
135/// stable text operations, filtering out transient UI elements.
136pub struct IncrementalExtractor {
137    last_snapshot: Option<ScreenSnapshot>,
138    window_lines: usize,
139}
140
141impl IncrementalExtractor {
142    /// Create a new extractor
143    ///
144    /// # Arguments
145    /// * `rows` - Terminal rows (used to calculate default window size)
146    /// * `window_lines` - Optional custom window size (default: rows * 20, min 800)
147    pub fn new(rows: usize, window_lines: Option<usize>) -> Self {
148        let default_window = std::cmp::max(rows * 20, 800);
149        Self {
150            last_snapshot: None,
151            window_lines: window_lines.unwrap_or(default_window),
152        }
153    }
154
155    /// Extract frame delta since last call
156    ///
157    /// # Type Parameters
158    /// * `T` - Term event listener type (from alacritty_terminal)
159    pub fn extract<T>(&mut self, term: &Term<T>) -> FrameDelta {
160        let current = self.capture_screen(term);
161        let delta = self.compute_delta(self.last_snapshot.as_ref(), &current);
162        self.last_snapshot = Some(current);
163        delta
164    }
165
166    /// Reset state (call when session restarts)
167    pub fn reset(&mut self) {
168        self.last_snapshot = None;
169    }
170
171    /// Get current screen without updating state
172    pub fn peek<T>(&self, term: &Term<T>) -> ScreenSnapshot {
173        self.capture_screen(term)
174    }
175
176    /// Helper to get a line from snapshot by absolute Y
177    fn get_snapshot_line(snap: &ScreenSnapshot, abs_y: usize) -> Option<&LineData> {
178        if abs_y < snap.start_y || abs_y >= snap.end_y {
179            return None;
180        }
181        snap.lines.get(abs_y - snap.start_y)
182    }
183
184    /// Capture current screen state
185    fn capture_screen<T>(&self, term: &Term<T>) -> ScreenSnapshot {
186        let grid = term.grid();
187        let mut lines = Vec::new();
188
189        let total_lines = grid.total_lines();
190        let display_offset = grid.display_offset();
191        let rows = grid.screen_lines();
192
193        // Calculate the visible region (accounting for scrollback)
194        let base_y = if total_lines > rows {
195            total_lines - rows - display_offset
196        } else {
197            0
198        };
199
200        let end_y = base_y + rows;
201        let start_y = if end_y > self.window_lines {
202            end_y - self.window_lines
203        } else {
204            0
205        };
206
207        // Capture lines in the window
208        for y in start_y..end_y {
209            let line_idx = alacritty_terminal::index::Line(y as i32);
210            if y < total_lines {
211                let row = &grid[line_idx];
212                let text: String = row.into_iter().map(|cell| cell.c).collect();
213                let text = text.trim_end().to_string();
214
215                // Check if line is wrapped (continues from previous line)
216                // In alacritty_terminal, wrapped lines have their first cell marked
217                let is_wrapped = if row.len() > 0 {
218                    row[alacritty_terminal::index::Column(0)]
219                        .flags
220                        .contains(alacritty_terminal::term::cell::Flags::WRAPLINE)
221                } else {
222                    false
223                };
224
225                lines.push(LineData { text, is_wrapped });
226            } else {
227                lines.push(LineData {
228                    text: String::new(),
229                    is_wrapped: false,
230                });
231            }
232        }
233
234        let cursor = &term.grid().cursor;
235
236        ScreenSnapshot {
237            start_y,
238            end_y,
239            lines,
240            cursor_x: cursor.point.column.0,
241            cursor_y: cursor.point.line.0 as usize,
242            base_y,
243            timestamp: chrono::Utc::now().timestamp_millis(),
244        }
245    }
246
247    /// Compute delta between two snapshots
248    fn compute_delta(&self, prev: Option<&ScreenSnapshot>, curr: &ScreenSnapshot) -> FrameDelta {
249        let mut added_lines = Vec::new();
250        let mut modified_lines = Vec::new();
251        let scrolled_lines: i32;
252
253        match prev {
254            None => {
255                // First capture: all non-empty lines are new
256                scrolled_lines = 0;
257                for (local_y, line) in curr.lines.iter().enumerate() {
258                    let y = curr.start_y + local_y;
259                    if !line.text.trim().is_empty() {
260                        added_lines.push(AddedLine {
261                            y,
262                            text: line.text.clone(),
263                            is_wrapped: line.is_wrapped,
264                        });
265                    }
266                }
267            }
268            Some(prev) => {
269                // Calculate scroll amount
270                scrolled_lines = curr.base_y as i32 - prev.base_y as i32;
271
272                // Compare lines
273                let start_y = std::cmp::min(prev.start_y, curr.start_y);
274                let end_y = std::cmp::max(prev.end_y, curr.end_y);
275
276                for y in start_y..end_y {
277                    let prev_line = Self::get_snapshot_line(prev, y);
278                    let curr_line = Self::get_snapshot_line(curr, y);
279                    let prev_text = prev_line.map(|l| l.text.as_str()).unwrap_or("");
280                    let curr_text = curr_line.map(|l| l.text.as_str()).unwrap_or("");
281                    let curr_wrapped = curr_line.map(|l| l.is_wrapped).unwrap_or(false);
282
283                    if prev_line.is_none() && curr_line.is_some() {
284                        // New line entered the window
285                        // Ignore top-expansion of the window to avoid replaying history
286                        if y < prev.start_y {
287                            continue;
288                        }
289                        if !curr_text.trim().is_empty() {
290                            added_lines.push(AddedLine {
291                                y,
292                                text: curr_text.to_string(),
293                                is_wrapped: curr_wrapped,
294                            });
295                        }
296                        continue;
297                    }
298
299                    if prev_line.is_some()
300                        && curr_line.is_some()
301                        && prev_text != curr_text
302                        && !curr_text.trim().is_empty()
303                    {
304                        modified_lines.push(ModifiedLine {
305                            y,
306                            old_text: prev_text.to_string(),
307                            new_text: curr_text.to_string(),
308                            is_wrapped: curr_wrapped,
309                        });
310                    }
311                }
312            }
313        }
314
315        // Extract stable operations
316        let stable_ops = self.extract_stable_ops(&added_lines, &modified_lines);
317
318        FrameDelta {
319            timestamp: curr.timestamp,
320            added_lines,
321            modified_lines,
322            scrolled_lines,
323            stable_ops,
324            cursor_position: (curr.cursor_x, curr.cursor_y),
325            window: (curr.start_y, curr.end_y),
326        }
327    }
328
329    /// Extract stable operations from added and modified lines
330    fn extract_stable_ops(
331        &self,
332        added: &[AddedLine],
333        modified: &[ModifiedLine],
334    ) -> Vec<StableTextOp> {
335        let mut ops = Vec::new();
336
337        // Process added lines - these are complete new lines
338        for line in added {
339            let text = line.text.trim_end();
340            if self.is_stable_line(text) {
341                ops.push(StableTextOp::Line {
342                    y: line.y,
343                    text: text.to_string(),
344                    is_wrapped: line.is_wrapped,
345                });
346            }
347        }
348
349        // Process modified lines - extract appended content
350        for line in modified {
351            let new_text = line.new_text.trim_end();
352            let old_text = line.old_text.trim_end();
353
354            // Case 1: old was unstable (spinner/status), new is stable -> full line
355            if self.is_stable_line(new_text) && !self.is_stable_line(old_text) {
356                ops.push(StableTextOp::Replace {
357                    y: line.y,
358                    text: new_text.to_string(),
359                    is_wrapped: line.is_wrapped,
360                });
361                continue;
362            }
363
364            // Case 2: Both stable, extract appended portion (streaming text)
365            if self.is_stable_line(new_text) && self.is_stable_line(old_text) {
366                if new_text.starts_with(old_text) && new_text.len() > old_text.len() {
367                    let appended = &new_text[old_text.len()..];
368                    if !appended.is_empty() {
369                        ops.push(StableTextOp::Append {
370                            y: line.y,
371                            text: appended.to_string(),
372                        });
373                    }
374                }
375            }
376        }
377
378        // Sort by y position, then by kind (line/replace before append)
379        ops.sort_by(|a, b| {
380            if a.y() != b.y() {
381                return a.y().cmp(&b.y());
382            }
383            let order = |op: &StableTextOp| -> u8 {
384                match op {
385                    StableTextOp::Append { .. } => 2,
386                    _ => 1,
387                }
388            };
389            order(a).cmp(&order(b))
390        });
391
392        ops
393    }
394
395    /// Check if a line is stable (not transient UI)
396    fn is_stable_line(&self, text: &str) -> bool {
397        let trimmed = text.trim();
398        if trimmed.is_empty() {
399            return false;
400        }
401
402        // Filter out spinner-only lines
403        if SPINNER_ONLY_PATTERN.is_match(trimmed) {
404            return false;
405        }
406
407        // Filter out separator lines
408        if SEPARATOR_PATTERN.is_match(trimmed) {
409            return false;
410        }
411
412        // Filter out status bar
413        if STATUSBAR_PATTERN.is_match(trimmed) {
414            return false;
415        }
416
417        // Filter out prompt-only lines
418        if PROMPT_ONLY_PATTERN.is_match(trimmed) {
419            return false;
420        }
421
422        true
423    }
424}
425
426// ========== TextAssembler ==========
427
428/// Assembles streaming text from stable operations
429///
430/// Handles newlines and wrapped lines correctly to produce
431/// coherent text output.
432pub struct TextAssembler {
433    buffer: String,
434}
435
436impl Default for TextAssembler {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442impl TextAssembler {
443    /// Create a new text assembler
444    pub fn new() -> Self {
445        Self {
446            buffer: String::new(),
447        }
448    }
449
450    /// Reset the assembler
451    pub fn reset(&mut self) {
452        self.buffer.clear();
453    }
454
455    /// Apply a stable operation and return the chunk added
456    pub fn apply(&mut self, op: &StableTextOp) -> String {
457        if op.text().is_empty() {
458            return String::new();
459        }
460
461        match op {
462            StableTextOp::Append { text, .. } => {
463                self.buffer.push_str(text);
464                text.clone()
465            }
466            StableTextOp::Line { text, is_wrapped, .. }
467            | StableTextOp::Replace { text, is_wrapped, .. } => {
468                let needs_newline =
469                    !self.buffer.is_empty() && !self.buffer.ends_with('\n') && !is_wrapped;
470
471                let chunk = if needs_newline {
472                    format!("\n{}", text)
473                } else {
474                    text.clone()
475                };
476
477                self.buffer.push_str(&chunk);
478                chunk
479            }
480        }
481    }
482
483    /// Apply multiple operations and return total chunk added
484    pub fn apply_all(&mut self, ops: &[StableTextOp]) -> String {
485        let mut appended = String::new();
486        for op in ops {
487            appended.push_str(&self.apply(op));
488        }
489        appended
490    }
491
492    /// Get the final assembled text
493    pub fn finalize(&self) -> String {
494        self.buffer.clone()
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_text_assembler_basic() {
504        let mut assembler = TextAssembler::new();
505
506        // First line
507        let op1 = StableTextOp::Line {
508            y: 0,
509            text: "Hello".to_string(),
510            is_wrapped: false,
511        };
512        assert_eq!(assembler.apply(&op1), "Hello");
513
514        // Append to same line
515        let op2 = StableTextOp::Append {
516            y: 0,
517            text: " World".to_string(),
518        };
519        assert_eq!(assembler.apply(&op2), " World");
520
521        // New line
522        let op3 = StableTextOp::Line {
523            y: 1,
524            text: "Second line".to_string(),
525            is_wrapped: false,
526        };
527        assert_eq!(assembler.apply(&op3), "\nSecond line");
528
529        assert_eq!(assembler.finalize(), "Hello World\nSecond line");
530    }
531
532    #[test]
533    fn test_text_assembler_wrapped_line() {
534        let mut assembler = TextAssembler::new();
535
536        let op1 = StableTextOp::Line {
537            y: 0,
538            text: "This is a very long line that".to_string(),
539            is_wrapped: false,
540        };
541        assembler.apply(&op1);
542
543        // Wrapped continuation - should NOT add newline
544        let op2 = StableTextOp::Line {
545            y: 1,
546            text: " continues here".to_string(),
547            is_wrapped: true,
548        };
549        assert_eq!(assembler.apply(&op2), " continues here");
550
551        assert_eq!(
552            assembler.finalize(),
553            "This is a very long line that continues here"
554        );
555    }
556
557    #[test]
558    fn test_stable_line_detection() {
559        let extractor = IncrementalExtractor::new(30, None);
560
561        // Stable lines
562        assert!(extractor.is_stable_line("Hello world"));
563        assert!(extractor.is_stable_line("  Some code  "));
564        assert!(extractor.is_stable_line("> user input here"));
565
566        // Unstable lines
567        assert!(!extractor.is_stable_line("·····"));
568        assert!(!extractor.is_stable_line("────────"));
569        assert!(!extractor.is_stable_line("Press esc to interrupt"));
570        assert!(!extractor.is_stable_line("> "));
571        assert!(!extractor.is_stable_line("❯ "));
572        assert!(!extractor.is_stable_line(""));
573        assert!(!extractor.is_stable_line("   "));
574    }
575}