winx_code_agent/state/
live_terminal.rs1use vt100::Parser;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ScreenUpdate {
24 Full(Vec<String>),
26 Diff(Vec<(usize, String)>),
28 Unchanged,
30}
31
32pub struct LiveTerminal {
35 parser: Parser,
36 last_snapshot: Option<Vec<String>>,
39}
40
41impl LiveTerminal {
42 pub fn new(rows: u16, cols: u16) -> Self {
47 Self { parser: Parser::new(rows.max(1), cols.max(1), 0), last_snapshot: None }
48 }
49
50 pub fn feed(&mut self, bytes: &[u8]) {
53 self.parser.process(bytes);
54 }
55
56 pub fn in_alt_screen(&self) -> bool {
59 self.parser.screen().alternate_screen()
60 }
61
62 pub fn resize(&mut self, rows: u16, cols: u16) {
64 self.parser.screen_mut().set_size(rows.max(1), cols.max(1));
65 }
66
67 pub fn snapshot(&self, max_lines: usize) -> Vec<String> {
71 let contents = self.parser.screen().contents();
72 let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
73 while lines.last().is_some_and(|l| l.trim().is_empty()) {
74 lines.pop();
75 }
76 if max_lines > 0 && lines.len() > max_lines {
77 lines = lines.split_off(lines.len() - max_lines);
78 }
79 lines
80 }
81
82 pub fn cursor_position(&self) -> (u16, u16) {
85 self.parser.screen().cursor_position()
86 }
87
88 pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
94 let current = self.snapshot(max_lines);
95 let update = match self.last_snapshot.as_ref() {
96 Some(previous) => {
97 let changed = diff_lines(previous, ¤t);
98 if changed.is_empty() {
99 ScreenUpdate::Unchanged
100 } else if changed.len() <= threshold {
101 ScreenUpdate::Diff(changed)
102 } else {
103 ScreenUpdate::Full(current.clone())
104 }
105 }
106 None => ScreenUpdate::Full(current.clone()),
107 };
108 self.last_snapshot = Some(current);
109 update
110 }
111}
112
113fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
116 let rows = previous.len().max(current.len());
117 (0..rows)
118 .filter_map(|i| {
119 let was = previous.get(i).map_or("", String::as_str);
120 let now = current.get(i).map_or("", String::as_str);
121 (was != now).then(|| (i + 1, now.to_string()))
122 })
123 .collect()
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
132 let mut t = LiveTerminal::new(10, 40);
136 t.feed(b"line one\r\nGenerating...\r\n"); t.feed(b"\x1b[2A"); t.feed(b"\x1b[Jline one\r\nDONE\r\n"); let joined = t.snapshot(0).join("\n");
140 assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
141 assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
142 }
143
144 #[test]
145 fn viewport_is_fixed_old_lines_scroll_off() {
146 let mut t = LiveTerminal::new(3, 40); for i in 0..10 {
148 t.feed(format!("row{i}\r\n").as_bytes());
149 }
150 let snap = t.snapshot(0);
151 assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
152 assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
153 assert!(
154 !snap.iter().any(|l| l.contains("row0")),
155 "oldest row should have scrolled off a fixed viewport: {snap:?}"
156 );
157 }
158
159 #[test]
160 fn detects_alternate_screen_toggle() {
161 let mut t = LiveTerminal::new(10, 40);
162 assert!(!t.in_alt_screen());
163 t.feed(b"\x1b[?1049h");
164 assert!(t.in_alt_screen());
165 t.feed(b"\x1b[?1049l");
166 assert!(!t.in_alt_screen());
167 }
168
169 #[test]
170 fn snapshot_last_n_lines() {
171 let mut t = LiveTerminal::new(10, 40);
172 t.feed(b"a\r\nb\r\nc\r\nd\r\n");
173 assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
174 }
175
176 #[test]
177 fn cursor_position_tracks_feed() {
178 let mut t = LiveTerminal::new(10, 40);
179 t.feed(b"abc");
180 assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
181 }
182
183 #[test]
184 fn snapshot_diff_full_then_unchanged_then_one_line() {
185 let mut t = LiveTerminal::new(5, 40);
186 t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
187 assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
189 assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
191 t.feed(b"\x1b[2;1Hbeta2");
193 let update = t.snapshot_diff(0, 10);
194 assert!(
195 matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
196 "expected a one-line diff, got {update:?}"
197 );
198 if let ScreenUpdate::Diff(changed) = &update {
199 assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
200 assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
201 }
202 }
203
204 #[test]
205 fn snapshot_diff_big_change_falls_back_to_full() {
206 let mut t = LiveTerminal::new(10, 40);
207 t.feed(b"seed\r\n");
208 let _ = t.snapshot_diff(0, 1); for i in 0..8 {
210 t.feed(format!("line{i}\r\n").as_bytes());
211 }
212 assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
214 }
215}