ailloli_ui_terminal_core/
lib.rs

1//! Pure terminal parsing, state, diagnostics, snapshot, and replay model.
2//!
3//! The crate incrementally parses a supported VT subset into an inspectable grid,
4//! tracks shell/command metadata, classifies common output diagnostics, and
5//! produces bounded agent-facing snapshots. It intentionally performs no PTY or
6//! process I/O, clipboard I/O, rendering, window integration, or application-
7//! specific orchestration.
8//!
9//! Constructors establish nonzero rectangular grids and bounded scrollback, but
10//! most model fields are public and several types deserialize without validation.
11//! Treat untrusted serialized state as data to validate before mutation. Snapshot
12//! redaction is best-effort and has explicitly documented coverage gaps.
13//!
14//! # Examples
15//!
16//! ```
17//! use ailloli_ui_terminal_core::{TerminalParser, TerminalState, VteTerminalParser};
18//!
19//! let mut state = TerminalState::new();
20//! let mut parser = VteTerminalParser::new();
21//! parser.advance(&mut state, b"hello \x1b[31mworld");
22//! assert!(state.screen.lines[0].plain_text().starts_with("hello world"));
23//! ```
24
25pub mod cell;
26pub mod cursor;
27pub mod damage;
28pub mod diagnostics;
29pub mod hyperlink;
30pub mod line;
31pub mod mode;
32pub mod parser;
33pub mod screen;
34pub mod scrollback;
35pub mod security;
36pub mod shell;
37pub mod size;
38pub mod snapshot;
39pub mod state;
40pub mod style;
41pub mod warning;
42
43pub use cell::{CellWidth, TerminalCell};
44pub use cursor::{TerminalCursor, TerminalCursorShape};
45pub use damage::TerminalDamage;
46pub use diagnostics::{
47    terminal_visual_line_global_indices, TerminalDiagnostic, TerminalDiagnosticEvent,
48    TerminalDiagnosticId, TerminalDiagnosticKind, TerminalDiagnosticLink,
49    TerminalDiagnosticSeverity, TerminalFileLocation, TerminalOutputClassification,
50    TerminalOutputClassifier, TerminalSourceRange,
51};
52pub use hyperlink::{TerminalHyperlink, TerminalHyperlinkId};
53pub use line::TerminalLine;
54pub use mode::{TerminalModes, TerminalMouseTrackingMode};
55pub use parser::{TerminalParser, VteTerminalParser};
56pub use screen::TerminalScreen;
57pub use scrollback::TerminalScrollback;
58pub use security::TerminalSecurityPolicy;
59pub use shell::{
60    shell_integration_script, CommandExecution, CommandId, CommandOutputRange, CommandStatus,
61    PromptDetector, ShellEvent, ShellExecutionState, ShellKind, TerminalProcessStatus,
62    TerminalShellSnapshot,
63};
64pub use size::TerminalSize;
65pub use snapshot::{
66    CommandSummary, TerminalEventKind, TerminalEventLog, TerminalEventRecord,
67    TerminalRedactionPolicy, TerminalRedactionRule, TerminalReplayResult, TerminalSnapshot,
68    TerminalSnapshotCell, TerminalSnapshotConfig, TerminalSnapshotCursor, TerminalSnapshotLine,
69};
70pub use state::{ActiveScreen, TerminalConfig, TerminalResizePolicy, TerminalState};
71pub use style::{TerminalColor, TerminalStyle};
72pub use warning::{TerminalWarning, TerminalWarningKind};
73
74#[cfg(test)]
75/// Cross-module tests for construction, mutation, reflow, snapshots, and policy defaults.
76mod tests {
77    use super::*;
78
79    #[test]
80    fn default_state_is_80_by_24_blank_with_cursor_origin() {
81        let state = TerminalState::new();
82
83        assert_eq!(state.screen.rows, 24);
84        assert_eq!(state.screen.cols, 80);
85        assert_eq!(state.alternate_screen.rows, 24);
86        assert_eq!(state.alternate_screen.cols, 80);
87        assert_eq!(state.cursor.row, 0);
88        assert_eq!(state.cursor.col, 0);
89        assert_eq!(state.active_screen, ActiveScreen::Normal);
90        assert_eq!(state.screen.cell(0, 0).expect("cell").text, " ");
91        assert_eq!(
92            state.screen.cell(23, 79).expect("cell").width,
93            CellWidth::Narrow
94        );
95    }
96
97    #[test]
98    fn terminal_size_clamps_zero_without_panic() {
99        let size = TerminalSize::new(0, 0);
100        assert_eq!(size.rows, 1);
101        assert_eq!(size.cols, 1);
102
103        let state = TerminalState::with_config(TerminalConfig {
104            size,
105            scrollback_limit: 0,
106            security: TerminalSecurityPolicy::default(),
107        });
108        assert_eq!(state.screen.rows, 1);
109        assert_eq!(state.screen.cols, 1);
110        assert_eq!(state.scrollback.limit(), 0);
111    }
112
113    #[test]
114    fn ascii_write_wraps_and_scrollback_keeps_limit_order() {
115        let mut state = TerminalState::with_config(TerminalConfig {
116            size: TerminalSize::new(2, 3),
117            scrollback_limit: 1,
118            security: TerminalSecurityPolicy::default(),
119        });
120
121        state.write_str("abcdefghi");
122
123        assert_eq!(state.scrollback.len(), 1);
124        assert_eq!(
125            state
126                .scrollback
127                .iter()
128                .next()
129                .expect("scrollback")
130                .plain_text(),
131            "def"
132        );
133        assert_eq!(state.screen.line(0).expect("line").plain_text(), "ghi");
134        assert_eq!(state.cursor.row, 1);
135        assert_eq!(state.cursor.col, 0);
136    }
137
138    #[test]
139    fn wide_char_creates_leading_and_trailing_cells() {
140        let mut state = TerminalState::with_config(TerminalConfig {
141            size: TerminalSize::new(2, 4),
142            scrollback_limit: 10,
143            security: TerminalSecurityPolicy::default(),
144        });
145
146        state.write_char('界');
147
148        assert_eq!(state.screen.cell(0, 0).expect("leading").text, "界");
149        assert_eq!(
150            state.screen.cell(0, 0).expect("leading").width,
151            CellWidth::WideLeading
152        );
153        assert_eq!(
154            state.screen.cell(0, 1).expect("trailing").width,
155            CellWidth::WideTrailing
156        );
157        assert_eq!(state.cursor.col, 2);
158    }
159
160    #[test]
161    fn overwriting_wide_cell_cleans_trailing_cell() {
162        let mut state = TerminalState::with_config(TerminalConfig {
163            size: TerminalSize::new(2, 4),
164            scrollback_limit: 10,
165            security: TerminalSecurityPolicy::default(),
166        });
167
168        state.write_char('界');
169        state.cursor.col = 0;
170        state.write_char('x');
171
172        assert_eq!(state.screen.cell(0, 0).expect("cell").text, "x");
173        assert_eq!(
174            state.screen.cell(0, 0).expect("cell").width,
175            CellWidth::Narrow
176        );
177        assert_eq!(state.screen.cell(0, 1).expect("cell").text, " ");
178        assert_eq!(
179            state.screen.cell(0, 1).expect("cell").width,
180            CellWidth::Narrow
181        );
182    }
183
184    #[test]
185    fn combining_mark_attaches_to_previous_cell() {
186        let mut state = TerminalState::with_config(TerminalConfig {
187            size: TerminalSize::new(2, 4),
188            scrollback_limit: 10,
189            security: TerminalSecurityPolicy::default(),
190        });
191
192        state.write_char('e');
193        state.write_char('\u{301}');
194
195        assert_eq!(state.screen.cell(0, 0).expect("cell").text, "e\u{301}");
196        assert_eq!(state.cursor.col, 1);
197    }
198
199    #[test]
200    fn clear_line_and_screen_mark_damage() {
201        let mut state = TerminalState::with_config(TerminalConfig {
202            size: TerminalSize::new(2, 4),
203            scrollback_limit: 10,
204            security: TerminalSecurityPolicy::default(),
205        });
206        state.write_str("abcd");
207        state.damage.reset();
208
209        state.clear_line(0);
210        assert_eq!(state.screen.line(0).expect("line").plain_text(), "    ");
211        assert_eq!(state.damage.dirty_lines, vec![0]);
212
213        state.clear_screen();
214        assert!(state.damage.full);
215        assert_eq!(state.screen.line(0).expect("line").plain_text(), "    ");
216        assert_eq!(state.screen.line(1).expect("line").plain_text(), "    ");
217    }
218
219    #[test]
220    fn insert_and_delete_lines_respect_scroll_region() {
221        let style = TerminalStyle::default();
222        let mut damage = TerminalDamage::clean();
223        let mut screen = TerminalScreen::new(TerminalSize::new(4, 3), style);
224        for row in 0..4 {
225            screen.put_narrow(row, 0, row.to_string(), style, None, &mut damage);
226        }
227        damage.reset();
228        screen.set_scroll_region(1, 2);
229
230        screen.insert_lines(1, 1, style, &mut damage);
231        assert_eq!(screen.line(0).expect("line").plain_text(), "0  ");
232        assert_eq!(screen.line(1).expect("line").plain_text(), "   ");
233        assert_eq!(screen.line(2).expect("line").plain_text(), "1  ");
234        assert_eq!(screen.line(3).expect("line").plain_text(), "3  ");
235
236        screen.delete_lines(1, 1, style, &mut damage);
237        assert_eq!(screen.line(0).expect("line").plain_text(), "0  ");
238        assert_eq!(screen.line(1).expect("line").plain_text(), "1  ");
239        assert_eq!(screen.line(2).expect("line").plain_text(), "   ");
240        assert_eq!(screen.line(3).expect("line").plain_text(), "3  ");
241    }
242
243    #[test]
244    fn resize_conserves_visible_cells_clamps_cursor_and_marks_full() {
245        let mut state = TerminalState::with_config(TerminalConfig {
246            size: TerminalSize::new(2, 3),
247            scrollback_limit: 10,
248            security: TerminalSecurityPolicy::default(),
249        });
250        state.write_str("abcde");
251        state.cursor.row = 1;
252        state.cursor.col = 2;
253        state.damage.reset();
254
255        state.resize(TerminalSize::new(3, 4));
256
257        assert_eq!(state.screen.rows, 3);
258        assert_eq!(state.screen.cols, 4);
259        assert_eq!(state.screen.line(0).expect("line").plain_text(), "abcd");
260        assert_eq!(state.screen.line(1).expect("line").plain_text(), "e   ");
261        assert_eq!(state.cursor.row, 1);
262        assert_eq!(state.cursor.col, 1);
263        assert!(state.damage.full);
264    }
265
266    #[test]
267    fn terminal_marks_autowrapped_lines_as_continuations() {
268        let mut state = TerminalState::with_config(TerminalConfig {
269            size: TerminalSize::new(2, 3),
270            scrollback_limit: 10,
271            security: TerminalSecurityPolicy::default(),
272        });
273
274        state.write_str("abcd");
275
276        assert!(!state.screen.line(0).expect("line").wrapped_from_previous);
277        assert!(state.screen.line(1).expect("line").wrapped_from_previous);
278    }
279
280    #[test]
281    fn terminal_preserves_explicit_line_break_metadata() {
282        let mut state = TerminalState::with_config(TerminalConfig {
283            size: TerminalSize::new(3, 4),
284            scrollback_limit: 10,
285            security: TerminalSecurityPolicy::default(),
286        });
287
288        state.write_str("ab\r\ncd");
289
290        assert!(!state.screen.line(0).expect("line").wrapped_from_previous);
291        assert!(!state.screen.line(1).expect("line").wrapped_from_previous);
292    }
293
294    #[test]
295    fn terminal_normal_screen_reflow_restores_text_after_shrink_and_grow() {
296        let mut state = TerminalState::with_config(TerminalConfig {
297            size: TerminalSize::new(3, 12),
298            scrollback_limit: 10,
299            security: TerminalSecurityPolicy::default(),
300        });
301        let text = "abcdefghijklmnopqrstuv";
302
303        state.write_str(text);
304        state.resize(TerminalSize::new(3, 8));
305        state.resize(TerminalSize::new(3, 12));
306
307        assert_eq!(normal_visual_text(&state), text);
308        assert_eq!(
309            state.screen.line(0).expect("line").plain_text(),
310            "abcdefghijkl"
311        );
312        assert_eq!(
313            state.screen.line(1).expect("line").plain_text(),
314            "mnopqrstuv  "
315        );
316    }
317
318    #[test]
319    fn terminal_reflows_soft_wrapped_lines_on_resize() {
320        let mut state = TerminalState::with_config(TerminalConfig {
321            size: TerminalSize::new(4, 4),
322            scrollback_limit: 10,
323            security: TerminalSecurityPolicy::default(),
324        });
325
326        state.write_str("abcdefg");
327        state.resize(TerminalSize::new(4, 3));
328
329        assert_eq!(state.screen.line(0).expect("line").plain_text(), "abc");
330        assert_eq!(state.screen.line(1).expect("line").plain_text(), "def");
331        assert_eq!(state.screen.line(2).expect("line").plain_text(), "g  ");
332        assert!(!state.screen.line(0).expect("line").wrapped_from_previous);
333        assert!(state.screen.line(1).expect("line").wrapped_from_previous);
334        assert!(state.screen.line(2).expect("line").wrapped_from_previous);
335    }
336
337    #[test]
338    fn terminal_preserves_explicit_line_breaks_on_resize() {
339        let mut state = TerminalState::with_config(TerminalConfig {
340            size: TerminalSize::new(4, 8),
341            scrollback_limit: 10,
342            security: TerminalSecurityPolicy::default(),
343        });
344
345        state.write_str("ab\r\ncd");
346        state.resize(TerminalSize::new(4, 2));
347
348        assert_eq!(state.screen.line(0).expect("line").plain_text(), "ab");
349        assert_eq!(state.screen.line(1).expect("line").plain_text(), "cd");
350        assert!(!state.screen.line(1).expect("line").wrapped_from_previous);
351    }
352
353    #[test]
354    fn terminal_reflow_breaks_stale_soft_wrap_after_cr_erase_line_prompt_redraw() {
355        let mut state = TerminalState::with_config(TerminalConfig {
356            size: TerminalSize::new(6, 18),
357            scrollback_limit: 10,
358            security: TerminalSecurityPolicy::default(),
359        });
360        let prompt = "dev@example:~/project/ailloli_ui$ ";
361
362        state.write_str(prompt);
363        assert!(state.screen.line(1).expect("wrapped").wrapped_from_previous);
364
365        state.carriage_return();
366        let redraw_row = state.cursor.row;
367        state.erase_line(2);
368        state.write_str(prompt);
369        state.resize(TerminalSize::new(6, 80));
370
371        assert!(
372            !state
373                .screen
374                .line(redraw_row)
375                .expect("redraw row")
376                .wrapped_from_previous
377        );
378        assert_no_line_contains_repeated_prompt(&state, "dev@example");
379    }
380
381    #[test]
382    fn terminal_reflow_preserves_soft_wrap_across_crlf() {
383        let mut state = TerminalState::with_config(TerminalConfig {
384            size: TerminalSize::new(5, 8),
385            scrollback_limit: 10,
386            security: TerminalSecurityPolicy::default(),
387        });
388
389        state.write_str("abcdefghijklmnopqrstuv\r\nnext");
390        state.resize(TerminalSize::new(5, 12));
391
392        assert_eq!(
393            state.screen.line(0).expect("line").plain_text(),
394            "abcdefghijkl"
395        );
396        assert_eq!(
397            state.screen.line(1).expect("line").plain_text(),
398            "mnopqrstuv  "
399        );
400        assert_eq!(
401            state.screen.line(2).expect("line").plain_text(),
402            "next        "
403        );
404        assert!(state.screen.line(1).expect("line").wrapped_from_previous);
405        assert!(!state.screen.line(2).expect("line").wrapped_from_previous);
406    }
407
408    #[test]
409    fn terminal_reflow_invalidates_wrap_on_cursor_addressed_line_edit() {
410        for edit in [
411            TerminalState::erase_chars as fn(&mut TerminalState, usize),
412            TerminalState::delete_chars,
413            TerminalState::insert_blank_chars,
414        ] {
415            let mut state = TerminalState::with_config(TerminalConfig {
416                size: TerminalSize::new(5, 4),
417                scrollback_limit: 10,
418                security: TerminalSecurityPolicy::default(),
419            });
420            state.write_str("abcdefghijk");
421            assert!(state.screen.line(1).expect("line").wrapped_from_previous);
422            assert!(state.screen.line(2).expect("line").wrapped_from_previous);
423
424            state.set_cursor_position(1, 1);
425            edit(&mut state, 1);
426
427            assert!(!state.screen.line(1).expect("line").wrapped_from_previous);
428            assert!(!state.screen.line(2).expect("line").wrapped_from_previous);
429            state.resize(TerminalSize::new(5, 12));
430            assert_no_line_contains_repeated_prompt(&state, "abcd");
431        }
432    }
433
434    #[test]
435    fn terminal_erase_line_breaks_soft_wrap_before_and_after_row() {
436        let mut state = TerminalState::with_config(TerminalConfig {
437            size: TerminalSize::new(5, 3),
438            scrollback_limit: 10,
439            security: TerminalSecurityPolicy::default(),
440        });
441
442        state.write_str("abcdefgh");
443        assert!(state.screen.line(1).expect("line").wrapped_from_previous);
444        assert!(state.screen.line(2).expect("line").wrapped_from_previous);
445
446        state.set_cursor_position(1, 0);
447        state.erase_line(2);
448
449        assert!(!state.screen.line(1).expect("line").wrapped_from_previous);
450        assert!(!state.screen.line(2).expect("line").wrapped_from_previous);
451        state.resize(TerminalSize::new(5, 12));
452        assert_eq!(
453            state.screen.line(0).expect("line").plain_text(),
454            "abc         "
455        );
456        assert_eq!(
457            state.screen.line(1).expect("line").plain_text(),
458            "            "
459        );
460        assert_eq!(
461            state.screen.line(2).expect("line").plain_text(),
462            "gh          "
463        );
464    }
465
466    #[test]
467    fn terminal_normal_screen_redraw_resize_stress_does_not_duplicate_prompt() {
468        let mut state = TerminalState::with_config(TerminalConfig {
469            size: TerminalSize::new(8, 20),
470            scrollback_limit: 100,
471            security: TerminalSecurityPolicy::default(),
472        });
473        let prompt = "dev@example:~/project/ailloli_ui$ ";
474
475        state.write_str(prompt);
476        for cols in [12, 36, 14, 50, 18, 64, 16, 80] {
477            state.resize(TerminalSize::new(8, cols));
478            state.carriage_return();
479            state.erase_line(2);
480            state.write_str(prompt);
481        }
482        state.resize(TerminalSize::new(8, 80));
483
484        assert_no_line_contains_repeated_prompt(&state, "dev@example");
485    }
486
487    #[test]
488    fn terminal_live_resize_preserves_single_prompt_after_shrink_redraw() {
489        let mut state = TerminalState::with_config(TerminalConfig {
490            size: TerminalSize::new(8, 60),
491            scrollback_limit: 100,
492            security: TerminalSecurityPolicy::default(),
493        });
494        let prompt = "dev@example:~/project/ailloli_ui$ ";
495        let mut parser = VteTerminalParser::new();
496
497        state.mark_shell_prompt_start();
498        state.write_str(prompt);
499        state.resize_with_policy(
500            TerminalSize::new(8, 18),
501            TerminalResizePolicy::LivePromptAwareReflow,
502        );
503        parser.advance(&mut state, b"\r\x1b[K\r");
504        parser.advance(&mut state, prompt.as_bytes());
505
506        assert_eq!(visual_marker_count(&state, "dev@example"), 1);
507        assert_no_line_contains_repeated_prompt(&state, "dev@example");
508    }
509
510    #[test]
511    fn terminal_live_resize_preserves_single_prompt_after_grow_redraw() {
512        let mut state = TerminalState::with_config(TerminalConfig {
513            size: TerminalSize::new(8, 18),
514            scrollback_limit: 100,
515            security: TerminalSecurityPolicy::default(),
516        });
517        let prompt = "dev@example:~/project/ailloli_ui$ ";
518        let mut parser = VteTerminalParser::new();
519
520        state.mark_shell_prompt_start();
521        state.write_str(prompt);
522        state.resize_with_policy(
523            TerminalSize::new(8, 80),
524            TerminalResizePolicy::LivePromptAwareReflow,
525        );
526        parser.advance(
527            &mut state,
528            b"\r\x1b[K\r\x1b[A\x1b[K\r\x1b[A\x1b[K\r\x1b[A\x1b[K\r",
529        );
530        parser.advance(&mut state, prompt.as_bytes());
531
532        assert_eq!(visual_marker_count(&state, "dev@example"), 1);
533        assert_no_line_contains_repeated_prompt(&state, "dev@example");
534    }
535
536    #[test]
537    fn terminal_live_prompt_multiple_shrink_redraws_do_not_duplicate_prompt() {
538        let mut state = TerminalState::with_config(TerminalConfig {
539            size: TerminalSize::new(8, 60),
540            scrollback_limit: 100,
541            security: TerminalSecurityPolicy::default(),
542        });
543        let prompt = "dev@example:~/project/ailloli_ui$ ";
544        let mut parser = VteTerminalParser::new();
545
546        state.mark_shell_prompt_start();
547        parser.advance(&mut state, prompt.as_bytes());
548        for cols in [18, 14, 18] {
549            state.resize_with_policy(
550                TerminalSize::new(8, cols),
551                TerminalResizePolicy::LivePromptAwareReflow,
552            );
553            parser.advance(&mut state, b"\r\x1b[K\r");
554            parser.advance(&mut state, prompt.as_bytes());
555        }
556
557        assert_eq!(visual_marker_count(&state, "dev@example"), 1);
558        assert_eq!(scrollback_marker_count(&state, "dev@example"), 0);
559        assert_no_line_contains_repeated_prompt(&state, "dev@example");
560    }
561
562    #[test]
563    fn terminal_live_prompt_reflows_active_prompt_on_resize() {
564        let mut state = TerminalState::with_config(TerminalConfig {
565            size: TerminalSize::new(8, 60),
566            scrollback_limit: 100,
567            security: TerminalSecurityPolicy::default(),
568        });
569        let prompt = "dev@example:~/project/ailloli_ui$ ";
570
571        state.mark_shell_prompt_start();
572        state.write_str(prompt);
573        state.resize_with_policy(
574            TerminalSize::new(8, 16),
575            TerminalResizePolicy::LivePromptAwareReflow,
576        );
577
578        assert_eq!(visual_marker_count(&state, "dev@example"), 1);
579        assert!(normal_visual_text(&state).contains(prompt.trim_end()));
580        assert!(state.screen.line(1).expect("wrapped").wrapped_from_previous);
581    }
582
583    #[test]
584    fn terminal_live_prompt_not_pushed_to_scrollback_on_resize() {
585        let mut state = TerminalState::with_config(TerminalConfig {
586            size: TerminalSize::new(2, 60),
587            scrollback_limit: 100,
588            security: TerminalSecurityPolicy::default(),
589        });
590        let prompt = "dev@example:~/project/ailloli_ui$ ";
591
592        state.mark_shell_prompt_start();
593        state.write_str(prompt);
594        state.resize_with_policy(
595            TerminalSize::new(2, 12),
596            TerminalResizePolicy::LivePromptAwareReflow,
597        );
598
599        assert_eq!(scrollback_marker_count(&state, "dev@example"), 0);
600    }
601
602    #[test]
603    fn terminal_live_prompt_cr_erase_line_clears_prompt_range() {
604        let mut state = TerminalState::with_config(TerminalConfig {
605            size: TerminalSize::new(8, 16),
606            scrollback_limit: 100,
607            security: TerminalSecurityPolicy::default(),
608        });
609        let prompt = "dev@example:~/project/ailloli_ui$ ";
610
611        state.mark_shell_prompt_start();
612        state.write_str(prompt);
613        assert_eq!(visual_marker_count(&state, "dev@example"), 1);
614
615        state.carriage_return();
616        state.erase_line(0);
617
618        assert_eq!(visual_marker_count(&state, "dev@example"), 0);
619        assert_eq!(state.cursor.row, 0);
620        assert_eq!(state.cursor.col, 0);
621    }
622
623    #[test]
624    fn terminal_live_prompt_cursor_tracks_last_wrapped_prompt_line() {
625        let mut state = TerminalState::with_config(TerminalConfig {
626            size: TerminalSize::new(8, 60),
627            scrollback_limit: 100,
628            security: TerminalSecurityPolicy::default(),
629        });
630        let prompt = "dev@example:~/project/ailloli_ui$ ";
631        let resized_cols = 16;
632
633        state.mark_shell_prompt_start();
634        state.write_str(prompt);
635        state.resize_with_policy(
636            TerminalSize::new(8, resized_cols),
637            TerminalResizePolicy::LivePromptAwareReflow,
638        );
639
640        assert_eq!(state.cursor.row, 2);
641        assert_eq!(state.cursor.col, prompt.chars().count() % resized_cols);
642        assert!(
643            state
644                .screen
645                .line(2)
646                .expect("cursor row")
647                .wrapped_from_previous
648        );
649    }
650
651    #[test]
652    fn terminal_default_resize_still_reflows_normal_output() {
653        let mut state = TerminalState::with_config(TerminalConfig {
654            size: TerminalSize::new(5, 24),
655            scrollback_limit: 100,
656            security: TerminalSecurityPolicy::default(),
657        });
658
659        state.write_str("abcdefghijklmnopqrstuvwxyz");
660        state.resize(TerminalSize::new(5, 8));
661        state.resize(TerminalSize::new(5, 26));
662
663        assert!(normal_visual_text(&state).contains("abcdefghijklmnopqrstuvwxyz"));
664    }
665
666    #[test]
667    fn terminal_live_resize_reflows_running_command_output() {
668        let mut state = TerminalState::with_config(TerminalConfig {
669            size: TerminalSize::new(5, 24),
670            scrollback_limit: 100,
671            security: TerminalSecurityPolicy::default(),
672        });
673
674        state.start_shell_command("cargo test", None, None);
675        state.write_str("abcdefghijklmnopqrstuvwxyz");
676        state.resize_with_policy(
677            TerminalSize::new(5, 8),
678            TerminalResizePolicy::LivePromptAwareReflow,
679        );
680        state.resize_with_policy(
681            TerminalSize::new(5, 26),
682            TerminalResizePolicy::LivePromptAwareReflow,
683        );
684
685        assert!(normal_visual_text(&state).contains("abcdefghijklmnopqrstuvwxyz"));
686    }
687
688    #[test]
689    fn terminal_reflow_preserves_scrollback_limit_and_order() {
690        let mut state = TerminalState::with_config(TerminalConfig {
691            size: TerminalSize::new(2, 4),
692            scrollback_limit: 2,
693            security: TerminalSecurityPolicy::default(),
694        });
695
696        state.write_str("abcdefghijkl");
697        state.resize(TerminalSize::new(2, 3));
698
699        assert!(state.scrollback.len() <= 2);
700        assert!(normal_visual_text(&state).ends_with("ghijkl"));
701    }
702
703    #[test]
704    fn terminal_cursor_position_remains_valid_after_reflow() {
705        let mut state = TerminalState::with_config(TerminalConfig {
706            size: TerminalSize::new(4, 10),
707            scrollback_limit: 10,
708            security: TerminalSecurityPolicy::default(),
709        });
710
711        state.write_str("hello world");
712        state.resize(TerminalSize::new(4, 5));
713
714        assert_eq!(normal_visual_text(&state), "hello world");
715        assert_eq!(state.cursor.row, 2);
716        assert_eq!(state.cursor.col, 1);
717        assert!(state.cursor.row < state.screen.rows);
718        assert!(state.cursor.col < state.screen.cols);
719    }
720
721    #[test]
722    fn terminal_snapshot_survives_reflow_resize() {
723        let mut state = TerminalState::with_config(TerminalConfig {
724            size: TerminalSize::new(3, 12),
725            scrollback_limit: 10,
726            security: TerminalSecurityPolicy::default(),
727        });
728
729        state.write_str("cargo build --workspace");
730        state.resize(TerminalSize::new(3, 7));
731
732        let snapshot = TerminalSnapshot::from_state(&state, TerminalSnapshotConfig::default());
733        assert!(!snapshot.lines.is_empty());
734        assert!(snapshot
735            .lines
736            .iter()
737            .map(|line| line.text.trim_end())
738            .collect::<String>()
739            .contains("cargo build"));
740    }
741
742    #[test]
743    fn terminal_alternate_screen_does_not_reflow_on_resize() {
744        let mut state = TerminalState::with_config(TerminalConfig {
745            size: TerminalSize::new(2, 6),
746            scrollback_limit: 10,
747            security: TerminalSecurityPolicy::default(),
748        });
749
750        state.switch_to_alternate_screen();
751        state.write_str("abcdef");
752        state.resize(TerminalSize::new(2, 3));
753
754        assert_eq!(
755            state.alternate_screen.line(0).expect("line").plain_text(),
756            "abc"
757        );
758        assert_eq!(normal_visual_text(&state), "");
759    }
760
761    #[test]
762    fn alternate_screen_is_separate_from_normal_screen() {
763        let mut state = TerminalState::with_config(TerminalConfig {
764            size: TerminalSize::new(2, 4),
765            scrollback_limit: 10,
766            security: TerminalSecurityPolicy::default(),
767        });
768
769        state.write_str("main");
770        state.switch_to_alternate_screen();
771        state.write_str("alt");
772        assert_eq!(state.active_screen, ActiveScreen::Alternate);
773        assert_eq!(
774            state.alternate_screen.line(0).expect("line").plain_text(),
775            "alt "
776        );
777
778        state.switch_to_normal_screen();
779        assert_eq!(state.active_screen, ActiveScreen::Normal);
780        assert_eq!(state.screen.line(0).expect("line").plain_text(), "main");
781    }
782
783    /// Concatenates trimmed normal scrollback and screen rows in visual order.
784    fn normal_visual_text(state: &TerminalState) -> String {
785        state
786            .scrollback
787            .iter()
788            .chain(state.screen.lines.iter())
789            .map(|line| line.plain_text().trim_end().to_string())
790            .collect::<String>()
791    }
792
793    /// Asserts that no normal visual row contains `marker` more than once.
794    fn assert_no_line_contains_repeated_prompt(state: &TerminalState, marker: &str) {
795        for line in state.scrollback.iter().chain(state.screen.lines.iter()) {
796            let text = line.plain_text();
797            assert!(
798                text.matches(marker).count() <= 1,
799                "line contains repeated prompt marker: {text:?}"
800            );
801        }
802    }
803
804    /// Counts non-overlapping marker occurrences across normal visual rows.
805    fn visual_marker_count(state: &TerminalState, marker: &str) -> usize {
806        state
807            .scrollback
808            .iter()
809            .chain(state.screen.lines.iter())
810            .map(|line| line.plain_text().matches(marker).count())
811            .sum()
812    }
813
814    /// Counts non-overlapping marker occurrences in retained scrollback only.
815    fn scrollback_marker_count(state: &TerminalState, marker: &str) -> usize {
816        state
817            .scrollback
818            .iter()
819            .map(|line| line.plain_text().matches(marker).count())
820            .sum()
821    }
822
823    #[test]
824    fn damage_deduplicates_and_resets() {
825        let mut damage = TerminalDamage::clean();
826        damage.mark_line(2);
827        damage.mark_line(2);
828        damage.mark_line(1);
829        damage.mark_cursor();
830        damage.mark_title();
831
832        assert_eq!(damage.dirty_lines, vec![1, 2]);
833        assert!(damage.cursor_dirty);
834        assert!(damage.title_dirty);
835
836        damage.reset();
837        assert_eq!(damage, TerminalDamage::clean());
838    }
839
840    #[test]
841    fn security_policy_default_blocks_clipboard_and_shell_integration() {
842        let policy = TerminalSecurityPolicy::default();
843
844        assert!(policy.allow_title_change);
845        assert!(policy.allow_hyperlinks);
846        assert!(!policy.allow_clipboard_write);
847        assert!(!policy.allow_clipboard_read);
848        assert!(!policy.allow_terminal_queries);
849        assert!(!policy.allow_shell_integration);
850    }
851}