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}