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::path::PathBuf;
38use std::sync::{Arc, Mutex};
39
40// Keep a generous scrollback so sync-to-buffer can include deep history.
41const SCROLLBACK_LINES: usize = 200_000;
42
43/// Event listener that captures PtyWrite events for sending back to the PTY.
44///
45/// When the terminal emulator needs to respond to queries (like DSR cursor position
46/// requests `\x1b[6n`), it generates `Event::PtyWrite` events. These must be captured
47/// and sent back to the PTY for the shell to receive the response.
48#[derive(Clone)]
49struct PtyWriteListener {
50    /// Queue of data to write back to the PTY
51    write_queue: Arc<Mutex<Vec<String>>>,
52    /// Latest title requested by the program via OSC 0/1/2 (or a reset
53    /// via the OSC reset sequence). `Some` means a change is pending;
54    /// the inner string is the new title (empty string for a reset).
55    /// `process_output` drains this after parsing to update the
56    /// terminal's stored title.
57    pending_title: Arc<Mutex<Option<String>>>,
58}
59
60impl PtyWriteListener {
61    fn new() -> Self {
62        Self {
63            write_queue: Arc::new(Mutex::new(Vec::new())),
64            pending_title: Arc::new(Mutex::new(None)),
65        }
66    }
67}
68
69impl EventListener for PtyWriteListener {
70    fn send_event(&self, event: Event) {
71        match event {
72            Event::PtyWrite(text) => {
73                if let Ok(mut queue) = self.write_queue.lock() {
74                    queue.push(text);
75                }
76            }
77            // OSC 0 (icon + window title), OSC 1 (icon title), and OSC 2
78            // (window title) all surface as `Title`. Record the latest;
79            // `process_output` propagates it to `terminal_title` so the
80            // buffer's tab auto-adjusts to whatever the running program set.
81            Event::Title(title) => {
82                if let Ok(mut pending) = self.pending_title.lock() {
83                    *pending = Some(title);
84                }
85            }
86            // Title reset (OSC with empty payload) — clear back to the
87            // buffer's default name by recording an empty title.
88            Event::ResetTitle => {
89                if let Ok(mut pending) = self.pending_title.lock() {
90                    *pending = Some(String::new());
91                }
92            }
93            // Other events (ClipboardStore, etc.) are ignored for now.
94            _ => {}
95        }
96    }
97}
98
99/// Incremental scanner that extracts the working directory a shell reports via
100/// the OSC 7 escape sequence (`ESC ] 7 ; file://host/path BEL`, or terminated
101/// by `ST` = `ESC \`).
102///
103/// The terminal emulator we embed (`alacritty_terminal` 0.25 / `vte` 0.15) does
104/// not surface OSC 7 — its OSC dispatcher drops the sequence as "unhandled" and
105/// the `Handler` trait has no cwd hook — so we sniff it out of the raw PTY byte
106/// stream ourselves. Sequences can straddle PTY reads, so the scanner is a
107/// resumable state machine: callers feed every byte that flows to the emulator
108/// and collect a payload once a complete sequence terminates.
109#[derive(Debug, Default)]
110struct Osc7Scanner {
111    /// How many bytes of the introducer `ESC ] 7 ;` have matched so far (0..4).
112    intro_match: usize,
113    /// True once the introducer matched and we're accumulating the payload.
114    collecting: bool,
115    /// True when the previous collected byte was `ESC`, i.e. a possible start
116    /// of the `ST` (`ESC \`) string terminator.
117    saw_esc: bool,
118    /// Accumulated payload bytes (between the introducer and the terminator).
119    buf: Vec<u8>,
120}
121
122/// Introducer bytes for OSC 7: `ESC ] 7 ;`.
123const OSC7_INTRO: [u8; 4] = [0x1b, b']', b'7', b';'];
124/// Cap on the OSC 7 payload we'll buffer. A `file://` cwd URI is far shorter;
125/// anything longer is malformed (or not really OSC 7) and is abandoned so a
126/// stray `ESC ] 7 ;` without a terminator can't grow the buffer unboundedly.
127const OSC7_MAX_PAYLOAD: usize = 4096;
128
129impl Osc7Scanner {
130    /// Feed one chunk of PTY output. Returns the payload string of each OSC 7
131    /// sequence that *completes* within this chunk (usually zero or one).
132    fn feed(&mut self, data: &[u8], out: &mut Vec<String>) {
133        for &byte in data {
134            if self.collecting {
135                if self.saw_esc {
136                    // Inside the payload we only treat `ESC \` (ST) as a
137                    // terminator. Any other byte after ESC means the sequence
138                    // is malformed — abandon it rather than risk swallowing
139                    // unrelated output.
140                    self.saw_esc = false;
141                    if byte == b'\\' {
142                        self.finish(out);
143                    } else {
144                        self.reset();
145                    }
146                } else if byte == 0x07 {
147                    // BEL terminator.
148                    self.finish(out);
149                } else if byte == 0x1b {
150                    self.saw_esc = true;
151                } else if self.buf.len() >= OSC7_MAX_PAYLOAD {
152                    self.reset();
153                } else {
154                    self.buf.push(byte);
155                }
156            } else if byte == OSC7_INTRO[self.intro_match] {
157                self.intro_match += 1;
158                if self.intro_match == OSC7_INTRO.len() {
159                    self.collecting = true;
160                    self.intro_match = 0;
161                    self.buf.clear();
162                }
163            } else {
164                // Restart the introducer match. The introducer's only repeated
165                // prefix byte is its first (ESC), so a one-byte re-check
166                // suffices to avoid missing a sequence like `ESC ESC ] 7 ;`.
167                self.intro_match = usize::from(byte == OSC7_INTRO[0]);
168            }
169        }
170    }
171
172    /// Emit the collected payload and reset to searching.
173    fn finish(&mut self, out: &mut Vec<String>) {
174        if let Ok(s) = std::str::from_utf8(&self.buf) {
175            out.push(s.to_owned());
176        }
177        self.reset();
178    }
179
180    fn reset(&mut self) {
181        self.collecting = false;
182        self.saw_esc = false;
183        self.intro_match = 0;
184        self.buf.clear();
185    }
186}
187
188/// Parse the payload of an OSC 7 sequence into a working-directory path.
189///
190/// The conventional payload is a `file://host/path` URI (the host is usually
191/// the local hostname or empty), but some shells emit a bare absolute path.
192/// Percent-escapes in the URI form are decoded. Returns `None` for payloads
193/// that don't resolve to an absolute path.
194fn parse_osc7_path(payload: &str) -> Option<PathBuf> {
195    let raw = if let Some(rest) = payload.strip_prefix("file://") {
196        // Strip the authority (host) component: everything up to the first
197        // '/', which begins the absolute path.
198        let path_part = match rest.find('/') {
199            Some(idx) => &rest[idx..],
200            None => return None,
201        };
202        percent_decode(path_part)
203    } else {
204        // Bare path fallback (non-standard, but seen in the wild).
205        payload.to_owned()
206    };
207
208    if raw.is_empty() {
209        return None;
210    }
211
212    // A `file:///C:/dir` URI decodes to `/C:/dir`; drop the leading slash so it
213    // reads as a drive-absolute Windows path. Done unconditionally (not behind
214    // cfg) because Fresh may run on Linux while editing a Windows host, or vice
215    // versa — the OSC 7 payload's flavour follows the *shell's* OS, not ours.
216    let raw = {
217        let bytes = raw.as_bytes();
218        if bytes.len() >= 3 && bytes[0] == b'/' && bytes[2] == b':' {
219            raw[1..].to_owned()
220        } else {
221            raw
222        }
223    };
224
225    // Accept the path if it's absolute in *either* convention. We can't use
226    // `Path::is_absolute()` — it's host-OS-specific, so it would reject a POSIX
227    // path on Windows (and a `C:\` path on Unix), discarding valid cwds from a
228    // remote shell of the other OS.
229    if is_osc7_absolute(&raw) {
230        Some(PathBuf::from(raw))
231    } else {
232        None
233    }
234}
235
236/// Whether an OSC 7 path string is absolute in POSIX or Windows terms: a
237/// leading `/` (POSIX), a UNC `\\…` prefix, or a `X:` drive (Windows).
238fn is_osc7_absolute(s: &str) -> bool {
239    let bytes = s.as_bytes();
240    s.starts_with('/')
241        || s.starts_with('\\')
242        || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
243}
244
245/// Decode `%XX` percent-escapes in an OSC 7 URI path. Invalid escapes are left
246/// verbatim. Operates on bytes so non-ASCII (already-UTF-8) paths survive.
247fn percent_decode(input: &str) -> String {
248    let bytes = input.as_bytes();
249    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
250    let mut i = 0;
251    while i < bytes.len() {
252        if bytes[i] == b'%' && i + 2 < bytes.len() {
253            let hi = (bytes[i + 1] as char).to_digit(16);
254            let lo = (bytes[i + 2] as char).to_digit(16);
255            if let (Some(hi), Some(lo)) = (hi, lo) {
256                out.push((hi * 16 + lo) as u8);
257                i += 3;
258                continue;
259            }
260        }
261        out.push(bytes[i]);
262        i += 1;
263    }
264    String::from_utf8_lossy(&out).into_owned()
265}
266
267/// Terminal state wrapping alacritty_terminal
268pub struct TerminalState {
269    /// The terminal emulator
270    term: Term<PtyWriteListener>,
271    /// ANSI parser
272    parser: Processor,
273    /// Current dimensions
274    cols: u16,
275    rows: u16,
276    /// Whether content has changed since last render
277    dirty: bool,
278    /// Terminal title (set via escape sequences)
279    terminal_title: String,
280    /// Number of grid history *rows* already streamed to the backing file in the
281    /// current epoch (an epoch resets when the scrollback is cleared). Only ever
282    /// advances past complete logical lines (rows that don't continue via
283    /// `WRAPLINE`), so the file always ends on a logical-line boundary. Flush
284    /// only ever advances this past lines it *wrote*, so nothing is skipped —
285    /// scrollback is never lost (a grow may re-write a bounded few lines instead).
286    synced_history_lines: usize,
287    /// Count of complete logical lines streamed this epoch. Invariant under
288    /// width reflow (a logical line keeps its identity when re-wrapped), so it's
289    /// the anchor used to rebuild `synced_history_lines` after a resize re-wraps
290    /// the grid and invalidates the physical row count.
291    synced_logical_lines: usize,
292    /// A width resize happened while the alternate screen was active, so the
293    /// primary grid's history was reflowed but couldn't be re-anchored yet
294    /// (the grid in view was the alt grid). Deferred until alt-screen exit.
295    pending_reflow_resync: bool,
296    /// Byte offset in backing file where scrollback ends (for truncation)
297    backing_file_history_end: u64,
298    /// Queue of data to write back to the PTY (for DSR responses, etc.)
299    pty_write_queue: Arc<Mutex<Vec<String>>>,
300    /// Pending title set by the program via OSC 0/1/2 (shared with the
301    /// event listener). Drained in `process_output` into `terminal_title`.
302    pending_title: Arc<Mutex<Option<String>>>,
303    /// Working directory most recently reported by the shell via OSC 7, used
304    /// to resolve relative paths the running program prints (e.g. for
305    /// Ctrl+Click to open). `None` until the shell emits OSC 7.
306    cwd: Option<PathBuf>,
307    /// Resumable scanner that extracts OSC 7 payloads from the raw PTY stream.
308    osc7: Osc7Scanner,
309}
310
311impl TerminalState {
312    /// Create a new terminal state
313    pub fn new(cols: u16, rows: u16) -> Self {
314        let size = TermSize::new(cols as usize, rows as usize);
315        let config = TermConfig {
316            scrolling_history: SCROLLBACK_LINES,
317            ..Default::default()
318        };
319        let listener = PtyWriteListener::new();
320        let pty_write_queue = listener.write_queue.clone();
321        let pending_title = listener.pending_title.clone();
322        let term = Term::new(config, &size, listener);
323
324        Self {
325            term,
326            parser: Processor::new(),
327            cols,
328            rows,
329            dirty: true,
330            terminal_title: String::new(),
331            synced_history_lines: 0,
332            synced_logical_lines: 0,
333            pending_reflow_resync: false,
334            backing_file_history_end: 0,
335            pty_write_queue,
336            pending_title,
337            cwd: None,
338            osc7: Osc7Scanner::default(),
339        }
340    }
341
342    /// The terminal's current working directory as last reported by the shell
343    /// via OSC 7, if any. Tracks `cd` within the session (when the shell is
344    /// configured to emit OSC 7); `None` otherwise.
345    pub fn cwd(&self) -> Option<&std::path::Path> {
346        self.cwd.as_deref()
347    }
348
349    /// Drain any pending data that needs to be written back to the PTY.
350    ///
351    /// This is used for responses to terminal queries like DSR (cursor position report).
352    /// The caller should write this data to the PTY writer.
353    pub fn drain_pty_write_queue(&self) -> Vec<String> {
354        if let Ok(mut queue) = self.pty_write_queue.lock() {
355            std::mem::take(&mut *queue)
356        } else {
357            Vec::new()
358        }
359    }
360
361    /// Process output from the PTY
362    pub fn process_output(&mut self, data: &[u8]) {
363        use alacritty_terminal::grid::Dimensions;
364
365        let history_before = self.term.grid().history_size();
366        let alt_before = self.term.mode().contains(TermMode::ALT_SCREEN);
367
368        // Sniff OSC 7 (working-directory reports) out of the raw stream before
369        // it reaches the emulator, which discards the sequence. Take the latest
370        // valid payload — only the final cwd in this chunk matters.
371        let mut osc7_payloads = Vec::new();
372        self.osc7.feed(data, &mut osc7_payloads);
373        if let Some(path) = osc7_payloads.iter().rev().find_map(|p| parse_osc7_path(p)) {
374            self.cwd = Some(path);
375        }
376
377        self.parser.advance(&mut self.term, data);
378        // The parser may have emitted OSC title events (0/1/2) into the
379        // listener's pending slot during `advance`. Apply the latest so
380        // the stored title reflects what the program requested.
381        if let Ok(mut pending) = self.pending_title.lock() {
382            if let Some(title) = pending.take() {
383                self.terminal_title = title;
384            }
385        }
386
387        let alt_after = self.term.mode().contains(TermMode::ALT_SCREEN);
388        if alt_before && !alt_after && self.pending_reflow_resync {
389            // Returned from the alternate screen after a width resize happened
390            // while it was active: the primary grid (now back in view) was
391            // reflowed, so re-anchor against it now.
392            self.resync_after_reflow();
393            self.pending_reflow_resync = false;
394        }
395
396        // Output never shrinks scrollback during normal printing — only a
397        // scrollback clear (`ESC[3J`) or terminal reset (`RIS`, `ESC c`) does.
398        // (The alternate screen also reports zero history, but that's transient
399        // and restored on exit, so exclude it.) When it happens, the grid
400        // history we were tracking is gone; the backing file keeps everything
401        // already streamed, so start a fresh epoch — subsequent output is
402        // appended after the existing scrollback in the file.
403        if !alt_after {
404            let history_after = self.term.grid().history_size();
405            if history_after < history_before {
406                self.synced_history_lines = 0;
407                self.synced_logical_lines = 0;
408            }
409        }
410
411        self.dirty = true;
412    }
413
414    /// Resize the terminal.
415    ///
416    /// Scrollback is streamed to the backing file as complete *logical* lines.
417    /// A resize perturbs the visible/history boundary and — on a width change —
418    /// re-wraps already-persisted content, changing its physical row count.
419    /// Reconciliation depends on *why* history changed:
420    ///
421    /// * Pure height change (no reflow): physical rows are still valid, so leave
422    ///   `synced_history_lines` alone. A shrink pushes the top rows into
423    ///   scrollback — new content the next flush writes (no loss). A grow pulls
424    ///   rows back onto the screen; the `current <= synced` flush guard suppresses
425    ///   them until genuinely new lines scroll off (no duplicates).
426    ///
427    /// * Width change (reflow): the physical count is meaningless now, but the
428    ///   logical line count is invariant under re-wrapping. Re-derive
429    ///   `synced_history_lines` from `synced_logical_lines` by walking the
430    ///   reflowed history (a cheap flag-only scan, no I/O) so the next flush
431    ///   appends exactly the logical lines not yet persisted — width spill
432    ///   included, re-wraps excluded. (Deferred if the alternate screen is up,
433    ///   since the primary grid isn't the one in view.)
434    pub fn resize(&mut self, cols: u16, rows: u16) {
435        if cols != self.cols || rows != self.rows {
436            let cols_changed = cols != self.cols;
437            self.cols = cols;
438            self.rows = rows;
439            let size = TermSize::new(cols as usize, rows as usize);
440            self.term.resize(size);
441
442            if cols_changed {
443                if self.term.mode().contains(TermMode::ALT_SCREEN) {
444                    // The grid in view is the alt screen (no scrollback); the
445                    // primary grid reflowed underneath. Re-anchor on alt exit.
446                    self.pending_reflow_resync = true;
447                } else {
448                    self.resync_after_reflow();
449                }
450            }
451
452            self.dirty = true;
453        }
454    }
455
456    /// Rebuild `synced_history_lines` (physical rows) after a width reflow
457    /// invalidated the physical row count.
458    ///
459    /// The logical-line position the pointer sat at (`synced_logical_lines`) is
460    /// invariant under re-wrapping, so we walk the reflowed history oldest→newest
461    /// counting complete logical lines until we've re-reached that position, and
462    /// set the physical pointer to the rows consumed. A flag-only scan (no
463    /// allocation, no I/O). If a simultaneous grow pulled rows back onto the
464    /// screen so history now holds fewer logical lines, the pointer lands at the
465    /// end of what remains; those lines may then be re-written (a bounded
466    /// duplicate) when they scroll off again — never lost.
467    fn resync_after_reflow(&mut self) {
468        use alacritty_terminal::grid::Dimensions;
469
470        let history = self.term.grid().history_size();
471        let target = self.synced_logical_lines;
472        let mut logical_seen = 0usize;
473        let mut synced = 0usize;
474        let mut k = 0usize;
475        while k < history && logical_seen < target {
476            let line_idx = -((history - k) as i32);
477            if !self.row_wraps(Line(line_idx)) {
478                logical_seen += 1;
479                synced = k + 1;
480            }
481            k += 1;
482        }
483        self.synced_history_lines = synced;
484        self.synced_logical_lines = logical_seen;
485    }
486
487    /// Get current dimensions
488    pub fn size(&self) -> (u16, u16) {
489        (self.cols, self.rows)
490    }
491
492    /// Check if content has changed
493    pub fn is_dirty(&self) -> bool {
494        self.dirty
495    }
496
497    /// Mark as clean after rendering
498    pub fn mark_clean(&mut self) {
499        self.dirty = false;
500    }
501
502    /// Get the cursor position (column, row)
503    pub fn cursor_position(&self) -> (u16, u16) {
504        let cursor = self.term.grid().cursor.point;
505        (cursor.column.0 as u16, cursor.line.0 as u16)
506    }
507
508    /// Check if cursor is visible
509    pub fn cursor_visible(&self) -> bool {
510        // alacritty_terminal doesn't expose cursor visibility directly
511        // We'll assume it's always visible for now
512        true
513    }
514
515    /// Snapshot of the cursor row's text content as a plain string.
516    ///
517    /// Used by the `terminal_output` plugin hook so listeners (e.g.
518    /// the Orchestrator agent state machine) can match prompt patterns
519    /// without a separate readback API. Returns cells `[0..cursor_col)`
520    /// of the cursor row so a legitimate trailing space typed by the
521    /// program (typical for prompts like `"... (Y/n): "`) is
522    /// preserved while the unwritten right-edge padding past the
523    /// cursor is dropped. Falls back to trimming the whole row when
524    /// the cursor has wrapped to the start of a freshly-allocated
525    /// next row (col == 0): the visible content lives one row up,
526    /// and the trailing space ambiguity doesn't apply (a wrap means
527    /// the line was full).
528    pub fn last_visible_line(&self) -> String {
529        let (col, row) = self.cursor_position();
530        if row >= self.rows {
531            return String::new();
532        }
533        if col == 0 && row > 0 {
534            // Cursor wrapped to a fresh row; the meaningful prompt
535            // content sits on the row above. Take that row whole and
536            // strip any right-edge padding from it.
537            let cells = self.get_line(row - 1);
538            let mut s: String = cells.iter().map(|cell| cell.c).collect();
539            let trimmed_len = s.trim_end_matches(' ').len();
540            s.truncate(trimmed_len);
541            return s;
542        }
543        let cells = self.get_line(row);
544        let take = (col as usize).min(cells.len());
545        cells.iter().take(take).map(|cell| cell.c).collect()
546    }
547
548    /// Get a line of content for rendering
549    ///
550    /// Returns cells as (char, foreground_color, background_color, flags) tuples.
551    /// Colors are ANSI color indices (0-255) or None for default.
552    /// Accounts for scroll offset (display_offset) when accessing lines.
553    pub fn get_line(&self, row: u16) -> Vec<TerminalCell> {
554        use alacritty_terminal::index::{Column, Line};
555        use alacritty_terminal::term::cell::Flags;
556
557        let grid = self.term.grid();
558        let display_offset = grid.display_offset();
559
560        // Adjust line index for scroll offset
561        // When scrolled up by N lines, row 0 should show content from N lines back in history
562        let line = Line(row as i32 - display_offset as i32);
563
564        // Check if line is in valid range (use rows as the limit)
565        if row >= self.rows {
566            return vec![TerminalCell::default(); self.cols as usize];
567        }
568
569        let row_data = &grid[line];
570        let mut cells = Vec::with_capacity(self.cols as usize);
571
572        for col in 0..self.cols as usize {
573            let cell = &row_data[Column(col)];
574            let c = cell.c;
575
576            // Convert colors
577            let fg = color_to_rgb(&cell.fg);
578            let bg = color_to_rgb(&cell.bg);
579
580            // Check flags
581            let flags = cell.flags;
582            let bold = flags.contains(Flags::BOLD);
583            let italic = flags.contains(Flags::ITALIC);
584            let underline = flags.contains(Flags::UNDERLINE);
585            let inverse = flags.contains(Flags::INVERSE);
586
587            cells.push(TerminalCell {
588                c,
589                fg,
590                bg,
591                bold,
592                italic,
593                underline,
594                inverse,
595            });
596        }
597
598        cells
599    }
600
601    /// Get all visible content as a string (for testing/debugging)
602    pub fn content_string(&self) -> String {
603        let mut result = String::new();
604        for row in 0..self.rows {
605            let line = self.get_line(row);
606            for cell in line {
607                result.push(cell.c);
608            }
609            result.push('\n');
610        }
611        result
612    }
613
614    /// Get all content including scrollback history as a string
615    /// Lines are in chronological order (oldest first)
616    ///
617    /// WARNING: This is O(total_history) and should NOT be used in hot paths.
618    /// For mode switching, use the incremental streaming architecture instead:
619    /// - `flush_new_scrollback()` during PTY reads
620    /// - `append_visible_screen()` on mode exit
621    #[allow(dead_code)]
622    pub fn full_content_string(&self) -> String {
623        use alacritty_terminal::grid::Dimensions;
624        use alacritty_terminal::index::{Column, Line};
625
626        let grid = self.term.grid();
627        let history_size = grid.history_size();
628        let mut result = String::new();
629
630        // First, add scrollback history (negative line indices)
631        // History lines go from -(history_size) to -1
632        for i in (1..=history_size).rev() {
633            let line = Line(-(i as i32));
634            let row_data = &grid[line];
635            let mut line_str = String::new();
636            for col in 0..self.cols as usize {
637                line_str.push(row_data[Column(col)].c);
638            }
639            let trimmed = line_str.trim_end();
640            result.push_str(trimmed);
641            result.push('\n');
642        }
643
644        // Then add visible screen content (line indices 0 to rows-1)
645        for row in 0..self.rows {
646            let line = self.get_line(row);
647            let line_str: String = line.iter().map(|c| c.c).collect();
648            let trimmed = line_str.trim_end();
649            result.push_str(trimmed);
650            if row < self.rows - 1 {
651                result.push('\n');
652            }
653        }
654
655        result
656    }
657
658    /// Get the number of scrollback history lines
659    pub fn history_size(&self) -> usize {
660        use alacritty_terminal::grid::Dimensions;
661        self.term.grid().history_size()
662    }
663
664    /// Get the title (if set by escape sequence)
665    pub fn title(&self) -> &str {
666        &self.terminal_title
667    }
668
669    /// Set the terminal title (called when escape sequence is received)
670    pub fn set_title(&mut self, title: String) {
671        self.terminal_title = title;
672    }
673
674    /// Scroll to the bottom of the terminal (display offset = 0)
675    /// Used when re-entering terminal mode from scrollback view
676    pub fn scroll_to_bottom(&mut self) {
677        self.term.scroll_display(Scroll::Bottom);
678        self.dirty = true;
679    }
680
681    // =========================================================================
682    // Terminal mode flags
683    // =========================================================================
684
685    /// Check if the terminal is in alternate screen mode.
686    /// Programs like vim, less, htop use alternate screen.
687    pub fn is_alternate_screen(&self) -> bool {
688        self.term.mode().contains(TermMode::ALT_SCREEN)
689    }
690
691    /// Check if the terminal wants mouse events reported.
692    /// Returns true if any mouse reporting mode is enabled.
693    pub fn wants_mouse_events(&self) -> bool {
694        let mode = self.term.mode();
695        mode.intersects(
696            TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG,
697        )
698    }
699
700    /// Check if SGR mouse encoding is enabled (modern mouse protocol).
701    pub fn uses_sgr_mouse(&self) -> bool {
702        self.term.mode().contains(TermMode::SGR_MOUSE)
703    }
704
705    /// Check if alternate scroll mode is enabled.
706    /// When enabled, scroll wheel should be sent as up/down arrow keys.
707    pub fn uses_alternate_scroll(&self) -> bool {
708        self.term.mode().contains(TermMode::ALTERNATE_SCROLL)
709    }
710
711    /// Check if application cursor keys mode (DECCKM) is enabled.
712    /// Programs like less, git log set this mode so that arrow keys
713    /// send `\x1bOA` (SS3) instead of `\x1b[A` (CSI).
714    pub fn is_app_cursor(&self) -> bool {
715        self.term.mode().contains(TermMode::APP_CURSOR)
716    }
717
718    // =========================================================================
719    // Incremental scrollback streaming
720    // =========================================================================
721
722    /// Flush newly scrolled-off scrollback to the writer as complete logical
723    /// lines, returning the number of logical lines written.
724    ///
725    /// Call after `process_output()` (and before reading the backing file) to
726    /// incrementally persist scrollback. Rows that alacritty wrapped (`WRAPLINE`)
727    /// are joined into one unwrapped logical line, so the backing file stores
728    /// logical lines — the editor then soft-wraps them to whatever width the
729    /// scroll-back view happens to be, instead of being frozen at the width they
730    /// were captured. Only logical lines that have *fully* scrolled into history
731    /// are written; a trailing line still continuing into the visible screen is
732    /// left for a later flush, keeping the file on a logical-line boundary.
733    pub fn flush_new_scrollback<W: Write>(&mut self, writer: &mut W) -> io::Result<usize> {
734        use alacritty_terminal::grid::Dimensions;
735
736        let history = self.term.grid().history_size();
737        if history <= self.synced_history_lines {
738            return Ok(0);
739        }
740
741        // History rows oldest→newest map to k = 0..history via line index
742        // -(history - k); -history is oldest, -1 is newest (just above visible).
743        // Write every complete logical line past the pointer, advancing the
744        // pointer only past lines actually written — so a line is never skipped,
745        // i.e. never lost. (A grow that rewinds the boundary may re-write a
746        // bounded handful of lines; duplication is the accepted trade-off.)
747        let mut written = 0usize;
748        let mut line_start = self.synced_history_lines;
749        let mut k = self.synced_history_lines;
750        while k < history {
751            let line_idx = -((history - k) as i32);
752            if self.row_wraps(Line(line_idx)) {
753                // Logical line continues onto the next row.
754                k += 1;
755                continue;
756            }
757            // Row k ends a logical line spanning rows [line_start ..= k].
758            self.write_logical_line(writer, line_start, k, history)?;
759            written += 1;
760            self.synced_logical_lines += 1;
761            k += 1;
762            self.synced_history_lines = k;
763            line_start = k;
764        }
765        // Any rows past `synced_history_lines` form an incomplete logical line
766        // (its final row wraps into the visible screen); leave them uncommitted.
767        Ok(written)
768    }
769
770    /// Append the visible screen content to the writer as logical lines.
771    ///
772    /// Call this when exiting terminal mode (or saving a session) to add the
773    /// current screen to the backing file. Wrapped rows are joined like
774    /// `flush_new_scrollback`, but every visible row is emitted (including the
775    /// trailing logical line and blank rows) so the scroll-back viewport can
776    /// anchor to the start of this block and line up with the live PTY frame.
777    /// The block is temporary — re-entering terminal mode truncates the file
778    /// back to `backing_file_history_end`.
779    pub fn append_visible_screen<W: Write>(&self, writer: &mut W) -> io::Result<()> {
780        let rows = self.rows as i32;
781        let mut start = 0i32;
782        let mut row = 0i32;
783        while row < rows {
784            if self.row_wraps(Line(row)) && row + 1 < rows {
785                row += 1;
786                continue;
787            }
788            // `write_logical_line` indexes via the history convention, so pass
789            // visible rows through directly (offset 0 == oldest here is just row).
790            self.write_visible_logical_line(writer, start, row)?;
791            row += 1;
792            start = row;
793        }
794        Ok(())
795    }
796
797    /// True if the last cell of `line` carries the `WRAPLINE` flag, i.e. the row
798    /// is a soft-wrap continuation point (the logical line continues on the next
799    /// physical row).
800    fn row_wraps(&self, line: Line) -> bool {
801        use alacritty_terminal::term::cell::Flags;
802        if self.cols == 0 {
803            return false;
804        }
805        let grid = self.term.grid();
806        grid[line][Column(self.cols as usize - 1)]
807            .flags
808            .contains(Flags::WRAPLINE)
809    }
810
811    /// Write history rows `line_start..=line_end` (oldest-relative `k` indices,
812    /// with `history` the current history size) as one joined logical line.
813    fn write_logical_line<W: Write>(
814        &self,
815        writer: &mut W,
816        line_start: usize,
817        line_end: usize,
818        history: usize,
819    ) -> io::Result<()> {
820        let mut sgr = SgrState::default();
821        let mut out = String::with_capacity((line_end - line_start + 1) * self.cols as usize * 2);
822        for k in line_start..=line_end {
823            let line_idx = -((history - k) as i32);
824            self.append_row_cells(Line(line_idx), &mut sgr, &mut out);
825        }
826        Self::finish_logical_line(&mut out, &sgr);
827        writeln!(writer, "{}", out)
828    }
829
830    /// Write visible rows `line_start..=line_end` (0-based screen rows) as one
831    /// joined logical line.
832    fn write_visible_logical_line<W: Write>(
833        &self,
834        writer: &mut W,
835        line_start: i32,
836        line_end: i32,
837    ) -> io::Result<()> {
838        let mut sgr = SgrState::default();
839        let mut out = String::with_capacity(self.cols as usize * 2);
840        for row in line_start..=line_end {
841            self.append_row_cells(Line(row), &mut sgr, &mut out);
842        }
843        Self::finish_logical_line(&mut out, &sgr);
844        writeln!(writer, "{}", out)
845    }
846
847    /// Close out an in-progress logical line: emit a final SGR reset if any
848    /// style is active, then trim trailing blanks (color codes are preserved).
849    fn finish_logical_line(out: &mut String, sgr: &SgrState) {
850        if sgr.has_style() {
851            out.push_str("\x1b[0m");
852        }
853        let trimmed_len = out.trim_end_matches([' ', '\0']).len();
854        out.truncate(trimmed_len);
855    }
856
857    /// Append all cells of one grid row to `out`, threading the SGR state so a
858    /// joined logical line carries continuous colors across wrapped rows and
859    /// only resets once at the end. Color codes are emitted as truecolor; the
860    /// buffer renderer interprets these (see `src/primitives/ansi.rs`).
861    fn append_row_cells(&self, line: Line, sgr: &mut SgrState, out: &mut String) {
862        use alacritty_terminal::term::cell::Flags;
863
864        let grid = self.term.grid();
865        let row_data = &grid[line];
866
867        for col in 0..self.cols as usize {
868            let cell = &row_data[Column(col)];
869            let fg = color_to_rgb(&cell.fg);
870            let bg = color_to_rgb(&cell.bg);
871            let flags = cell.flags;
872            let bold = flags.contains(Flags::BOLD);
873            let italic = flags.contains(Flags::ITALIC);
874            let underline = flags.contains(Flags::UNDERLINE);
875
876            let fg_changed = fg != sgr.fg;
877            let bg_changed = bg != sgr.bg;
878            let bold_changed = bold != sgr.bold;
879            let italic_changed = italic != sgr.italic;
880            let underline_changed = underline != sgr.underline;
881
882            if fg_changed || bg_changed || bold_changed || italic_changed || underline_changed {
883                let mut codes: Vec<String> = Vec::new();
884
885                // A turned-off attribute requires a full reset + reapply.
886                if (sgr.bold && !bold) || (sgr.italic && !italic) || (sgr.underline && !underline) {
887                    codes.push("0".to_string());
888                    if bold {
889                        codes.push("1".to_string());
890                    }
891                    if italic {
892                        codes.push("3".to_string());
893                    }
894                    if underline {
895                        codes.push("4".to_string());
896                    }
897                    if let Some((r, g, b)) = fg {
898                        codes.push(format!("38;2;{};{};{}", r, g, b));
899                    }
900                    if let Some((r, g, b)) = bg {
901                        codes.push(format!("48;2;{};{};{}", r, g, b));
902                    }
903                } else {
904                    if bold_changed && bold {
905                        codes.push("1".to_string());
906                    }
907                    if italic_changed && italic {
908                        codes.push("3".to_string());
909                    }
910                    if underline_changed && underline {
911                        codes.push("4".to_string());
912                    }
913                    if fg_changed {
914                        if let Some((r, g, b)) = fg {
915                            codes.push(format!("38;2;{};{};{}", r, g, b));
916                        } else {
917                            codes.push("39".to_string());
918                        }
919                    }
920                    if bg_changed {
921                        if let Some((r, g, b)) = bg {
922                            codes.push(format!("48;2;{};{};{}", r, g, b));
923                        } else {
924                            codes.push("49".to_string());
925                        }
926                    }
927                }
928
929                if !codes.is_empty() {
930                    out.push_str(&format!("\x1b[{}m", codes.join(";")));
931                }
932
933                sgr.fg = fg;
934                sgr.bg = bg;
935                sgr.bold = bold;
936                sgr.italic = italic;
937                sgr.underline = underline;
938            }
939
940            out.push(cell.c);
941        }
942    }
943
944    /// Get the byte offset where scrollback history ends in the backing file.
945    ///
946    /// Used for truncating the file when re-entering terminal mode
947    /// (to remove the visible screen portion).
948    pub fn backing_file_history_end(&self) -> u64 {
949        self.backing_file_history_end
950    }
951
952    /// Set the byte offset where scrollback history ends.
953    ///
954    /// Call this after flushing scrollback to record the file position.
955    pub fn set_backing_file_history_end(&mut self, offset: u64) {
956        self.backing_file_history_end = offset;
957    }
958
959    /// Get the number of scrollback lines that have been synced to the backing file.
960    pub fn synced_history_lines(&self) -> usize {
961        self.synced_history_lines
962    }
963
964    /// Reset sync state (e.g., when starting fresh or after truncation).
965    pub fn reset_sync_state(&mut self) {
966        self.synced_history_lines = 0;
967        self.synced_logical_lines = 0;
968        self.pending_reflow_resync = false;
969        self.backing_file_history_end = 0;
970    }
971}
972
973/// A single cell in the terminal grid
974#[derive(Debug, Clone)]
975pub struct TerminalCell {
976    /// The character
977    pub c: char,
978    /// Foreground color as RGB
979    pub fg: Option<(u8, u8, u8)>,
980    /// Background color as RGB
981    pub bg: Option<(u8, u8, u8)>,
982    /// Bold flag
983    pub bold: bool,
984    /// Italic flag
985    pub italic: bool,
986    /// Underline flag
987    pub underline: bool,
988    /// Inverse video flag
989    pub inverse: bool,
990}
991
992impl Default for TerminalCell {
993    fn default() -> Self {
994        Self {
995            c: ' ',
996            fg: None,
997            bg: None,
998            bold: false,
999            italic: false,
1000            underline: false,
1001            inverse: false,
1002        }
1003    }
1004}
1005
1006/// Running SGR (color/attribute) state while serializing a logical line, so a
1007/// joined line carries continuous styling across wrapped rows and resets once.
1008#[derive(Default)]
1009struct SgrState {
1010    fg: Option<(u8, u8, u8)>,
1011    bg: Option<(u8, u8, u8)>,
1012    bold: bool,
1013    italic: bool,
1014    underline: bool,
1015}
1016
1017impl SgrState {
1018    fn has_style(&self) -> bool {
1019        self.fg.is_some() || self.bg.is_some() || self.bold || self.italic || self.underline
1020    }
1021}
1022
1023/// Convert alacritty color to RGB
1024fn color_to_rgb(color: &alacritty_terminal::vte::ansi::Color) -> Option<(u8, u8, u8)> {
1025    use alacritty_terminal::vte::ansi::Color;
1026
1027    match color {
1028        Color::Spec(rgb) => Some((rgb.r, rgb.g, rgb.b)),
1029        Color::Named(named) => {
1030            // Convert named colors to RGB
1031            // Using standard ANSI color palette
1032            let rgb = match named {
1033                alacritty_terminal::vte::ansi::NamedColor::Black => (0, 0, 0),
1034                alacritty_terminal::vte::ansi::NamedColor::Red => (205, 49, 49),
1035                alacritty_terminal::vte::ansi::NamedColor::Green => (13, 188, 121),
1036                alacritty_terminal::vte::ansi::NamedColor::Yellow => (229, 229, 16),
1037                alacritty_terminal::vte::ansi::NamedColor::Blue => (36, 114, 200),
1038                alacritty_terminal::vte::ansi::NamedColor::Magenta => (188, 63, 188),
1039                alacritty_terminal::vte::ansi::NamedColor::Cyan => (17, 168, 205),
1040                alacritty_terminal::vte::ansi::NamedColor::White => (229, 229, 229),
1041                alacritty_terminal::vte::ansi::NamedColor::BrightBlack => (102, 102, 102),
1042                alacritty_terminal::vte::ansi::NamedColor::BrightRed => (241, 76, 76),
1043                alacritty_terminal::vte::ansi::NamedColor::BrightGreen => (35, 209, 139),
1044                alacritty_terminal::vte::ansi::NamedColor::BrightYellow => (245, 245, 67),
1045                alacritty_terminal::vte::ansi::NamedColor::BrightBlue => (59, 142, 234),
1046                alacritty_terminal::vte::ansi::NamedColor::BrightMagenta => (214, 112, 214),
1047                alacritty_terminal::vte::ansi::NamedColor::BrightCyan => (41, 184, 219),
1048                alacritty_terminal::vte::ansi::NamedColor::BrightWhite => (255, 255, 255),
1049                alacritty_terminal::vte::ansi::NamedColor::Foreground => return None,
1050                alacritty_terminal::vte::ansi::NamedColor::Background => return None,
1051                alacritty_terminal::vte::ansi::NamedColor::Cursor => return None,
1052                _ => return None,
1053            };
1054            Some(rgb)
1055        }
1056        Color::Indexed(idx) => {
1057            // Convert 256-color index to RGB
1058            // Standard 256-color palette
1059            let idx = *idx as usize;
1060            if idx < 16 {
1061                // Standard colors (same as named)
1062                let colors = [
1063                    (0, 0, 0),       // Black
1064                    (205, 49, 49),   // Red
1065                    (13, 188, 121),  // Green
1066                    (229, 229, 16),  // Yellow
1067                    (36, 114, 200),  // Blue
1068                    (188, 63, 188),  // Magenta
1069                    (17, 168, 205),  // Cyan
1070                    (229, 229, 229), // White
1071                    (102, 102, 102), // Bright Black
1072                    (241, 76, 76),   // Bright Red
1073                    (35, 209, 139),  // Bright Green
1074                    (245, 245, 67),  // Bright Yellow
1075                    (59, 142, 234),  // Bright Blue
1076                    (214, 112, 214), // Bright Magenta
1077                    (41, 184, 219),  // Bright Cyan
1078                    (255, 255, 255), // Bright White
1079                ];
1080                Some(colors[idx])
1081            } else if idx < 232 {
1082                // 216 color cube (6x6x6)
1083                let idx = idx - 16;
1084                let r = (idx / 36) * 51;
1085                let g = ((idx / 6) % 6) * 51;
1086                let b = (idx % 6) * 51;
1087                Some((r as u8, g as u8, b as u8))
1088            } else {
1089                // 24 grayscale colors
1090                let gray = (idx - 232) * 10 + 8;
1091                Some((gray as u8, gray as u8, gray as u8))
1092            }
1093        }
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    #[test]
1102    fn test_terminal_state_new() {
1103        let state = TerminalState::new(80, 24);
1104        assert_eq!(state.size(), (80, 24));
1105        assert!(state.is_dirty());
1106    }
1107
1108    #[test]
1109    fn test_terminal_process_output() {
1110        let mut state = TerminalState::new(80, 24);
1111        state.process_output(b"Hello, World!");
1112        let content = state.content_string();
1113        assert!(content.contains("Hello, World!"));
1114    }
1115
1116    #[test]
1117    fn test_terminal_resize() {
1118        let mut state = TerminalState::new(80, 24);
1119        state.mark_clean();
1120        assert!(!state.is_dirty());
1121
1122        state.resize(100, 30);
1123        assert_eq!(state.size(), (100, 30));
1124        assert!(state.is_dirty());
1125    }
1126
1127    /// Resize re-anchors `synced_history_lines` to the reflowed grid so the
1128    /// incremental streamer can't lose/duplicate lines afterwards.
1129    #[test]
1130    fn test_resize_reanchors_synced_history() {
1131        let mut state = TerminalState::new(80, 24);
1132        for i in 0..200 {
1133            state.process_output(format!("line {i}\r\n").as_bytes());
1134        }
1135        // Drain into the backing-file mirror (a Vec sink).
1136        let mut sink: Vec<u8> = Vec::new();
1137        state.flush_new_scrollback(&mut sink).unwrap();
1138        assert_eq!(state.synced_history_lines(), state.history_size());
1139
1140        // Widen: reflow shrinks history; counter must follow, not stay stale.
1141        state.resize(200, 24);
1142        assert_eq!(state.synced_history_lines(), state.history_size());
1143        // No phantom "new" lines to flush right after a resize.
1144        let mut after: Vec<u8> = Vec::new();
1145        assert_eq!(state.flush_new_scrollback(&mut after).unwrap(), 0);
1146    }
1147
1148    /// A pure height *shrink* (cols unchanged) pushes the top visible rows into
1149    /// scrollback. Those rows are genuinely new history, so the counter must
1150    /// stay low enough that the next flush writes them — they must not be
1151    /// dropped. Guards against re-anchoring `synced` on every resize.
1152    #[test]
1153    fn test_height_shrink_streams_spilled_rows() {
1154        let mut state = TerminalState::new(80, 24);
1155        // Fill the screen (no scroll-off yet) with identifiable rows.
1156        for i in 0..24 {
1157            state.process_output(format!("row{i:02}\r\n").as_bytes());
1158        }
1159        let mut sink: Vec<u8> = Vec::new();
1160        state.flush_new_scrollback(&mut sink).unwrap();
1161        let before = state.synced_history_lines();
1162
1163        // Shrink height only — alacritty pushes the top rows into history.
1164        state.resize(80, 10);
1165        assert!(
1166            state.history_size() > before,
1167            "shrink should push rows into history"
1168        );
1169        // The spilled rows are new content and must be flushed (not skipped).
1170        let mut spill: Vec<u8> = Vec::new();
1171        let written = state.flush_new_scrollback(&mut spill).unwrap();
1172        assert!(written > 0, "spilled rows must be streamed, got {written}");
1173    }
1174
1175    /// A pure height *grow* (cols unchanged) pulls rows from scrollback back
1176    /// onto the screen. Those rows are already in the backing file, so when
1177    /// they later scroll off again they must not be streamed a second time.
1178    #[test]
1179    fn test_height_grow_does_not_reflow_duplicate() {
1180        let mut state = TerminalState::new(80, 24);
1181        for i in 0..100 {
1182            state.process_output(format!("line {i}\r\n").as_bytes());
1183        }
1184        let mut sink: Vec<u8> = Vec::new();
1185        state.flush_new_scrollback(&mut sink).unwrap();
1186        let synced_before = state.synced_history_lines();
1187
1188        // Grow height only: pulls rows from history back onto the screen.
1189        state.resize(80, 40);
1190        // Counter is left untouched; the flush guard suppresses the pulled rows.
1191        assert_eq!(state.synced_history_lines(), synced_before);
1192        let mut after: Vec<u8> = Vec::new();
1193        assert_eq!(
1194            state.flush_new_scrollback(&mut after).unwrap(),
1195            0,
1196            "growing height must not re-stream rows already in the backing file"
1197        );
1198    }
1199
1200    // ---- #5 logical-line capture -------------------------------------------
1201
1202    /// Min/max occurrences of each marker `L{i:05}#` for i in 0..n across the
1203    /// full captured record `text` (everything streamed plus the final screen).
1204    fn marker_counts(text: &str, n: usize) -> (usize, usize) {
1205        let mut min = usize::MAX;
1206        let mut max = 0;
1207        for i in 0..n {
1208            let c = text.matches(&format!("L{i:05}#")).count();
1209            min = min.min(c);
1210            max = max.max(c);
1211        }
1212        (min, max)
1213    }
1214
1215    /// A wrapped line is stored as ONE unwrapped logical line in the backing
1216    /// file (not hard-split at the capture width), so the editor can re-wrap it.
1217    #[test]
1218    fn test_wrapped_line_stored_as_single_logical_line() {
1219        let mut state = TerminalState::new(40, 24);
1220        // ~100 chars at width 40 → wraps to 3 physical rows.
1221        let long = "X".repeat(100);
1222        state.process_output(format!("{long}\r\n").as_bytes());
1223        // Scroll it off the screen.
1224        for _ in 0..24 {
1225            state.process_output(b"y\r\n");
1226        }
1227        let mut sink: Vec<u8> = Vec::new();
1228        state.flush_new_scrollback(&mut sink).unwrap();
1229        let text = String::from_utf8_lossy(&sink);
1230        let xline = text.lines().find(|l| l.contains("XXXX")).unwrap();
1231        assert_eq!(
1232            xline.chars().filter(|&c| c == 'X').count(),
1233            100,
1234            "the wrapped line must be rejoined into one 100-char logical line"
1235        );
1236    }
1237
1238    /// The headline scenario: lots of scrollback, then MANY resizes (including
1239    /// simultaneous width+height changes) with no viewing in between, then a
1240    /// final capture. Not a single logical line may be lost.
1241    #[test]
1242    fn test_no_scrollback_lost_across_many_mixed_resizes() {
1243        let mut state = TerminalState::new(80, 24);
1244        let n = 500;
1245        let mut sink: Vec<u8> = Vec::new();
1246        // Emit in batches, flushing after each (as the PTY read loop would),
1247        // and resize between batches — width, height, and both at once.
1248        let sizes = [
1249            (120u16, 24u16),
1250            (60, 30),
1251            (200, 18),
1252            (90, 40),
1253            (50, 22),
1254            (160, 50),
1255            (70, 20),
1256        ];
1257        for b in 0..n / 20 {
1258            for i in 0..20 {
1259                let idx = b * 20 + i;
1260                // Mix in lines long enough to wrap at the narrow widths.
1261                let pad = "=".repeat((idx % 90) + 5);
1262                state.process_output(format!("L{idx:05}# {pad}\r\n").as_bytes());
1263            }
1264            state.flush_new_scrollback(&mut sink).unwrap();
1265            let (w, h) = sizes[b % sizes.len()];
1266            state.resize(w, h);
1267        }
1268        // Capture the residual scrollback + visible screen into the same stream
1269        // a viewer/session-save would read.
1270        state.flush_new_scrollback(&mut sink).unwrap();
1271        state.append_visible_screen(&mut sink).unwrap();
1272        let text = String::from_utf8_lossy(&sink);
1273
1274        let (min, max) = marker_counts(&text, n);
1275        // PRIMARY GOAL: never lose a scrollback line, no matter the resizes.
1276        assert!(
1277            min >= 1,
1278            "lost scrollback line(s): some marker missing (min={min})"
1279        );
1280        // Duplication is a tolerated last resort (a grow can overlap the visible
1281        // tail with committed history) but must stay bounded by the screen height,
1282        // never unbounded growth.
1283        assert!(max <= 3, "excessive duplication (max={max})");
1284    }
1285
1286    /// `clear` (ESC[3J clears scrollback) must not stall capture: lines printed
1287    /// afterwards have to keep landing in the backing file, appended after the
1288    /// scrollback that was already committed.
1289    #[test]
1290    fn test_clear_scrollback_resumes_capture() {
1291        let mut state = TerminalState::new(80, 24);
1292        let mut sink: Vec<u8> = Vec::new();
1293        for i in 0..100 {
1294            state.process_output(format!("OLD{i:04}#\r\n").as_bytes());
1295        }
1296        state.flush_new_scrollback(&mut sink).unwrap();
1297        assert!(state.synced_logical_lines > 0);
1298
1299        // Clear scrollback (what `clear` emits), then print more.
1300        state.process_output(b"\x1b[3J\x1b[H\x1b[2J");
1301        for i in 0..100 {
1302            state.process_output(format!("NEW{i:04}#\r\n").as_bytes());
1303        }
1304        state.flush_new_scrollback(&mut sink).unwrap();
1305        state.append_visible_screen(&mut sink).unwrap();
1306
1307        let text = String::from_utf8_lossy(&sink);
1308        // Old scrollback preserved, AND post-clear output captured (the bug was
1309        // post-clear output being silently dropped).
1310        assert!(text.contains("OLD0000#"), "pre-clear scrollback lost");
1311        assert!(text.contains("NEW0000#"), "post-clear output dropped");
1312        assert!(text.contains("NEW0090#"), "later post-clear output dropped");
1313    }
1314
1315    /// Entering/leaving the alternate screen (vim, less, htop) reports zero
1316    /// history transiently; it must not be mistaken for a clear, nor cause the
1317    /// pre-alt-screen scrollback to be re-emitted on exit.
1318    #[test]
1319    fn test_alt_screen_roundtrip_no_duplicate() {
1320        let mut state = TerminalState::new(80, 24);
1321        let mut sink: Vec<u8> = Vec::new();
1322        for i in 0..100 {
1323            state.process_output(format!("BASE{i:04}#\r\n").as_bytes());
1324        }
1325        state.flush_new_scrollback(&mut sink).unwrap();
1326
1327        // Enter alt screen, draw, leave alt screen.
1328        state.process_output(b"\x1b[?1049h");
1329        state.process_output(b"full screen app drawing\r\nmore\r\n");
1330        state.process_output(b"\x1b[?1049l");
1331        // A couple of new real lines after returning.
1332        for i in 0..5 {
1333            state.process_output(format!("AFTER{i:04}#\r\n").as_bytes());
1334        }
1335        state.flush_new_scrollback(&mut sink).unwrap();
1336        state.append_visible_screen(&mut sink).unwrap();
1337
1338        let text = String::from_utf8_lossy(&sink);
1339        // No base line duplicated by the alt-screen round trip.
1340        for i in 0..100 {
1341            assert!(
1342                text.matches(&format!("BASE{i:04}#")).count() <= 1,
1343                "alt-screen round trip duplicated BASE{i:04}"
1344            );
1345        }
1346        assert!(
1347            text.contains("AFTER0000#"),
1348            "post-alt-screen output dropped"
1349        );
1350    }
1351
1352    /// Resizing the width *while* the alternate screen is up reflows the hidden
1353    /// primary grid; the re-anchor is deferred to alt-screen exit. Afterwards,
1354    /// new output must still be captured (no loss) and the pre-alt scrollback
1355    /// must not be wholesale re-written.
1356    #[test]
1357    fn test_resize_during_alt_screen_then_capture() {
1358        let mut state = TerminalState::new(80, 24);
1359        let mut sink: Vec<u8> = Vec::new();
1360        for i in 0..150 {
1361            // Lines long enough to wrap differently across the resize.
1362            let pad = "=".repeat(60);
1363            state.process_output(format!("PRE{i:04}# {pad}\r\n").as_bytes());
1364        }
1365        state.flush_new_scrollback(&mut sink).unwrap();
1366
1367        // Enter alt screen, resize width (reflows primary underneath), exit.
1368        state.process_output(b"\x1b[?1049h");
1369        state.resize(40, 24);
1370        state.resize(120, 24);
1371        state.process_output(b"\x1b[?1049l");
1372        for i in 0..150 {
1373            state.process_output(format!("POST{i:04}# x\r\n").as_bytes());
1374        }
1375        state.flush_new_scrollback(&mut sink).unwrap();
1376        state.append_visible_screen(&mut sink).unwrap();
1377
1378        let text = String::from_utf8_lossy(&sink);
1379        // Post-alt output fully captured (the deferred re-anchor must not skip).
1380        for i in 0..150 {
1381            assert!(
1382                text.contains(&format!("POST{i:04}#")),
1383                "post-alt output lost POST{i:04}"
1384            );
1385        }
1386        // Pre-alt scrollback preserved and not duplicated en masse.
1387        for i in 0..150 {
1388            assert!(
1389                text.matches(&format!("PRE{i:04}#")).count() <= 2,
1390                "pre-alt scrollback duplicated PRE{i:04}"
1391            );
1392        }
1393        assert!(text.contains("PRE0000#"), "pre-alt scrollback lost");
1394    }
1395
1396    /// `last_visible_line` returns the text on the cursor row, with
1397    /// the alacritty right-edge padding trimmed. This is the payload
1398    /// the `terminal_output` plugin hook surfaces to the Orchestrator
1399    /// state machine for prompt detection.
1400    #[test]
1401    fn test_last_visible_line_returns_cursor_row() {
1402        let mut state = TerminalState::new(80, 24);
1403        state.process_output(b"hello\r\nworld");
1404        // Cursor is now on the second line after writing "world".
1405        assert_eq!(state.last_visible_line(), "world");
1406    }
1407
1408    /// Empty cells past the visible run are stripped, but a single
1409    /// trailing space typed by the program (typical for prompts like
1410    /// `"(Y/n): "`) is preserved.
1411    #[test]
1412    fn test_last_visible_line_preserves_prompt_trailing_space() {
1413        let mut state = TerminalState::new(80, 24);
1414        state.process_output(b"Continue? (Y/n): ");
1415        // The literal trailing space is real prompt text, not grid
1416        // padding past the cursor, so it must survive.
1417        assert_eq!(state.last_visible_line(), "Continue? (Y/n): ");
1418    }
1419
1420    /// A row that has only ever been the right-edge padding renders
1421    /// as the empty string, not 80 spaces.
1422    #[test]
1423    fn test_last_visible_line_blank_row_is_empty() {
1424        let state = TerminalState::new(80, 24);
1425        assert_eq!(state.last_visible_line(), "");
1426    }
1427
1428    #[test]
1429    fn test_flush_new_scrollback_no_history() {
1430        // When there's no scrollback history, flush should return 0
1431        let mut state = TerminalState::new(80, 24);
1432        state.process_output(b"Hello");
1433
1434        let mut buffer = Vec::new();
1435        let count = state.flush_new_scrollback(&mut buffer).unwrap();
1436
1437        assert_eq!(count, 0, "No scrollback yet, should flush 0 lines");
1438        assert!(buffer.is_empty(), "Buffer should be empty");
1439    }
1440
1441    #[test]
1442    fn test_flush_new_scrollback_after_scroll() {
1443        // Generate enough output to create scrollback
1444        let mut state = TerminalState::new(80, 10); // Small terminal to trigger scrollback quickly
1445
1446        // Generate output that exceeds the terminal height
1447        for i in 1..=20 {
1448            state.process_output(format!("Line {}\r\n", i).as_bytes());
1449        }
1450
1451        let mut buffer = Vec::new();
1452        let count = state.flush_new_scrollback(&mut buffer).unwrap();
1453
1454        // Should have some scrollback lines
1455        let output = String::from_utf8_lossy(&buffer);
1456        eprintln!(
1457            "Scrollback test: count={}, synced={}, buffer_len={}, output:\n{}",
1458            count,
1459            state.synced_history_lines(),
1460            buffer.len(),
1461            output
1462        );
1463
1464        // The first lines should have scrolled off
1465        assert!(count > 0, "Should have some scrollback lines");
1466        assert!(
1467            output.contains("Line 1"),
1468            "Scrollback should contain Line 1"
1469        );
1470    }
1471
1472    #[test]
1473    fn test_append_visible_screen() {
1474        let mut state = TerminalState::new(80, 5);
1475        state.process_output(b"Line A\r\nLine B\r\nLine C\r\n");
1476
1477        let mut buffer = Vec::new();
1478        state.append_visible_screen(&mut buffer).unwrap();
1479
1480        let output = String::from_utf8_lossy(&buffer);
1481        assert!(
1482            output.contains("Line A"),
1483            "Visible screen should contain Line A"
1484        );
1485        assert!(
1486            output.contains("Line B"),
1487            "Visible screen should contain Line B"
1488        );
1489        assert!(
1490            output.contains("Line C"),
1491            "Visible screen should contain Line C"
1492        );
1493    }
1494
1495    #[test]
1496    fn test_scrollback_then_visible_no_duplication() {
1497        // Test the full flow: scrollback lines + visible screen should not duplicate
1498        let mut state = TerminalState::new(80, 5); // Small terminal
1499
1500        // Generate output that creates scrollback
1501        // Use unique markers that won't accidentally match each other
1502        for i in 1..=15 {
1503            state.process_output(format!("UNIQUELINE_{:02}\r\n", i).as_bytes());
1504        }
1505
1506        // Flush scrollback
1507        let mut scrollback_buffer = Vec::new();
1508        let scrollback_count = state.flush_new_scrollback(&mut scrollback_buffer).unwrap();
1509        let scrollback_output = String::from_utf8_lossy(&scrollback_buffer);
1510
1511        // Append visible screen
1512        let mut visible_buffer = Vec::new();
1513        state.append_visible_screen(&mut visible_buffer).unwrap();
1514        let visible_output = String::from_utf8_lossy(&visible_buffer);
1515
1516        eprintln!(
1517            "Scrollback ({} lines):\n{}",
1518            scrollback_count, scrollback_output
1519        );
1520        eprintln!("Visible screen:\n{}", visible_output);
1521
1522        // Combined output should have each line exactly once
1523        let combined = format!("{}{}", scrollback_output, visible_output);
1524
1525        // Count occurrences of each line
1526        for i in 1..=15 {
1527            let pattern = format!("UNIQUELINE_{:02}", i);
1528            let count = combined.matches(&pattern).count();
1529            assert!(
1530                count >= 1,
1531                "Line {} should appear at least once, but found {} times",
1532                i,
1533                count
1534            );
1535            // Allow for some overlap at boundaries, but not excessive duplication
1536            assert!(
1537                count <= 2,
1538                "Line {} appears {} times - too much duplication",
1539                i,
1540                count
1541            );
1542        }
1543    }
1544
1545    #[test]
1546    fn test_backing_file_history_end_tracking() {
1547        let mut state = TerminalState::new(80, 5);
1548
1549        // Initially should be 0
1550        assert_eq!(state.backing_file_history_end(), 0);
1551
1552        // Set it
1553        state.set_backing_file_history_end(1234);
1554        assert_eq!(state.backing_file_history_end(), 1234);
1555
1556        // Reset should clear it
1557        state.reset_sync_state();
1558        assert_eq!(state.backing_file_history_end(), 0);
1559        assert_eq!(state.synced_history_lines(), 0);
1560    }
1561
1562    #[test]
1563    fn test_multiple_flush_cycles_no_duplication() {
1564        use alacritty_terminal::grid::Dimensions;
1565
1566        // Simulate multiple enter/exit terminal mode cycles
1567        let mut state = TerminalState::new(80, 5);
1568
1569        // First batch of output (10 lines in 5-row terminal)
1570        // Lines 1-6 scroll into history, lines 7-10 are visible
1571        for i in 1..=10 {
1572            state.process_output(format!("Batch1-Line{}\r\n", i).as_bytes());
1573        }
1574
1575        let history1 = state.term.grid().history_size();
1576        eprintln!("After Batch1: history_size={}", history1);
1577        assert_eq!(
1578            history1, 6,
1579            "After 10 lines in 5-row terminal, 6 should be in history"
1580        );
1581
1582        // First flush - should get lines 1-6
1583        let mut buffer1 = Vec::new();
1584        let count1 = state.flush_new_scrollback(&mut buffer1).unwrap();
1585        let output1 = String::from_utf8_lossy(&buffer1);
1586        eprintln!("First flush: {} lines\n{}", count1, output1);
1587
1588        assert_eq!(count1, 6);
1589        assert!(output1.contains("Batch1-Line1"));
1590        assert!(output1.contains("Batch1-Line6"));
1591        assert!(
1592            !output1.contains("Batch1-Line7"),
1593            "Line 7 should still be visible, not in scrollback"
1594        );
1595
1596        // Second flush without new output should return 0
1597        let mut buffer2 = Vec::new();
1598        let count2 = state.flush_new_scrollback(&mut buffer2).unwrap();
1599        assert_eq!(count2, 0, "Second flush without new output should be 0");
1600
1601        // More output (10 more lines)
1602        // This pushes Batch1-Line7-10 into history, plus Batch2-Line1-6
1603        for i in 1..=10 {
1604            state.process_output(format!("Batch2-Line{}\r\n", i).as_bytes());
1605        }
1606
1607        let history3 = state.term.grid().history_size();
1608        eprintln!("After Batch2: history_size={}", history3);
1609
1610        // Third flush should get lines that scrolled off since last flush
1611        // That's Batch1-Line7-10 (4 lines) + Batch2-Line1-6 (6 lines) = 10 lines
1612        let mut buffer3 = Vec::new();
1613        let count3 = state.flush_new_scrollback(&mut buffer3).unwrap();
1614        let output3 = String::from_utf8_lossy(&buffer3);
1615        eprintln!("Third flush: {} lines\n{}", count3, output3);
1616
1617        assert_eq!(count3, 10, "Should flush 10 new lines");
1618        // Should include Batch1 lines 7-10 (they weren't flushed before, were still visible)
1619        assert!(
1620            output3.contains("Batch1-Line7"),
1621            "Batch1-Line7 should be in third flush (was visible, now scrolled)"
1622        );
1623        assert!(output3.contains("Batch1-Line10"));
1624        // Should include Batch2 lines 1-6 (new content that scrolled off)
1625        assert!(output3.contains("Batch2-Line1"));
1626        assert!(output3.contains("Batch2-Line6"));
1627        // Should NOT include Batch1-Line1-6 (already flushed)
1628        assert!(
1629            !output3.contains("Batch1-Line1\n"),
1630            "Batch1-Line1 was already flushed, shouldn't appear again"
1631        );
1632        assert!(
1633            !output3.contains("Batch1-Line6\n"),
1634            "Batch1-Line6 was already flushed, shouldn't appear again"
1635        );
1636    }
1637
1638    #[test]
1639    fn test_dsr_cursor_position_response() {
1640        // Test that sending a DSR (Device Status Report) query generates a response
1641        // This is critical for Windows ConPTY where PowerShell waits for this response
1642        let mut state = TerminalState::new(80, 24);
1643
1644        // Initially the write queue should be empty
1645        assert!(
1646            state.drain_pty_write_queue().is_empty(),
1647            "Write queue should be empty initially"
1648        );
1649
1650        // Send DSR query: ESC [ 6 n (request cursor position)
1651        state.process_output(b"\x1b[6n");
1652
1653        // The terminal should generate a response: ESC [ row ; col R
1654        let responses = state.drain_pty_write_queue();
1655        assert_eq!(responses.len(), 1, "Should have exactly one response");
1656
1657        let response = &responses[0];
1658        // Response format: \x1b[row;colR where row and col are 1-based
1659        // Cursor starts at (0,0) internally, so response should be \x1b[1;1R
1660        assert!(
1661            response.starts_with("\x1b["),
1662            "Response should start with ESC["
1663        );
1664        assert!(response.ends_with("R"), "Response should end with R");
1665        eprintln!("DSR response: {:?}", response);
1666
1667        // Draining again should return empty
1668        assert!(
1669            state.drain_pty_write_queue().is_empty(),
1670            "Write queue should be empty after draining"
1671        );
1672    }
1673
1674    #[test]
1675    fn test_dsr_response_after_cursor_move() {
1676        // Test DSR response reflects actual cursor position
1677        let mut state = TerminalState::new(80, 24);
1678
1679        // Move cursor to row 5, column 10 using CUP (Cursor Position)
1680        // ESC [ 5 ; 10 H
1681        state.process_output(b"\x1b[5;10H");
1682
1683        // Request cursor position
1684        state.process_output(b"\x1b[6n");
1685
1686        let responses = state.drain_pty_write_queue();
1687        assert_eq!(responses.len(), 1);
1688
1689        let response = &responses[0];
1690        // Should report position as row 5, col 10
1691        assert_eq!(response, "\x1b[5;10R", "Response should be \\x1b[5;10R");
1692    }
1693
1694    /// OSC 2 ("set window title") drives the stored terminal title so the
1695    /// buffer's tab can auto-adjust to whatever the program requested.
1696    #[test]
1697    fn test_osc_set_window_title() {
1698        let mut state = TerminalState::new(80, 24);
1699        assert_eq!(state.title(), "");
1700        // ESC ] 2 ; <title> BEL
1701        state.process_output(b"\x1b]2;my-shell: ~/project\x07");
1702        assert_eq!(state.title(), "my-shell: ~/project");
1703    }
1704
1705    /// OSC 0 sets both the icon name and the window title; we treat it the
1706    /// same as OSC 2 for the buffer title.
1707    #[test]
1708    fn test_osc_set_icon_and_window_title() {
1709        let mut state = TerminalState::new(80, 24);
1710        state.process_output(b"\x1b]0;vim README.md\x07");
1711        assert_eq!(state.title(), "vim README.md");
1712    }
1713
1714    /// A later OSC title overrides an earlier one, and the title can arrive
1715    /// in the same chunk as other output.
1716    #[test]
1717    fn test_osc_title_updates_and_mixes_with_output() {
1718        let mut state = TerminalState::new(80, 24);
1719        state.process_output(b"\x1b]2;first\x07hello");
1720        assert_eq!(state.title(), "first");
1721        state.process_output(b"world\x1b]2;second\x07");
1722        assert_eq!(state.title(), "second");
1723        // The printable bytes still landed on the grid.
1724        assert!(state.content_string().contains("helloworld"));
1725    }
1726
1727    /// OSC 7 with a `file://host/path` payload, BEL-terminated, updates the
1728    /// tracked cwd. The emulator still drops the sequence (no stray output).
1729    #[test]
1730    fn test_osc7_sets_cwd_bel_terminated() {
1731        let mut state = TerminalState::new(80, 24);
1732        assert_eq!(state.cwd(), None);
1733        state.process_output(b"\x1b]7;file://myhost/home/user/project\x07ok");
1734        assert_eq!(
1735            state.cwd(),
1736            Some(std::path::Path::new("/home/user/project"))
1737        );
1738        // The sequence itself must not leak onto the grid; only "ok" prints.
1739        let content = state.content_string();
1740        assert!(content.contains("ok"));
1741        assert!(!content.contains("file://"));
1742    }
1743
1744    /// OSC 7 terminated by ST (`ESC \`) is recognized too.
1745    #[test]
1746    fn test_osc7_st_terminated() {
1747        let mut state = TerminalState::new(80, 24);
1748        state.process_output(b"\x1b]7;file://host/var/log\x1b\\");
1749        assert_eq!(state.cwd(), Some(std::path::Path::new("/var/log")));
1750    }
1751
1752    /// Percent-escapes in the OSC 7 path are decoded (spaces, etc.).
1753    #[test]
1754    fn test_osc7_percent_decoded() {
1755        let mut state = TerminalState::new(80, 24);
1756        state.process_output(b"\x1b]7;file://host/home/user/my%20dir\x07");
1757        assert_eq!(state.cwd(), Some(std::path::Path::new("/home/user/my dir")));
1758    }
1759
1760    /// An OSC 7 sequence split across two PTY reads is still captured — the
1761    /// scanner state persists between `process_output` calls.
1762    #[test]
1763    fn test_osc7_split_across_reads() {
1764        let mut state = TerminalState::new(80, 24);
1765        state.process_output(b"\x1b]7;file://host/home/u");
1766        assert_eq!(state.cwd(), None);
1767        state.process_output(b"ser/split\x07");
1768        assert_eq!(state.cwd(), Some(std::path::Path::new("/home/user/split")));
1769    }
1770
1771    /// A later OSC 7 overrides an earlier one (tracks `cd`).
1772    #[test]
1773    fn test_osc7_updates_on_cd() {
1774        let mut state = TerminalState::new(80, 24);
1775        state.process_output(b"\x1b]7;file://host/first\x07");
1776        assert_eq!(state.cwd(), Some(std::path::Path::new("/first")));
1777        state.process_output(b"\x1b]7;file://host/second/dir\x07");
1778        assert_eq!(state.cwd(), Some(std::path::Path::new("/second/dir")));
1779    }
1780
1781    /// A Windows `file:///C:/dir` URI is parsed to a drive-absolute path. The
1782    /// leading-slash strip and drive acceptance are host-OS-independent (a
1783    /// remote Windows shell can report this while Fresh runs on Linux), so this
1784    /// is asserted on every platform.
1785    #[test]
1786    fn test_osc7_windows_drive_path() {
1787        let mut state = TerminalState::new(80, 24);
1788        state.process_output(b"\x1b]7;file:///C:/Users/me/proj\x07");
1789        assert_eq!(state.cwd(), Some(std::path::Path::new("C:/Users/me/proj")));
1790    }
1791
1792    /// A bare (non-`file://`) absolute path payload is accepted as a fallback.
1793    #[test]
1794    fn test_osc7_bare_path_fallback() {
1795        let mut state = TerminalState::new(80, 24);
1796        state.process_output(b"\x1b]7;/opt/work\x07");
1797        assert_eq!(state.cwd(), Some(std::path::Path::new("/opt/work")));
1798    }
1799
1800    /// A relative or empty payload is rejected (cwd stays unchanged).
1801    #[test]
1802    fn test_osc7_rejects_relative() {
1803        let mut state = TerminalState::new(80, 24);
1804        state.process_output(b"\x1b]7;file://host/good\x07");
1805        state.process_output(b"\x1b]7;relative/path\x07");
1806        // The relative payload is ignored; the previous valid cwd is kept.
1807        assert_eq!(state.cwd(), Some(std::path::Path::new("/good")));
1808    }
1809}