Skip to main content

fresh/services/terminal/
term.rs

1//! Terminal state using alacritty_terminal for emulation
2//!
3//! This module wraps alacritty_terminal to provide:
4//! - VT100/ANSI escape sequence parsing
5//! - Terminal grid management
6//! - Cursor state tracking
7//! - Incremental scrollback streaming to backing file
8//!
9//! # Role in Incremental Streaming Architecture
10//!
11//! This module provides the core state management and streaming methods.
12//! See `super` module docs for the full architecture overview.
13//!
14//! ## Key Methods
15//!
16//! - `process_output`: Feed PTY bytes into the terminal emulator
17//! - `flush_new_scrollback`: Stream new scrollback lines to backing file
18//! - `append_visible_screen`: Append visible screen on mode exit
19//! - `backing_file_history_end`: Get truncation point for mode re-entry
20//!
21//! ## State Tracking
22//!
23//! `synced_history_lines` tracks how many scrollback lines have been written to the
24//! backing file. When `grid.history_size() > synced_history_lines`, new lines need
25//! to be flushed.
26//!
27//! `backing_file_history_end` tracks the byte offset where scrollback ends in the
28//! backing file, used for truncation when re-entering terminal mode.
29
30use alacritty_terminal::event::{Event, EventListener};
31use alacritty_terminal::grid::Scroll;
32use alacritty_terminal::index::{Column, Line};
33use alacritty_terminal::term::test::TermSize;
34use alacritty_terminal::term::{Config as TermConfig, Term, TermMode};
35use alacritty_terminal::vte::ansi::Processor;
36use std::io::{self, Write};
37use std::sync::{Arc, Mutex};
38
39// Keep a generous scrollback so sync-to-buffer can include deep history.
40const SCROLLBACK_LINES: usize = 200_000;
41
42/// Event listener that captures PtyWrite events for sending back to the PTY.
43///
44/// When the terminal emulator needs to respond to queries (like DSR cursor position
45/// requests `\x1b[6n`), it generates `Event::PtyWrite` events. These must be captured
46/// and sent back to the PTY for the shell to receive the response.
47#[derive(Clone)]
48struct PtyWriteListener {
49    /// Queue of data to write back to the PTY
50    write_queue: Arc<Mutex<Vec<String>>>,
51}
52
53impl PtyWriteListener {
54    fn new() -> Self {
55        Self {
56            write_queue: Arc::new(Mutex::new(Vec::new())),
57        }
58    }
59}
60
61impl EventListener for PtyWriteListener {
62    fn send_event(&self, event: Event) {
63        if let Event::PtyWrite(text) = event {
64            if let Ok(mut queue) = self.write_queue.lock() {
65                queue.push(text);
66            }
67        }
68        // Other events (Title, ClipboardStore, etc.) are ignored for now
69    }
70}
71
72/// Terminal state wrapping alacritty_terminal
73pub struct TerminalState {
74    /// The terminal emulator
75    term: Term<PtyWriteListener>,
76    /// ANSI parser
77    parser: Processor,
78    /// Current dimensions
79    cols: u16,
80    rows: u16,
81    /// Whether content has changed since last render
82    dirty: bool,
83    /// Terminal title (set via escape sequences)
84    terminal_title: String,
85    /// Number of scrollback lines already written to backing file
86    synced_history_lines: usize,
87    /// Byte offset in backing file where scrollback ends (for truncation)
88    backing_file_history_end: u64,
89    /// Queue of data to write back to the PTY (for DSR responses, etc.)
90    pty_write_queue: Arc<Mutex<Vec<String>>>,
91}
92
93impl TerminalState {
94    /// Create a new terminal state
95    pub fn new(cols: u16, rows: u16) -> Self {
96        let size = TermSize::new(cols as usize, rows as usize);
97        let config = TermConfig {
98            scrolling_history: SCROLLBACK_LINES,
99            ..Default::default()
100        };
101        let listener = PtyWriteListener::new();
102        let pty_write_queue = listener.write_queue.clone();
103        let term = Term::new(config, &size, listener);
104
105        Self {
106            term,
107            parser: Processor::new(),
108            cols,
109            rows,
110            dirty: true,
111            terminal_title: String::new(),
112            synced_history_lines: 0,
113            backing_file_history_end: 0,
114            pty_write_queue,
115        }
116    }
117
118    /// Drain any pending data that needs to be written back to the PTY.
119    ///
120    /// This is used for responses to terminal queries like DSR (cursor position report).
121    /// The caller should write this data to the PTY writer.
122    pub fn drain_pty_write_queue(&self) -> Vec<String> {
123        if let Ok(mut queue) = self.pty_write_queue.lock() {
124            std::mem::take(&mut *queue)
125        } else {
126            Vec::new()
127        }
128    }
129
130    /// Process output from the PTY
131    pub fn process_output(&mut self, data: &[u8]) {
132        self.parser.advance(&mut self.term, data);
133        self.dirty = true;
134    }
135
136    /// Resize the terminal
137    pub fn resize(&mut self, cols: u16, rows: u16) {
138        if cols != self.cols || rows != self.rows {
139            self.cols = cols;
140            self.rows = rows;
141            let size = TermSize::new(cols as usize, rows as usize);
142            self.term.resize(size);
143            self.dirty = true;
144        }
145    }
146
147    /// Get current dimensions
148    pub fn size(&self) -> (u16, u16) {
149        (self.cols, self.rows)
150    }
151
152    /// Check if content has changed
153    pub fn is_dirty(&self) -> bool {
154        self.dirty
155    }
156
157    /// Mark as clean after rendering
158    pub fn mark_clean(&mut self) {
159        self.dirty = false;
160    }
161
162    /// Get the cursor position (column, row)
163    pub fn cursor_position(&self) -> (u16, u16) {
164        let cursor = self.term.grid().cursor.point;
165        (cursor.column.0 as u16, cursor.line.0 as u16)
166    }
167
168    /// Check if cursor is visible
169    pub fn cursor_visible(&self) -> bool {
170        // alacritty_terminal doesn't expose cursor visibility directly
171        // We'll assume it's always visible for now
172        true
173    }
174
175    /// Get a line of content for rendering
176    ///
177    /// Returns cells as (char, foreground_color, background_color, flags) tuples.
178    /// Colors are ANSI color indices (0-255) or None for default.
179    /// Accounts for scroll offset (display_offset) when accessing lines.
180    pub fn get_line(&self, row: u16) -> Vec<TerminalCell> {
181        use alacritty_terminal::index::{Column, Line};
182        use alacritty_terminal::term::cell::Flags;
183
184        let grid = self.term.grid();
185        let display_offset = grid.display_offset();
186
187        // Adjust line index for scroll offset
188        // When scrolled up by N lines, row 0 should show content from N lines back in history
189        let line = Line(row as i32 - display_offset as i32);
190
191        // Check if line is in valid range (use rows as the limit)
192        if row >= self.rows {
193            return vec![TerminalCell::default(); self.cols as usize];
194        }
195
196        let row_data = &grid[line];
197        let mut cells = Vec::with_capacity(self.cols as usize);
198
199        for col in 0..self.cols as usize {
200            let cell = &row_data[Column(col)];
201            let c = cell.c;
202
203            // Convert colors
204            let fg = color_to_rgb(&cell.fg);
205            let bg = color_to_rgb(&cell.bg);
206
207            // Check flags
208            let flags = cell.flags;
209            let bold = flags.contains(Flags::BOLD);
210            let italic = flags.contains(Flags::ITALIC);
211            let underline = flags.contains(Flags::UNDERLINE);
212            let inverse = flags.contains(Flags::INVERSE);
213
214            cells.push(TerminalCell {
215                c,
216                fg,
217                bg,
218                bold,
219                italic,
220                underline,
221                inverse,
222            });
223        }
224
225        cells
226    }
227
228    /// Get all visible content as a string (for testing/debugging)
229    pub fn content_string(&self) -> String {
230        let mut result = String::new();
231        for row in 0..self.rows {
232            let line = self.get_line(row);
233            for cell in line {
234                result.push(cell.c);
235            }
236            result.push('\n');
237        }
238        result
239    }
240
241    /// Get all content including scrollback history as a string
242    /// Lines are in chronological order (oldest first)
243    ///
244    /// WARNING: This is O(total_history) and should NOT be used in hot paths.
245    /// For mode switching, use the incremental streaming architecture instead:
246    /// - `flush_new_scrollback()` during PTY reads
247    /// - `append_visible_screen()` on mode exit
248    #[allow(dead_code)]
249    pub fn full_content_string(&self) -> String {
250        use alacritty_terminal::grid::Dimensions;
251        use alacritty_terminal::index::{Column, Line};
252
253        let grid = self.term.grid();
254        let history_size = grid.history_size();
255        let mut result = String::new();
256
257        // First, add scrollback history (negative line indices)
258        // History lines go from -(history_size) to -1
259        for i in (1..=history_size).rev() {
260            let line = Line(-(i as i32));
261            let row_data = &grid[line];
262            let mut line_str = String::new();
263            for col in 0..self.cols as usize {
264                line_str.push(row_data[Column(col)].c);
265            }
266            let trimmed = line_str.trim_end();
267            result.push_str(trimmed);
268            result.push('\n');
269        }
270
271        // Then add visible screen content (line indices 0 to rows-1)
272        for row in 0..self.rows {
273            let line = self.get_line(row);
274            let line_str: String = line.iter().map(|c| c.c).collect();
275            let trimmed = line_str.trim_end();
276            result.push_str(trimmed);
277            if row < self.rows - 1 {
278                result.push('\n');
279            }
280        }
281
282        result
283    }
284
285    /// Get the number of scrollback history lines
286    pub fn history_size(&self) -> usize {
287        use alacritty_terminal::grid::Dimensions;
288        self.term.grid().history_size()
289    }
290
291    /// Get the title (if set by escape sequence)
292    pub fn title(&self) -> &str {
293        &self.terminal_title
294    }
295
296    /// Set the terminal title (called when escape sequence is received)
297    pub fn set_title(&mut self, title: String) {
298        self.terminal_title = title;
299    }
300
301    /// Scroll to the bottom of the terminal (display offset = 0)
302    /// Used when re-entering terminal mode from scrollback view
303    pub fn scroll_to_bottom(&mut self) {
304        self.term.scroll_display(Scroll::Bottom);
305        self.dirty = true;
306    }
307
308    // =========================================================================
309    // Terminal mode flags
310    // =========================================================================
311
312    /// Check if the terminal is in alternate screen mode.
313    /// Programs like vim, less, htop use alternate screen.
314    pub fn is_alternate_screen(&self) -> bool {
315        self.term.mode().contains(TermMode::ALT_SCREEN)
316    }
317
318    /// Check if the terminal wants mouse events reported.
319    /// Returns true if any mouse reporting mode is enabled.
320    pub fn wants_mouse_events(&self) -> bool {
321        let mode = self.term.mode();
322        mode.intersects(
323            TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG,
324        )
325    }
326
327    /// Check if SGR mouse encoding is enabled (modern mouse protocol).
328    pub fn uses_sgr_mouse(&self) -> bool {
329        self.term.mode().contains(TermMode::SGR_MOUSE)
330    }
331
332    /// Check if alternate scroll mode is enabled.
333    /// When enabled, scroll wheel should be sent as up/down arrow keys.
334    pub fn uses_alternate_scroll(&self) -> bool {
335        self.term.mode().contains(TermMode::ALTERNATE_SCROLL)
336    }
337
338    /// Check if application cursor keys mode (DECCKM) is enabled.
339    /// Programs like less, git log set this mode so that arrow keys
340    /// send `\x1bOA` (SS3) instead of `\x1b[A` (CSI).
341    pub fn is_app_cursor(&self) -> bool {
342        self.term.mode().contains(TermMode::APP_CURSOR)
343    }
344
345    // =========================================================================
346    // Incremental scrollback streaming
347    // =========================================================================
348
349    /// Flush any new scrollback lines to the writer.
350    ///
351    /// Call this after `process_output()` to incrementally stream scrollback
352    /// to the backing file. Returns the number of new lines written.
353    ///
354    /// This is the core of the incremental streaming architecture: scrollback
355    /// lines are written once as they scroll off the screen, avoiding O(n)
356    /// work on mode switches.
357    pub fn flush_new_scrollback<W: Write>(&mut self, writer: &mut W) -> io::Result<usize> {
358        use alacritty_terminal::grid::Dimensions;
359
360        let grid = self.term.grid();
361        let current_history = grid.history_size();
362
363        if current_history <= self.synced_history_lines {
364            return Ok(0);
365        }
366
367        let new_count = current_history - self.synced_history_lines;
368
369        // New scrollback lines are at indices -new_count down to -1
370        // When history grows, new lines are always added at the "bottom" of history
371        // (closest to visible screen), and old lines shift to larger negative indices.
372        //
373        // Example: if synced=6 and current=16:
374        // - Old lines (already flushed) are now at -16 to -11
375        // - New lines are at -10 to -1
376        // We write oldest-first: -10, -9, ..., -1
377        for i in 0..new_count {
378            // Line index: oldest new line first
379            // i=0 -> -new_count = -10 (oldest new line)
380            // i=9 -> -1 (newest new line, just scrolled off)
381            let line_idx = -((new_count - i) as i32);
382            self.write_grid_line(writer, Line(line_idx))?;
383        }
384
385        self.synced_history_lines = current_history;
386        // Update the byte offset where scrollback ends
387        // The writer should be positioned at end, so we can query position
388        // For simplicity, we track this separately when we know the file position
389
390        Ok(new_count)
391    }
392
393    /// Append the visible screen content to the writer.
394    ///
395    /// Call this when exiting terminal mode to add the current screen
396    /// to the backing file. The visible screen is the "rewritable tail"
397    /// that gets overwritten each time we exit terminal mode.
398    ///
399    /// Only writes up to and including the last non-empty line to avoid
400    /// padding the scrollback with empty lines.
401    pub fn append_visible_screen<W: Write>(&self, writer: &mut W) -> io::Result<()> {
402        let grid = self.term.grid();
403
404        // Find the last non-empty row
405        let mut last_non_empty_row: i32 = -1;
406        for row in 0..self.rows as i32 {
407            let row_data = &grid[Line(row)];
408            let is_empty = (0..self.cols as usize)
409                .all(|col| row_data[Column(col)].c == ' ' || row_data[Column(col)].c == '\0');
410            if !is_empty {
411                last_non_empty_row = row;
412            }
413        }
414
415        // Write rows up to and including the last non-empty row
416        for row in 0..=last_non_empty_row {
417            self.write_grid_line(writer, Line(row))?;
418        }
419        Ok(())
420    }
421
422    /// Write a single grid line to the writer with ANSI color codes, trimming trailing whitespace.
423    ///
424    /// Note: The ANSI codes enable terminal scrollback colors to be preserved in the backing file.
425    /// For colors to display correctly in scrollback mode, the buffer renderer must interpret
426    /// these ANSI escape sequences. See src/view/buffer.rs for rendering logic.
427    fn write_grid_line<W: Write>(&self, writer: &mut W, line: Line) -> io::Result<()> {
428        use alacritty_terminal::term::cell::Flags;
429
430        let grid = self.term.grid();
431        let row_data = &grid[line];
432
433        let mut line_str = String::with_capacity(self.cols as usize * 2);
434        let mut current_fg: Option<(u8, u8, u8)> = None;
435        let mut current_bg: Option<(u8, u8, u8)> = None;
436        let mut current_bold = false;
437        let mut current_italic = false;
438        let mut current_underline = false;
439
440        for col in 0..self.cols as usize {
441            let cell = &row_data[Column(col)];
442            let fg = color_to_rgb(&cell.fg);
443            let bg = color_to_rgb(&cell.bg);
444            let flags = cell.flags;
445            let bold = flags.contains(Flags::BOLD);
446            let italic = flags.contains(Flags::ITALIC);
447            let underline = flags.contains(Flags::UNDERLINE);
448
449            // Check if we need to emit style codes
450            let fg_changed = fg != current_fg;
451            let bg_changed = bg != current_bg;
452            let bold_changed = bold != current_bold;
453            let italic_changed = italic != current_italic;
454            let underline_changed = underline != current_underline;
455
456            if fg_changed || bg_changed || bold_changed || italic_changed || underline_changed {
457                // Build SGR (Select Graphic Rendition) sequence
458                let mut codes: Vec<String> = Vec::new();
459
460                // Reset first if we're turning off attributes
461                if (current_bold && !bold)
462                    || (current_italic && !italic)
463                    || (current_underline && !underline)
464                {
465                    codes.push("0".to_string());
466                    // After reset, we need to reapply colors and active attributes
467                    if bold {
468                        codes.push("1".to_string());
469                    }
470                    if italic {
471                        codes.push("3".to_string());
472                    }
473                    if underline {
474                        codes.push("4".to_string());
475                    }
476                    if let Some((r, g, b)) = fg {
477                        codes.push(format!("38;2;{};{};{}", r, g, b));
478                    }
479                    if let Some((r, g, b)) = bg {
480                        codes.push(format!("48;2;{};{};{}", r, g, b));
481                    }
482                } else {
483                    // Apply incremental changes
484                    if bold_changed && bold {
485                        codes.push("1".to_string());
486                    }
487                    if italic_changed && italic {
488                        codes.push("3".to_string());
489                    }
490                    if underline_changed && underline {
491                        codes.push("4".to_string());
492                    }
493                    if fg_changed {
494                        if let Some((r, g, b)) = fg {
495                            codes.push(format!("38;2;{};{};{}", r, g, b));
496                        } else {
497                            codes.push("39".to_string()); // Default foreground
498                        }
499                    }
500                    if bg_changed {
501                        if let Some((r, g, b)) = bg {
502                            codes.push(format!("48;2;{};{};{}", r, g, b));
503                        } else {
504                            codes.push("49".to_string()); // Default background
505                        }
506                    }
507                }
508
509                if !codes.is_empty() {
510                    line_str.push_str(&format!("\x1b[{}m", codes.join(";")));
511                }
512
513                current_fg = fg;
514                current_bg = bg;
515                current_bold = bold;
516                current_italic = italic;
517                current_underline = underline;
518            }
519
520            line_str.push(cell.c);
521        }
522
523        // Reset at end of line if we have any active styles
524        if current_fg.is_some()
525            || current_bg.is_some()
526            || current_bold
527            || current_italic
528            || current_underline
529        {
530            line_str.push_str("\x1b[0m");
531        }
532
533        // Trim trailing whitespace but preserve color codes
534        let trimmed = line_str.trim_end_matches([' ', '\0']);
535        writeln!(writer, "{}", trimmed)
536    }
537
538    /// Get the byte offset where scrollback history ends in the backing file.
539    ///
540    /// Used for truncating the file when re-entering terminal mode
541    /// (to remove the visible screen portion).
542    pub fn backing_file_history_end(&self) -> u64 {
543        self.backing_file_history_end
544    }
545
546    /// Set the byte offset where scrollback history ends.
547    ///
548    /// Call this after flushing scrollback to record the file position.
549    pub fn set_backing_file_history_end(&mut self, offset: u64) {
550        self.backing_file_history_end = offset;
551    }
552
553    /// Get the number of scrollback lines that have been synced to the backing file.
554    pub fn synced_history_lines(&self) -> usize {
555        self.synced_history_lines
556    }
557
558    /// Reset sync state (e.g., when starting fresh or after truncation).
559    pub fn reset_sync_state(&mut self) {
560        self.synced_history_lines = 0;
561        self.backing_file_history_end = 0;
562    }
563}
564
565/// A single cell in the terminal grid
566#[derive(Debug, Clone)]
567pub struct TerminalCell {
568    /// The character
569    pub c: char,
570    /// Foreground color as RGB
571    pub fg: Option<(u8, u8, u8)>,
572    /// Background color as RGB
573    pub bg: Option<(u8, u8, u8)>,
574    /// Bold flag
575    pub bold: bool,
576    /// Italic flag
577    pub italic: bool,
578    /// Underline flag
579    pub underline: bool,
580    /// Inverse video flag
581    pub inverse: bool,
582}
583
584impl Default for TerminalCell {
585    fn default() -> Self {
586        Self {
587            c: ' ',
588            fg: None,
589            bg: None,
590            bold: false,
591            italic: false,
592            underline: false,
593            inverse: false,
594        }
595    }
596}
597
598/// Convert alacritty color to RGB
599fn color_to_rgb(color: &alacritty_terminal::vte::ansi::Color) -> Option<(u8, u8, u8)> {
600    use alacritty_terminal::vte::ansi::Color;
601
602    match color {
603        Color::Spec(rgb) => Some((rgb.r, rgb.g, rgb.b)),
604        Color::Named(named) => {
605            // Convert named colors to RGB
606            // Using standard ANSI color palette
607            let rgb = match named {
608                alacritty_terminal::vte::ansi::NamedColor::Black => (0, 0, 0),
609                alacritty_terminal::vte::ansi::NamedColor::Red => (205, 49, 49),
610                alacritty_terminal::vte::ansi::NamedColor::Green => (13, 188, 121),
611                alacritty_terminal::vte::ansi::NamedColor::Yellow => (229, 229, 16),
612                alacritty_terminal::vte::ansi::NamedColor::Blue => (36, 114, 200),
613                alacritty_terminal::vte::ansi::NamedColor::Magenta => (188, 63, 188),
614                alacritty_terminal::vte::ansi::NamedColor::Cyan => (17, 168, 205),
615                alacritty_terminal::vte::ansi::NamedColor::White => (229, 229, 229),
616                alacritty_terminal::vte::ansi::NamedColor::BrightBlack => (102, 102, 102),
617                alacritty_terminal::vte::ansi::NamedColor::BrightRed => (241, 76, 76),
618                alacritty_terminal::vte::ansi::NamedColor::BrightGreen => (35, 209, 139),
619                alacritty_terminal::vte::ansi::NamedColor::BrightYellow => (245, 245, 67),
620                alacritty_terminal::vte::ansi::NamedColor::BrightBlue => (59, 142, 234),
621                alacritty_terminal::vte::ansi::NamedColor::BrightMagenta => (214, 112, 214),
622                alacritty_terminal::vte::ansi::NamedColor::BrightCyan => (41, 184, 219),
623                alacritty_terminal::vte::ansi::NamedColor::BrightWhite => (255, 255, 255),
624                alacritty_terminal::vte::ansi::NamedColor::Foreground => return None,
625                alacritty_terminal::vte::ansi::NamedColor::Background => return None,
626                alacritty_terminal::vte::ansi::NamedColor::Cursor => return None,
627                _ => return None,
628            };
629            Some(rgb)
630        }
631        Color::Indexed(idx) => {
632            // Convert 256-color index to RGB
633            // Standard 256-color palette
634            let idx = *idx as usize;
635            if idx < 16 {
636                // Standard colors (same as named)
637                let colors = [
638                    (0, 0, 0),       // Black
639                    (205, 49, 49),   // Red
640                    (13, 188, 121),  // Green
641                    (229, 229, 16),  // Yellow
642                    (36, 114, 200),  // Blue
643                    (188, 63, 188),  // Magenta
644                    (17, 168, 205),  // Cyan
645                    (229, 229, 229), // White
646                    (102, 102, 102), // Bright Black
647                    (241, 76, 76),   // Bright Red
648                    (35, 209, 139),  // Bright Green
649                    (245, 245, 67),  // Bright Yellow
650                    (59, 142, 234),  // Bright Blue
651                    (214, 112, 214), // Bright Magenta
652                    (41, 184, 219),  // Bright Cyan
653                    (255, 255, 255), // Bright White
654                ];
655                Some(colors[idx])
656            } else if idx < 232 {
657                // 216 color cube (6x6x6)
658                let idx = idx - 16;
659                let r = (idx / 36) * 51;
660                let g = ((idx / 6) % 6) * 51;
661                let b = (idx % 6) * 51;
662                Some((r as u8, g as u8, b as u8))
663            } else {
664                // 24 grayscale colors
665                let gray = (idx - 232) * 10 + 8;
666                Some((gray as u8, gray as u8, gray as u8))
667            }
668        }
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn test_terminal_state_new() {
678        let state = TerminalState::new(80, 24);
679        assert_eq!(state.size(), (80, 24));
680        assert!(state.is_dirty());
681    }
682
683    #[test]
684    fn test_terminal_process_output() {
685        let mut state = TerminalState::new(80, 24);
686        state.process_output(b"Hello, World!");
687        let content = state.content_string();
688        assert!(content.contains("Hello, World!"));
689    }
690
691    #[test]
692    fn test_terminal_resize() {
693        let mut state = TerminalState::new(80, 24);
694        state.mark_clean();
695        assert!(!state.is_dirty());
696
697        state.resize(100, 30);
698        assert_eq!(state.size(), (100, 30));
699        assert!(state.is_dirty());
700    }
701
702    #[test]
703    fn test_flush_new_scrollback_no_history() {
704        // When there's no scrollback history, flush should return 0
705        let mut state = TerminalState::new(80, 24);
706        state.process_output(b"Hello");
707
708        let mut buffer = Vec::new();
709        let count = state.flush_new_scrollback(&mut buffer).unwrap();
710
711        assert_eq!(count, 0, "No scrollback yet, should flush 0 lines");
712        assert!(buffer.is_empty(), "Buffer should be empty");
713    }
714
715    #[test]
716    fn test_flush_new_scrollback_after_scroll() {
717        // Generate enough output to create scrollback
718        let mut state = TerminalState::new(80, 10); // Small terminal to trigger scrollback quickly
719
720        // Generate output that exceeds the terminal height
721        for i in 1..=20 {
722            state.process_output(format!("Line {}\r\n", i).as_bytes());
723        }
724
725        let mut buffer = Vec::new();
726        let count = state.flush_new_scrollback(&mut buffer).unwrap();
727
728        // Should have some scrollback lines
729        let output = String::from_utf8_lossy(&buffer);
730        eprintln!(
731            "Scrollback test: count={}, synced={}, buffer_len={}, output:\n{}",
732            count,
733            state.synced_history_lines(),
734            buffer.len(),
735            output
736        );
737
738        // The first lines should have scrolled off
739        assert!(count > 0, "Should have some scrollback lines");
740        assert!(
741            output.contains("Line 1"),
742            "Scrollback should contain Line 1"
743        );
744    }
745
746    #[test]
747    fn test_append_visible_screen() {
748        let mut state = TerminalState::new(80, 5);
749        state.process_output(b"Line A\r\nLine B\r\nLine C\r\n");
750
751        let mut buffer = Vec::new();
752        state.append_visible_screen(&mut buffer).unwrap();
753
754        let output = String::from_utf8_lossy(&buffer);
755        assert!(
756            output.contains("Line A"),
757            "Visible screen should contain Line A"
758        );
759        assert!(
760            output.contains("Line B"),
761            "Visible screen should contain Line B"
762        );
763        assert!(
764            output.contains("Line C"),
765            "Visible screen should contain Line C"
766        );
767    }
768
769    #[test]
770    fn test_scrollback_then_visible_no_duplication() {
771        // Test the full flow: scrollback lines + visible screen should not duplicate
772        let mut state = TerminalState::new(80, 5); // Small terminal
773
774        // Generate output that creates scrollback
775        // Use unique markers that won't accidentally match each other
776        for i in 1..=15 {
777            state.process_output(format!("UNIQUELINE_{:02}\r\n", i).as_bytes());
778        }
779
780        // Flush scrollback
781        let mut scrollback_buffer = Vec::new();
782        let scrollback_count = state.flush_new_scrollback(&mut scrollback_buffer).unwrap();
783        let scrollback_output = String::from_utf8_lossy(&scrollback_buffer);
784
785        // Append visible screen
786        let mut visible_buffer = Vec::new();
787        state.append_visible_screen(&mut visible_buffer).unwrap();
788        let visible_output = String::from_utf8_lossy(&visible_buffer);
789
790        eprintln!(
791            "Scrollback ({} lines):\n{}",
792            scrollback_count, scrollback_output
793        );
794        eprintln!("Visible screen:\n{}", visible_output);
795
796        // Combined output should have each line exactly once
797        let combined = format!("{}{}", scrollback_output, visible_output);
798
799        // Count occurrences of each line
800        for i in 1..=15 {
801            let pattern = format!("UNIQUELINE_{:02}", i);
802            let count = combined.matches(&pattern).count();
803            assert!(
804                count >= 1,
805                "Line {} should appear at least once, but found {} times",
806                i,
807                count
808            );
809            // Allow for some overlap at boundaries, but not excessive duplication
810            assert!(
811                count <= 2,
812                "Line {} appears {} times - too much duplication",
813                i,
814                count
815            );
816        }
817    }
818
819    #[test]
820    fn test_backing_file_history_end_tracking() {
821        let mut state = TerminalState::new(80, 5);
822
823        // Initially should be 0
824        assert_eq!(state.backing_file_history_end(), 0);
825
826        // Set it
827        state.set_backing_file_history_end(1234);
828        assert_eq!(state.backing_file_history_end(), 1234);
829
830        // Reset should clear it
831        state.reset_sync_state();
832        assert_eq!(state.backing_file_history_end(), 0);
833        assert_eq!(state.synced_history_lines(), 0);
834    }
835
836    #[test]
837    fn test_multiple_flush_cycles_no_duplication() {
838        use alacritty_terminal::grid::Dimensions;
839
840        // Simulate multiple enter/exit terminal mode cycles
841        let mut state = TerminalState::new(80, 5);
842
843        // First batch of output (10 lines in 5-row terminal)
844        // Lines 1-6 scroll into history, lines 7-10 are visible
845        for i in 1..=10 {
846            state.process_output(format!("Batch1-Line{}\r\n", i).as_bytes());
847        }
848
849        let history1 = state.term.grid().history_size();
850        eprintln!("After Batch1: history_size={}", history1);
851        assert_eq!(
852            history1, 6,
853            "After 10 lines in 5-row terminal, 6 should be in history"
854        );
855
856        // First flush - should get lines 1-6
857        let mut buffer1 = Vec::new();
858        let count1 = state.flush_new_scrollback(&mut buffer1).unwrap();
859        let output1 = String::from_utf8_lossy(&buffer1);
860        eprintln!("First flush: {} lines\n{}", count1, output1);
861
862        assert_eq!(count1, 6);
863        assert!(output1.contains("Batch1-Line1"));
864        assert!(output1.contains("Batch1-Line6"));
865        assert!(
866            !output1.contains("Batch1-Line7"),
867            "Line 7 should still be visible, not in scrollback"
868        );
869
870        // Second flush without new output should return 0
871        let mut buffer2 = Vec::new();
872        let count2 = state.flush_new_scrollback(&mut buffer2).unwrap();
873        assert_eq!(count2, 0, "Second flush without new output should be 0");
874
875        // More output (10 more lines)
876        // This pushes Batch1-Line7-10 into history, plus Batch2-Line1-6
877        for i in 1..=10 {
878            state.process_output(format!("Batch2-Line{}\r\n", i).as_bytes());
879        }
880
881        let history3 = state.term.grid().history_size();
882        eprintln!("After Batch2: history_size={}", history3);
883
884        // Third flush should get lines that scrolled off since last flush
885        // That's Batch1-Line7-10 (4 lines) + Batch2-Line1-6 (6 lines) = 10 lines
886        let mut buffer3 = Vec::new();
887        let count3 = state.flush_new_scrollback(&mut buffer3).unwrap();
888        let output3 = String::from_utf8_lossy(&buffer3);
889        eprintln!("Third flush: {} lines\n{}", count3, output3);
890
891        assert_eq!(count3, 10, "Should flush 10 new lines");
892        // Should include Batch1 lines 7-10 (they weren't flushed before, were still visible)
893        assert!(
894            output3.contains("Batch1-Line7"),
895            "Batch1-Line7 should be in third flush (was visible, now scrolled)"
896        );
897        assert!(output3.contains("Batch1-Line10"));
898        // Should include Batch2 lines 1-6 (new content that scrolled off)
899        assert!(output3.contains("Batch2-Line1"));
900        assert!(output3.contains("Batch2-Line6"));
901        // Should NOT include Batch1-Line1-6 (already flushed)
902        assert!(
903            !output3.contains("Batch1-Line1\n"),
904            "Batch1-Line1 was already flushed, shouldn't appear again"
905        );
906        assert!(
907            !output3.contains("Batch1-Line6\n"),
908            "Batch1-Line6 was already flushed, shouldn't appear again"
909        );
910    }
911
912    #[test]
913    fn test_dsr_cursor_position_response() {
914        // Test that sending a DSR (Device Status Report) query generates a response
915        // This is critical for Windows ConPTY where PowerShell waits for this response
916        let mut state = TerminalState::new(80, 24);
917
918        // Initially the write queue should be empty
919        assert!(
920            state.drain_pty_write_queue().is_empty(),
921            "Write queue should be empty initially"
922        );
923
924        // Send DSR query: ESC [ 6 n (request cursor position)
925        state.process_output(b"\x1b[6n");
926
927        // The terminal should generate a response: ESC [ row ; col R
928        let responses = state.drain_pty_write_queue();
929        assert_eq!(responses.len(), 1, "Should have exactly one response");
930
931        let response = &responses[0];
932        // Response format: \x1b[row;colR where row and col are 1-based
933        // Cursor starts at (0,0) internally, so response should be \x1b[1;1R
934        assert!(
935            response.starts_with("\x1b["),
936            "Response should start with ESC["
937        );
938        assert!(response.ends_with("R"), "Response should end with R");
939        eprintln!("DSR response: {:?}", response);
940
941        // Draining again should return empty
942        assert!(
943            state.drain_pty_write_queue().is_empty(),
944            "Write queue should be empty after draining"
945        );
946    }
947
948    #[test]
949    fn test_dsr_response_after_cursor_move() {
950        // Test DSR response reflects actual cursor position
951        let mut state = TerminalState::new(80, 24);
952
953        // Move cursor to row 5, column 10 using CUP (Cursor Position)
954        // ESC [ 5 ; 10 H
955        state.process_output(b"\x1b[5;10H");
956
957        // Request cursor position
958        state.process_output(b"\x1b[6n");
959
960        let responses = state.drain_pty_write_queue();
961        assert_eq!(responses.len(), 1);
962
963        let response = &responses[0];
964        // Should report position as row 5, col 10
965        assert_eq!(response, "\x1b[5;10R", "Response should be \\x1b[5;10R");
966    }
967}