1pub 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)]
75mod 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 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 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 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 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}