Skip to main content

xei_core/
term.rs

1//! Built-in terminal emulator with a **real PTY**.
2//!
3//! Uses `portable-pty` so child processes (opencode, claude, vim, …) get a
4//! genuine tty, correct `TIOCSWINSZ` on resize, and SIGWINCH. Pairs that with
5//! UTF-8 / CSI / OSC parsing and an alternate screen buffer for full-screen TUIs.
6
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::sync::mpsc::{self, Receiver, TryRecvError};
10use std::thread;
11
12use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
13use unicode_width::UnicodeWidthChar;
14
15pub struct Terminal {
16    pub open: bool,
17    /// When true, terminal fills an editor slot (Ctrl+Shift+T)
18    /// instead of the side panel (Ctrl+T).
19    pub full_panel: bool,
20    /// When split is active, full terminal is bound to this pane index only
21    /// (`None` = fill the whole main editor area).
22    pub pane_bound: Option<usize>,
23    rows: Vec<Vec<Cell>>,
24    /// Saved primary buffer while alternate screen is active.
25    saved_primary: Option<SavedScreen>,
26    alt_screen: bool,
27    cursor_row: usize,
28    cursor_col: usize,
29    saved_cursor: (usize, usize),
30    cols: u16,
31    rows_count: u16,
32    scroll_offset: usize,
33    /// Inner app enabled mouse reporting (CSI ?1000/1002/1003 h).
34    mouse_reporting: bool,
35    /// DECCKM — application cursor keys (arrows as ESC O A..D).
36    app_cursor_keys: bool,
37    /// Child enabled bracketed paste (DECSET 2004) — wrap forwarded pastes.
38    bracketed_paste: bool,
39    /// PTY master — kept alive for `resize` (TIOCSWINSZ + SIGWINCH).
40    master: Option<Box<dyn MasterPty + Send>>,
41    child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
42    writer: Option<Box<dyn Write + Send>>,
43    rx: Option<Receiver<Vec<u8>>>,
44    scrollback: Vec<Vec<Cell>>,
45    fg: Color,
46    bg: Color,
47    bold: bool,
48    reverse: bool,
49    /// Incomplete UTF-8 / escape sequence from the previous poll chunk.
50    pending: Vec<u8>,
51    pub started: bool,
52    /// Full/pane terminal: Esc asked once — wait for y/n before closing.
53    pub close_confirm: bool,
54}
55
56struct SavedScreen {
57    rows: Vec<Vec<Cell>>,
58    cursor_row: usize,
59    cursor_col: usize,
60    scrollback: Vec<Vec<Cell>>,
61}
62
63#[derive(Clone)]
64struct Cell {
65    ch: char,
66    fg: Option<Color>,
67    bg: Option<Color>,
68}
69
70impl Cell {
71    fn blank() -> Self {
72        Self {
73            ch: ' ',
74            fg: None,
75            bg: None,
76        }
77    }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Color {
82    Default,
83    Black,
84    Red,
85    Green,
86    Yellow,
87    Blue,
88    Magenta,
89    Cyan,
90    White,
91    BrightBlack,
92    BrightRed,
93    BrightGreen,
94    BrightYellow,
95    BrightBlue,
96    BrightMagenta,
97    BrightCyan,
98    BrightWhite,
99    Rgb(u8, u8, u8),
100}
101
102impl Color {
103    fn to_ratatui(self) -> ratatui::style::Color {
104        match self {
105            // Default → pure black so agent TUIs don't sit on a grey "frame"
106            Color::Default => ratatui::style::Color::Rgb(0, 0, 0),
107            Color::Black => ratatui::style::Color::Rgb(0, 0, 0),
108            Color::Red => ratatui::style::Color::Red,
109            Color::Green => ratatui::style::Color::Green,
110            Color::Yellow => ratatui::style::Color::Yellow,
111            Color::Blue => ratatui::style::Color::Blue,
112            Color::Magenta => ratatui::style::Color::Magenta,
113            Color::Cyan => ratatui::style::Color::Cyan,
114            Color::White => ratatui::style::Color::White,
115            Color::BrightBlack => ratatui::style::Color::Gray,
116            Color::BrightRed => ratatui::style::Color::LightRed,
117            Color::BrightGreen => ratatui::style::Color::LightGreen,
118            Color::BrightYellow => ratatui::style::Color::LightYellow,
119            Color::BrightBlue => ratatui::style::Color::LightBlue,
120            Color::BrightMagenta => ratatui::style::Color::LightMagenta,
121            Color::BrightCyan => ratatui::style::Color::LightCyan,
122            Color::BrightWhite => ratatui::style::Color::White,
123            Color::Rgb(r, g, b) => ratatui::style::Color::Rgb(r, g, b),
124        }
125    }
126}
127
128fn blank_grid(cols: u16, rows: u16) -> Vec<Vec<Cell>> {
129    vec![vec![Cell::blank(); cols as usize]; rows as usize]
130}
131
132impl Default for Terminal {
133    fn default() -> Self {
134        let (cols, rows) = (80, 24);
135        Self {
136            open: false,
137            full_panel: false,
138            pane_bound: None,
139            rows: blank_grid(cols, rows),
140            saved_primary: None,
141            alt_screen: false,
142            cursor_row: 0,
143            cursor_col: 0,
144            saved_cursor: (0, 0),
145            cols,
146            rows_count: rows,
147            scroll_offset: 0,
148            mouse_reporting: false,
149            app_cursor_keys: false,
150            bracketed_paste: false,
151            master: None,
152            child: None,
153            writer: None,
154            rx: None,
155            scrollback: Vec::new(),
156            fg: Color::Default,
157            bg: Color::Default,
158            bold: false,
159            reverse: false,
160            pending: Vec::new(),
161            started: false,
162            close_confirm: false,
163        }
164    }
165}
166
167impl Terminal {
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    pub fn cols(&self) -> u16 {
173        self.cols
174    }
175    pub fn rows_count(&self) -> u16 {
176        self.rows_count
177    }
178
179    /// Resize the virtual screen **and** the real PTY (TIOCSWINSZ → SIGWINCH).
180    pub fn resize(&mut self, cols: u16, rows: u16) {
181        let cols = cols.max(2);
182        let rows = rows.max(2);
183        if cols == self.cols && rows == self.rows_count {
184            // Still push size to PTY in case we started before first paint
185            // already matched — no-op when equal.
186            return;
187        }
188        self.resize_grid(cols, rows);
189        if let Some(ref master) = self.master {
190            let _ = master.resize(PtySize {
191                rows,
192                cols,
193                pixel_width: 0,
194                pixel_height: 0,
195            });
196        }
197    }
198
199    fn resize_grid(&mut self, cols: u16, rows: u16) {
200        let resize_buf = |grid: &mut Vec<Vec<Cell>>| {
201            let mut new_rows = Vec::with_capacity(rows as usize);
202            for r in 0..rows as usize {
203                let mut row = if r < grid.len() {
204                    let mut old = grid[r].clone();
205                    old.resize(cols as usize, Cell::blank());
206                    old.truncate(cols as usize);
207                    old
208                } else {
209                    vec![Cell::blank(); cols as usize]
210                };
211                if row.len() != cols as usize {
212                    row.resize(cols as usize, Cell::blank());
213                }
214                new_rows.push(row);
215            }
216            *grid = new_rows;
217        };
218        resize_buf(&mut self.rows);
219        if let Some(ref mut saved) = self.saved_primary {
220            resize_buf(&mut saved.rows);
221            saved.cursor_row = saved.cursor_row.min(rows as usize - 1);
222            saved.cursor_col = saved.cursor_col.min(cols as usize - 1);
223        }
224        self.cols = cols;
225        self.rows_count = rows;
226        self.cursor_row = self.cursor_row.min(rows as usize - 1);
227        self.cursor_col = self.cursor_col.min(cols as usize - 1);
228    }
229
230    /// Open a real PTY and spawn the user shell at the current grid size.
231    pub fn start(&mut self, anchor: Option<&PathBuf>) {
232        if self.started {
233            return;
234        }
235
236        let cwd = anchor
237            .and_then(|p| p.parent().map(|p| p.to_path_buf()))
238            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
239
240        let shell = std::env::var("SHELL").unwrap_or_else(|_| {
241            if cfg!(windows) {
242                "powershell.exe".into()
243            } else {
244                "/bin/zsh".into()
245            }
246        });
247
248        let pty_system = native_pty_system();
249        let pair = match pty_system.openpty(PtySize {
250            rows: self.rows_count,
251            cols: self.cols,
252            pixel_width: 0,
253            pixel_height: 0,
254        }) {
255            Ok(p) => p,
256            Err(_) => {
257                // Last-resort: stay closed rather than a broken half-state
258                return;
259            }
260        };
261
262        let mut cmd = CommandBuilder::new(&shell);
263        cmd.cwd(cwd);
264        cmd.env("TERM", "xterm-256color");
265        cmd.env("COLORTERM", "truecolor");
266        cmd.env("COLUMNS", self.cols.to_string());
267        cmd.env("LINES", self.rows_count.to_string());
268        // Avoid inheriting host kitty/graphics state into nested agents
269        cmd.env_remove("KITTY_WINDOW_ID");
270        cmd.env_remove("WEZTERM_PANE");
271
272        let child = match pair.slave.spawn_command(cmd) {
273            Ok(c) => c,
274            Err(_) => return,
275        };
276
277        let mut reader = match pair.master.try_clone_reader() {
278            Ok(r) => r,
279            Err(_) => return,
280        };
281        let writer = match pair.master.take_writer() {
282            Ok(w) => w,
283            Err(_) => return,
284        };
285
286        let (tx, rx) = mpsc::channel::<Vec<u8>>();
287        thread::spawn(move || {
288            let mut buf = [0u8; 8192];
289            loop {
290                match reader.read(&mut buf) {
291                    Ok(0) | Err(_) => break,
292                    Ok(n) => {
293                        if tx.send(buf[..n].to_vec()).is_err() {
294                            break;
295                        }
296                    }
297                }
298            }
299        });
300
301        self.master = Some(pair.master);
302        self.child = Some(child);
303        self.writer = Some(writer);
304        self.rx = Some(rx);
305        self.open = true;
306        self.started = true;
307        self.pending.clear();
308        self.alt_screen = false;
309        self.saved_primary = None;
310        self.rows = blank_grid(self.cols, self.rows_count);
311        self.cursor_row = 0;
312        self.cursor_col = 0;
313        self.scrollback.clear();
314        self.scroll_offset = 0;
315        self.mouse_reporting = false;
316        self.app_cursor_keys = false;
317        self.bracketed_paste = false;
318        self.fg = Color::Default;
319        self.bg = Color::Default;
320        self.bold = false;
321        self.reverse = false;
322    }
323
324    pub fn shutdown(&mut self) {
325        // Drop writer first → EOF to slave
326        self.writer = None;
327        if let Some(mut child) = self.child.take() {
328            let _ = child.kill();
329            let _ = child.wait();
330        }
331        self.master = None;
332        self.rx = None;
333        self.started = false;
334        self.open = false;
335        self.full_panel = false;
336        self.pane_bound = None;
337        self.close_confirm = false;
338        self.pending.clear();
339        self.alt_screen = false;
340        self.saved_primary = None;
341        self.rows = blank_grid(self.cols, self.rows_count);
342        self.cursor_row = 0;
343        self.cursor_col = 0;
344        self.scrollback.clear();
345        self.scroll_offset = 0;
346        self.mouse_reporting = false;
347        self.app_cursor_keys = false;
348        self.bracketed_paste = false;
349        self.fg = Color::Default;
350        self.bg = Color::Default;
351        self.bold = false;
352        self.reverse = false;
353    }
354
355    pub fn write_input(&mut self, bytes: &[u8]) {
356        // Typing snaps the view back to the live prompt.
357        self.scroll_offset = 0;
358        if let Some(ref mut w) = self.writer {
359            let _ = w.write_all(bytes);
360            let _ = w.flush();
361        }
362    }
363
364    /// Forward pasted text to the child. When the child enabled bracketed paste
365    /// (DECSET 2004), wrap it in `\x1b[200~ … \x1b[201~` so TUIs (claude, vim,
366    /// fish) treat it as one paste — not typed keys that auto-submit on newline.
367    /// This is what makes Cmd+V and file drag-drop deliver a path/blob intact.
368    pub fn paste_input(&mut self, text: &str) {
369        if text.is_empty() {
370            return;
371        }
372        if self.bracketed_paste {
373            let mut buf = Vec::with_capacity(text.len() + 12);
374            buf.extend_from_slice(b"\x1b[200~");
375            buf.extend_from_slice(text.as_bytes());
376            buf.extend_from_slice(b"\x1b[201~");
377            self.write_input(&buf);
378        } else {
379            self.write_input(text.as_bytes());
380        }
381    }
382
383    pub fn poll(&mut self) {
384        let data = if let Some(ref rx) = self.rx {
385            let mut all = Vec::new();
386            loop {
387                match rx.try_recv() {
388                    Ok(part) => all.extend_from_slice(&part),
389                    Err(TryRecvError::Empty) => break,
390                    Err(TryRecvError::Disconnected) => break,
391                }
392            }
393            if all.is_empty() {
394                return;
395            }
396            all
397        } else {
398            return;
399        };
400        self.process_output(&data);
401    }
402
403    fn process_output(&mut self, data: &[u8]) {
404        self.pending.extend_from_slice(data);
405        let buf = std::mem::take(&mut self.pending);
406        let mut i = 0;
407        while i < buf.len() {
408            match self.try_consume(&buf, i) {
409                Consume::Advanced(n) => i = n,
410                Consume::NeedMore => {
411                    self.pending = buf[i..].to_vec();
412                    if self.pending.len() > 8192 {
413                        self.pending.clear();
414                    }
415                    break;
416                }
417            }
418        }
419    }
420
421    fn try_consume(&mut self, data: &[u8], i: usize) -> Consume {
422        let b = data[i];
423        if b == 0x1b {
424            if i + 1 >= data.len() {
425                return Consume::NeedMore;
426            }
427            let n = data[i + 1];
428            match n {
429                b'[' => return self.consume_csi(data, i + 2),
430                b']' => return self.consume_osc(data, i + 2),
431                b'P' | b'X' | b'^' | b'_' => return self.consume_string_seq(data, i + 2),
432                b'\\' => return Consume::Advanced(i + 2),
433                b'(' | b')' | b'*' | b'+' | b'-' | b'.' | b'/' => {
434                    if i + 2 >= data.len() {
435                        return Consume::NeedMore;
436                    }
437                    return Consume::Advanced(i + 3);
438                }
439                b'7' => {
440                    self.saved_cursor = (self.cursor_row, self.cursor_col);
441                    return Consume::Advanced(i + 2);
442                }
443                b'8' => {
444                    self.cursor_row = self.saved_cursor.0.min(self.rows_count as usize - 1);
445                    self.cursor_col = self.saved_cursor.1.min(self.cols as usize - 1);
446                    return Consume::Advanced(i + 2);
447                }
448                b'=' | b'>' | b'c' | b'M' | b'E' | b'D' | b'H' | b'Z' => {
449                    return Consume::Advanced(i + 2);
450                }
451                _ if n >= 0x20 && n < 0x7f => return Consume::Advanced(i + 2),
452                _ => return Consume::Advanced(i + 1),
453            }
454        }
455
456        match b {
457            b'\n' => {
458                self.newline();
459                Consume::Advanced(i + 1)
460            }
461            b'\r' => {
462                self.cursor_col = 0;
463                Consume::Advanced(i + 1)
464            }
465            0x08 | 0x7f => {
466                if self.cursor_col > 0 {
467                    self.cursor_col -= 1;
468                }
469                Consume::Advanced(i + 1)
470            }
471            b'\t' => {
472                let next = ((self.cursor_col / 8) + 1) * 8;
473                while self.cursor_col < next && self.cursor_col < self.cols as usize {
474                    self.write_char(' ');
475                }
476                Consume::Advanced(i + 1)
477            }
478            0x07 | 0x0e | 0x0f => Consume::Advanced(i + 1),
479            b if b >= 0x80 => self.consume_utf8(data, i),
480            b if b >= 0x20 => {
481                self.write_char(b as char);
482                Consume::Advanced(i + 1)
483            }
484            _ => Consume::Advanced(i + 1),
485        }
486    }
487
488    fn consume_utf8(&mut self, data: &[u8], i: usize) -> Consume {
489        let b0 = data[i];
490        let need = if b0 & 0xE0 == 0xC0 {
491            2
492        } else if b0 & 0xF0 == 0xE0 {
493            3
494        } else if b0 & 0xF8 == 0xF0 {
495            4
496        } else {
497            return Consume::Advanced(i + 1);
498        };
499        if i + need > data.len() {
500            return Consume::NeedMore;
501        }
502        match std::str::from_utf8(&data[i..i + need]) {
503            Ok(s) => {
504                if let Some(ch) = s.chars().next() {
505                    self.write_char(ch);
506                }
507                Consume::Advanced(i + need)
508            }
509            Err(_) => Consume::Advanced(i + 1),
510        }
511    }
512
513    fn consume_csi(&mut self, data: &[u8], start: usize) -> Consume {
514        let mut i = start;
515        let mut private = None;
516        if i < data.len() && matches!(data[i], b'?' | b'>' | b'=' | b'<') {
517            private = Some(data[i] as char);
518            i += 1;
519        }
520        let param_start = i;
521        while i < data.len() {
522            let b = data[i];
523            if b.is_ascii_digit() || b == b';' || b == b':' || b == b' ' {
524                i += 1;
525                continue;
526            }
527            if (0x20..=0x2F).contains(&b) {
528                i += 1;
529                continue;
530            }
531            if (0x40..=0x7E).contains(&b) {
532                let params = parse_csi_params(&data[param_start..i]);
533                self.apply_csi(b as char, &params, private);
534                return Consume::Advanced(i + 1);
535            }
536            return Consume::Advanced(i + 1);
537        }
538        Consume::NeedMore
539    }
540
541    fn consume_osc(&mut self, data: &[u8], start: usize) -> Consume {
542        let mut i = start;
543        while i < data.len() {
544            if data[i] == 0x07 {
545                return Consume::Advanced(i + 1);
546            }
547            if data[i] == 0x1b {
548                if i + 1 >= data.len() {
549                    return Consume::NeedMore;
550                }
551                if data[i + 1] == b'\\' {
552                    return Consume::Advanced(i + 2);
553                }
554                return Consume::Advanced(i);
555            }
556            i += 1;
557        }
558        Consume::NeedMore
559    }
560
561    fn consume_string_seq(&mut self, data: &[u8], start: usize) -> Consume {
562        self.consume_osc(data, start)
563    }
564
565    fn apply_csi(&mut self, cmd: char, nums: &[i32], private: Option<char>) {
566        // Private modes: CSI ? … h/l  (alt screen, cursor, etc.)
567        if private == Some('?') && (cmd == 'h' || cmd == 'l') {
568            let enable = cmd == 'h';
569            for &mode in nums {
570                self.apply_private_mode(mode, enable);
571            }
572            return;
573        }
574        if private.is_some() {
575            // Other private sequences — swallow
576            return;
577        }
578
579        let n = |i: usize, d: i32| -> i32 {
580            nums.get(i)
581                .copied()
582                .filter(|&v| v != 0)
583                .unwrap_or(d)
584        };
585        let n0 = |i: usize, d: i32| -> i32 { nums.get(i).copied().unwrap_or(d) };
586
587        match cmd {
588            'A' => {
589                self.cursor_row = self
590                    .cursor_row
591                    .saturating_sub(n(0, 1).max(1) as usize)
592            }
593            'B' => {
594                self.cursor_row = (self.cursor_row + n(0, 1).max(1) as usize)
595                    .min(self.rows_count as usize - 1)
596            }
597            'C' => {
598                self.cursor_col = (self.cursor_col + n(0, 1).max(1) as usize)
599                    .min(self.cols as usize - 1)
600            }
601            'D' => {
602                self.cursor_col = self
603                    .cursor_col
604                    .saturating_sub(n(0, 1).max(1) as usize)
605            }
606            'E' => {
607                self.cursor_row = (self.cursor_row + n(0, 1).max(1) as usize)
608                    .min(self.rows_count as usize - 1);
609                self.cursor_col = 0;
610            }
611            'F' => {
612                self.cursor_row = self
613                    .cursor_row
614                    .saturating_sub(n(0, 1).max(1) as usize);
615                self.cursor_col = 0;
616            }
617            'G' => {
618                self.cursor_col = (n(0, 1).max(1) as usize - 1).min(self.cols as usize - 1);
619            }
620            'H' | 'f' => {
621                self.cursor_row =
622                    (n(0, 1).max(1) as usize - 1).min(self.rows_count as usize - 1);
623                self.cursor_col =
624                    (n(1, 1).max(1) as usize - 1).min(self.cols as usize - 1);
625            }
626            'd' => {
627                self.cursor_row =
628                    (n(0, 1).max(1) as usize - 1).min(self.rows_count as usize - 1);
629            }
630            'J' => self.erase_display(n0(0, 0)),
631            'K' => self.erase_line(n0(0, 0)),
632            'S' => {
633                let n = n(0, 1).max(1) as usize;
634                for _ in 0..n {
635                    self.scroll_up_one();
636                }
637            }
638            'T' => {
639                let n = n(0, 1).max(1) as usize;
640                for _ in 0..n {
641                    self.scroll_down_one();
642                }
643            }
644            '@' => {
645                let n = n(0, 1).max(1) as usize;
646                let r = self.cursor_row;
647                let c = self.cursor_col;
648                let row = &mut self.rows[r];
649                for _ in 0..n {
650                    if c < row.len() {
651                        row.insert(c, Cell::blank());
652                        if row.len() > self.cols as usize {
653                            row.pop();
654                        }
655                    }
656                }
657            }
658            'P' => {
659                let n = n(0, 1).max(1) as usize;
660                let r = self.cursor_row;
661                let c = self.cursor_col;
662                let row = &mut self.rows[r];
663                for _ in 0..n {
664                    if c < row.len() {
665                        row.remove(c);
666                        row.push(Cell::blank());
667                    }
668                }
669            }
670            'X' => {
671                let n = n(0, 1).max(1) as usize;
672                let r = self.cursor_row;
673                for c in self.cursor_col..(self.cursor_col + n).min(self.cols as usize) {
674                    self.rows[r][c] = Cell::blank();
675                }
676            }
677            's' => self.saved_cursor = (self.cursor_row, self.cursor_col),
678            'u' => {
679                self.cursor_row = self.saved_cursor.0.min(self.rows_count as usize - 1);
680                self.cursor_col = self.saved_cursor.1.min(self.cols as usize - 1);
681            }
682            'm' => self.apply_sgr(nums),
683            'n' | 'r' | 't' => {}
684            _ => {}
685        }
686    }
687
688    fn apply_private_mode(&mut self, mode: i32, enable: bool) {
689        match mode {
690            // Alternate screen (xterm)
691            47 | 1047 | 1049 => {
692                if enable {
693                    self.enter_alt_screen(mode == 1049 || mode == 1047);
694                } else {
695                    self.leave_alt_screen(mode == 1049 || mode == 1047);
696                }
697            }
698            // DECCKM: arrows switch between CSI (\x1b[A) and SS3 (\x1bOA).
699            1 => self.app_cursor_keys = enable,
700            // The inner app asked for mouse events — the shell forwards wheel.
701            1000 | 1002 | 1003 => self.mouse_reporting = enable,
702            // Bracketed paste — remember so forwarded pastes get wrapped.
703            2004 => self.bracketed_paste = enable,
704            // Cursor visibility, SGR encoding, focus — ignore
705            25 | 1006 | 1004 | 7 | 12 => {}
706            _ => {}
707        }
708    }
709
710    fn enter_alt_screen(&mut self, clear: bool) {
711        if self.alt_screen {
712            if clear {
713                self.rows = blank_grid(self.cols, self.rows_count);
714                self.cursor_row = 0;
715                self.cursor_col = 0;
716            }
717            return;
718        }
719        self.saved_primary = Some(SavedScreen {
720            rows: std::mem::replace(&mut self.rows, blank_grid(self.cols, self.rows_count)),
721            cursor_row: self.cursor_row,
722            cursor_col: self.cursor_col,
723            scrollback: std::mem::take(&mut self.scrollback),
724        });
725        self.alt_screen = true;
726        self.cursor_row = 0;
727        self.cursor_col = 0;
728        self.scroll_offset = 0;
729        if !clear {
730            // already blank
731        }
732    }
733
734    fn leave_alt_screen(&mut self, _restore_cursor_style: bool) {
735        if !self.alt_screen {
736            return;
737        }
738        if let Some(saved) = self.saved_primary.take() {
739            self.rows = saved.rows;
740            self.cursor_row = saved.cursor_row.min(self.rows_count as usize - 1);
741            self.cursor_col = saved.cursor_col.min(self.cols as usize - 1);
742            self.scrollback = saved.scrollback;
743        } else {
744            self.rows = blank_grid(self.cols, self.rows_count);
745            self.cursor_row = 0;
746            self.cursor_col = 0;
747        }
748        self.alt_screen = false;
749        self.scroll_offset = 0;
750    }
751
752    fn erase_display(&mut self, mode: i32) {
753        if mode == 2 || mode == 3 {
754            for row in &mut self.rows {
755                for c in row.iter_mut() {
756                    *c = Cell::blank();
757                }
758            }
759            if mode == 2 {
760                self.cursor_row = 0;
761                self.cursor_col = 0;
762            }
763            if mode == 3 {
764                self.scrollback.clear();
765            }
766        } else if mode == 0 {
767            for c in self.cursor_col..self.cols as usize {
768                self.rows[self.cursor_row][c] = Cell::blank();
769            }
770            for r in self.cursor_row + 1..self.rows_count as usize {
771                for c in 0..self.cols as usize {
772                    self.rows[r][c] = Cell::blank();
773                }
774            }
775        } else if mode == 1 {
776            for r in 0..self.cursor_row {
777                for c in 0..self.cols as usize {
778                    self.rows[r][c] = Cell::blank();
779                }
780            }
781            for c in 0..=self.cursor_col.min(self.cols as usize - 1) {
782                self.rows[self.cursor_row][c] = Cell::blank();
783            }
784        }
785    }
786
787    fn erase_line(&mut self, mode: i32) {
788        let r = self.cursor_row;
789        let range: Box<dyn Iterator<Item = usize>> = if mode == 0 {
790            Box::new(self.cursor_col..self.cols as usize)
791        } else if mode == 1 {
792            Box::new(0..=self.cursor_col.min(self.cols as usize - 1))
793        } else {
794            Box::new(0..self.cols as usize)
795        };
796        for c in range {
797            self.rows[r][c] = Cell::blank();
798        }
799    }
800
801    fn scroll_up_one(&mut self) {
802        if self.rows.is_empty() {
803            return;
804        }
805        if !self.alt_screen {
806            self.scrollback.push(self.rows[0].clone());
807            if self.scrollback.len() > 5000 {
808                let drain = self.scrollback.len() - 5000;
809                self.scrollback.drain(0..drain);
810            }
811        }
812        self.rows.remove(0);
813        self.rows
814            .push(vec![Cell::blank(); self.cols as usize]);
815    }
816
817    fn scroll_down_one(&mut self) {
818        self.rows
819            .insert(0, vec![Cell::blank(); self.cols as usize]);
820        if self.rows.len() > self.rows_count as usize {
821            self.rows.pop();
822        }
823    }
824
825    fn apply_sgr(&mut self, modes: &[i32]) {
826        let modes: Vec<i32> = if modes.is_empty() {
827            vec![0]
828        } else {
829            modes.to_vec()
830        };
831        let mut idx = 0;
832        while idx < modes.len() {
833            match modes[idx] {
834                0 => {
835                    self.fg = Color::Default;
836                    self.bg = Color::Default;
837                    self.bold = false;
838                    self.reverse = false;
839                }
840                1 => self.bold = true,
841                2 | 22 => self.bold = false,
842                7 => self.reverse = true,
843                27 => self.reverse = false,
844                30..=37 => self.fg = ansi_to_color(modes[idx] - 30),
845                38 => {
846                    if let Some((c, skip)) = parse_ext_color(&modes[idx + 1..]) {
847                        self.fg = c;
848                        idx += skip;
849                    }
850                }
851                39 => self.fg = Color::Default,
852                40..=47 => self.bg = ansi_to_color(modes[idx] - 40),
853                48 => {
854                    if let Some((c, skip)) = parse_ext_color(&modes[idx + 1..]) {
855                        self.bg = c;
856                        idx += skip;
857                    }
858                }
859                49 => self.bg = Color::Default,
860                90..=97 => self.fg = bright_to_color(modes[idx] - 90),
861                100..=107 => self.bg = bright_to_color(modes[idx] - 100),
862                _ => {}
863            }
864            idx += 1;
865        }
866    }
867
868    fn newline(&mut self) {
869        if self.cursor_row + 1 >= self.rows_count as usize {
870            self.scroll_up_one();
871        } else {
872            self.cursor_row += 1;
873        }
874        self.cursor_col = 0;
875    }
876
877    fn write_char(&mut self, ch: char) {
878        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
879        if w == 0 {
880            return;
881        }
882        if self.cursor_col + w > self.cols as usize {
883            self.newline();
884        }
885        if self.cursor_row >= self.rows.len() || self.cursor_col >= self.cols as usize {
886            return;
887        }
888        let (mut fg, mut bg) = (self.fg, self.bg);
889        if self.reverse {
890            std::mem::swap(&mut fg, &mut bg);
891        }
892        let fg = if fg != Color::Default { Some(fg) } else { None };
893        let bg = if bg != Color::Default { Some(bg) } else { None };
894        self.rows[self.cursor_row][self.cursor_col] = Cell { ch, fg, bg };
895        if w >= 2 && self.cursor_col + 1 < self.cols as usize {
896            self.rows[self.cursor_row][self.cursor_col + 1] = Cell {
897                ch: ' ',
898                fg: None,
899                bg,
900            };
901        }
902        self.cursor_col += w;
903    }
904
905    /// Visible primary buffer rows as (char, fg, bg) with Default → black.
906    /// Cells → spans, skipping the spacer cell after a wide (CJK) char so a
907    /// 2-column glyph doesn't render as glyph + extra space.
908    fn row_cells_to_spans(
909        row: &[Cell],
910        force_black_bg: bool,
911    ) -> Vec<(String, Option<ratatui::style::Color>, Option<ratatui::style::Color>)> {
912        // Run-group consecutive same-style cells: a typical row collapses from
913        // ~200 one-char Strings to a handful of spans (this runs every frame).
914        let mut out: Vec<(String, Option<ratatui::style::Color>, Option<ratatui::style::Color>)> =
915            Vec::new();
916        let mut i = 0;
917        while i < row.len() {
918            let cell = &row[i];
919            let w = UnicodeWidthChar::width(cell.ch).unwrap_or(1).max(1);
920            // Always resolve so empty cells paint pure black
921            let fg = Some(cell.fg.unwrap_or(Color::Default).to_ratatui_fg());
922            let bg = if force_black_bg {
923                Some(Color::Default.to_ratatui())
924            } else {
925                Some(cell.bg.unwrap_or(Color::Default).to_ratatui())
926            };
927            match out.last_mut() {
928                Some((run, rfg, rbg)) if *rfg == fg && *rbg == bg => run.push(cell.ch),
929                _ => out.push((cell.ch.to_string(), fg, bg)),
930            }
931            i += w;
932        }
933        out
934    }
935
936    pub fn visible_rows(
937        &self,
938    ) -> Vec<Vec<(String, Option<ratatui::style::Color>, Option<ratatui::style::Color>)>> {
939        self.rows
940            .iter()
941            .map(|row| Self::row_cells_to_spans(row, false))
942            .collect()
943    }
944
945    /// Inner app (claude/vim/htop…) asked for mouse events — forward wheel.
946    pub fn wants_mouse(&self) -> bool {
947        self.mouse_reporting
948    }
949
950    /// Arrow-key bytes matching the inner app's cursor-key mode (DECCKM).
951    pub fn arrow_seq(&self, dir: char) -> &'static [u8] {
952        match (self.app_cursor_keys, dir) {
953            (true, 'A') => b"\x1bOA",
954            (true, 'B') => b"\x1bOB",
955            (true, 'C') => b"\x1bOC",
956            (true, 'D') => b"\x1bOD",
957            (false, 'A') => b"\x1b[A",
958            (false, 'B') => b"\x1b[B",
959            (false, 'C') => b"\x1b[C",
960            _ => b"\x1b[D",
961        }
962    }
963
964    pub fn scroll(&self) -> usize {
965        // Alt-screen TUIs shouldn't show scrollback
966        if self.alt_screen {
967            0
968        } else {
969            self.scroll_offset
970        }
971    }
972    pub fn scroll_up(&mut self, a: usize) {
973        if self.alt_screen {
974            return;
975        }
976        self.scroll_offset = self
977            .scroll_offset
978            .saturating_add(a)
979            .min(self.scrollback.len());
980    }
981    pub fn scroll_down(&mut self, a: usize) {
982        if self.alt_screen {
983            return;
984        }
985        self.scroll_offset = self.scroll_offset.saturating_sub(a);
986    }
987    pub fn scrollback_len(&self) -> usize {
988        if self.alt_screen {
989            0
990        } else {
991            self.scrollback.len()
992        }
993    }
994
995    pub fn visible_scrollback(
996        &self,
997    ) -> Vec<Vec<(String, Option<ratatui::style::Color>, Option<ratatui::style::Color>)>> {
998        if self.alt_screen {
999            return Vec::new();
1000        }
1001        self.scrollback
1002            .iter()
1003            .map(|row| Self::row_cells_to_spans(row, true))
1004            .collect()
1005    }
1006
1007    pub fn cursor_position(&self) -> (u16, u16) {
1008        (self.cursor_col as u16, self.cursor_row as u16)
1009    }
1010
1011    pub fn is_alt_screen(&self) -> bool {
1012        self.alt_screen
1013    }
1014}
1015
1016impl Color {
1017    fn to_ratatui_fg(self) -> ratatui::style::Color {
1018        match self {
1019            Color::Default => ratatui::style::Color::Rgb(200, 200, 200),
1020            other => other.to_ratatui(),
1021        }
1022    }
1023}
1024
1025enum Consume {
1026    Advanced(usize),
1027    NeedMore,
1028}
1029
1030fn parse_csi_params(bytes: &[u8]) -> Vec<i32> {
1031    let s = String::from_utf8_lossy(bytes);
1032    if s.is_empty() {
1033        return Vec::new();
1034    }
1035    let mut out = Vec::new();
1036    for part in s.split(';') {
1037        if part.is_empty() {
1038            out.push(0);
1039            continue;
1040        }
1041        if part.contains(':') {
1042            for sub in part.split(':') {
1043                out.push(sub.parse::<i32>().unwrap_or(0));
1044            }
1045        } else {
1046            out.push(part.parse::<i32>().unwrap_or(0));
1047        }
1048    }
1049    out
1050}
1051
1052fn parse_ext_color(rest: &[i32]) -> Option<(Color, usize)> {
1053    if rest.is_empty() {
1054        return None;
1055    }
1056    match rest[0] {
1057        5 if rest.len() >= 2 => Some((index_to_color(rest[1]), 2)),
1058        2 if rest.len() >= 4 => {
1059            let r = rest[1].clamp(0, 255) as u8;
1060            let g = rest[2].clamp(0, 255) as u8;
1061            let b = rest[3].clamp(0, 255) as u8;
1062            Some((Color::Rgb(r, g, b), 4))
1063        }
1064        _ => Some((Color::Default, 1)),
1065    }
1066}
1067
1068fn ansi_to_color(i: i32) -> Color {
1069    match i {
1070        0 => Color::Black,
1071        1 => Color::Red,
1072        2 => Color::Green,
1073        3 => Color::Yellow,
1074        4 => Color::Blue,
1075        5 => Color::Magenta,
1076        6 => Color::Cyan,
1077        7 => Color::White,
1078        _ => Color::Default,
1079    }
1080}
1081fn bright_to_color(i: i32) -> Color {
1082    match i {
1083        0 => Color::BrightBlack,
1084        1 => Color::BrightRed,
1085        2 => Color::BrightGreen,
1086        3 => Color::BrightYellow,
1087        4 => Color::BrightBlue,
1088        5 => Color::BrightMagenta,
1089        6 => Color::BrightCyan,
1090        7 => Color::BrightWhite,
1091        _ => Color::Default,
1092    }
1093}
1094fn index_to_color(i: i32) -> Color {
1095    match i {
1096        0..=7 => ansi_to_color(i),
1097        8..=15 => bright_to_color(i - 8),
1098        16..=231 => {
1099            let n = i - 16;
1100            let r = ((n / 36) % 6) * 51;
1101            let g = ((n / 6) % 6) * 51;
1102            let b = (n % 6) * 51;
1103            Color::Rgb(r as u8, g as u8, b as u8)
1104        }
1105        232..=255 => {
1106            let v = ((i - 232) * 10 + 8).clamp(0, 255) as u8;
1107            Color::Rgb(v, v, v)
1108        }
1109        _ => Color::Default,
1110    }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116
1117    #[test]
1118    fn utf8_box_drawing_not_mojibake() {
1119        let mut t = Terminal::new();
1120        t.process_output(&[0xe2, 0x94, 0x80]);
1121        assert_eq!(t.rows[0][0].ch, '─');
1122    }
1123
1124    #[test]
1125    fn osc_title_is_swallowed() {
1126        let mut t = Terminal::new();
1127        let mut seq = b"\x1b]0;hello\x07".to_vec();
1128        seq.extend_from_slice(b"ok");
1129        t.process_output(&seq);
1130        assert_eq!(t.rows[0][0].ch, 'o');
1131        assert_eq!(t.rows[0][1].ch, 'k');
1132    }
1133
1134    #[test]
1135    fn incomplete_utf8_held_across_chunks() {
1136        let mut t = Terminal::new();
1137        t.process_output(&[0xe2]);
1138        t.process_output(&[0x94, 0x80]);
1139        assert_eq!(t.rows[0][0].ch, '─');
1140    }
1141
1142    #[test]
1143    fn alt_screen_enter_leave() {
1144        let mut t = Terminal::new();
1145        t.process_output(b"hello");
1146        assert_eq!(t.rows[0][0].ch, 'h');
1147        // CSI ? 1049 h
1148        t.process_output(b"\x1b[?1049h");
1149        assert!(t.alt_screen);
1150        assert_eq!(t.rows[0][0].ch, ' ');
1151        t.process_output(b"alt");
1152        assert_eq!(t.rows[0][0].ch, 'a');
1153        // CSI ? 1049 l
1154        t.process_output(b"\x1b[?1049l");
1155        assert!(!t.alt_screen);
1156        assert_eq!(t.rows[0][0].ch, 'h');
1157    }
1158
1159    #[test]
1160    fn cup_and_clear() {
1161        let mut t = Terminal::new();
1162        t.process_output(b"\x1b[10;5H*");
1163        assert_eq!(t.cursor_row, 9);
1164        assert_eq!(t.cursor_col, 5); // after writing *
1165        assert_eq!(t.rows[9][4].ch, '*');
1166        t.process_output(b"\x1b[2J");
1167        assert_eq!(t.rows[9][4].ch, ' ');
1168        assert_eq!(t.cursor_row, 0);
1169    }
1170}