1use alacritty_terminal::grid::Dimensions;
7use alacritty_terminal::term::Term;
8use once_cell::sync::Lazy;
9use regex::Regex;
10
11static SPINNER_ONLY_PATTERN: Lazy<Regex> =
15 Lazy::new(|| Regex::new(r"^[·✻✽✶✳✢⠐⠂⠈⠁⠉⠃⠋⠓⠒⠖⠦⠤]+$").unwrap());
16
17static SEPARATOR_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[─━═]+$").unwrap());
19
20static STATUSBAR_PATTERN: Lazy<Regex> =
22 Lazy::new(|| Regex::new(r"(?i)esc to interrupt").unwrap());
23
24static PROMPT_ONLY_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[❯>]\s*$").unwrap());
26
27#[derive(Debug, Clone)]
31pub struct LineData {
32 pub text: String,
33 pub is_wrapped: bool,
34}
35
36#[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#[derive(Debug, Clone)]
50pub enum StableTextOp {
51 Line {
53 y: usize,
54 text: String,
55 is_wrapped: bool,
56 },
57 Replace {
59 y: usize,
60 text: String,
61 is_wrapped: bool,
62 },
63 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#[derive(Debug, Clone)]
103pub struct AddedLine {
104 pub y: usize,
105 pub text: String,
106 pub is_wrapped: bool,
107}
108
109#[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#[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
130pub struct IncrementalExtractor {
137 last_snapshot: Option<ScreenSnapshot>,
138 window_lines: usize,
139}
140
141impl IncrementalExtractor {
142 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 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(), ¤t);
162 self.last_snapshot = Some(current);
163 delta
164 }
165
166 pub fn reset(&mut self) {
168 self.last_snapshot = None;
169 }
170
171 pub fn peek<T>(&self, term: &Term<T>) -> ScreenSnapshot {
173 self.capture_screen(term)
174 }
175
176 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 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 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 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 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 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 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 scrolled_lines = curr.base_y as i32 - prev.base_y as i32;
271
272 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 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 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 fn extract_stable_ops(
331 &self,
332 added: &[AddedLine],
333 modified: &[ModifiedLine],
334 ) -> Vec<StableTextOp> {
335 let mut ops = Vec::new();
336
337 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 for line in modified {
351 let new_text = line.new_text.trim_end();
352 let old_text = line.old_text.trim_end();
353
354 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 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 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 fn is_stable_line(&self, text: &str) -> bool {
397 let trimmed = text.trim();
398 if trimmed.is_empty() {
399 return false;
400 }
401
402 if SPINNER_ONLY_PATTERN.is_match(trimmed) {
404 return false;
405 }
406
407 if SEPARATOR_PATTERN.is_match(trimmed) {
409 return false;
410 }
411
412 if STATUSBAR_PATTERN.is_match(trimmed) {
414 return false;
415 }
416
417 if PROMPT_ONLY_PATTERN.is_match(trimmed) {
419 return false;
420 }
421
422 true
423 }
424}
425
426pub struct TextAssembler {
433 buffer: String,
434}
435
436impl Default for TextAssembler {
437 fn default() -> Self {
438 Self::new()
439 }
440}
441
442impl TextAssembler {
443 pub fn new() -> Self {
445 Self {
446 buffer: String::new(),
447 }
448 }
449
450 pub fn reset(&mut self) {
452 self.buffer.clear();
453 }
454
455 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 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 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 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 let op2 = StableTextOp::Append {
516 y: 0,
517 text: " World".to_string(),
518 };
519 assert_eq!(assembler.apply(&op2), " World");
520
521 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 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 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 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}