repartee 0.9.1

A modern terminal IRC client built with Ratatui and Tokio
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
pub mod types;

use std::collections::HashMap;
use std::io::{Read, Write};

use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use tokio::sync::mpsc;

pub use types::ShellEvent;

/// Maximum number of concurrent web shell sessions.
const MAX_WEB_SESSIONS: usize = 10;

/// A single shell session backed by a PTY and a vt100 terminal emulator.
struct ShellSession {
    parser: vt100::Parser,
    writer: Box<dyn Write + Send>,
    child: Box<dyn portable_pty::Child + Send>,
    /// Master PTY handle — kept alive for resize (SIGWINCH) propagation.
    master: Box<dyn portable_pty::MasterPty + Send>,
    /// Buffer ID this session is attached to (e.g. "shell/zsh").
    buffer_id: String,
    /// Display label for the shell (e.g. "zsh", "htop").
    label: String,
}

/// Manages all active shell sessions and their PTY connections.
///
/// TUI sessions (`sessions`) are rendered in the ratatui terminal.
/// Web sessions (`web_sessions`) are independent PTYs streamed to web clients.
/// They are separate processes — no resize fighting between views.
pub struct ShellManager {
    sessions: HashMap<String, ShellSession>,
    /// Web-specific PTY sessions, keyed by web session UUID.
    web_sessions: HashMap<String, ShellSession>,
    event_tx: mpsc::Sender<ShellEvent>,
    next_id: u32,
}

impl ShellManager {
    pub fn new() -> (Self, mpsc::Receiver<ShellEvent>) {
        let (event_tx, event_rx) = mpsc::channel(256);
        (
            Self {
                sessions: HashMap::new(),
                web_sessions: HashMap::new(),
                event_tx,
                next_id: 0,
            },
            event_rx,
        )
    }

    /// Open a new shell session. Returns `(shell_id, label)` on success.
    ///
    /// `command` defaults to `$SHELL` if `None`.
    /// The PTY is sized to `cols × rows` and a reader thread is spawned
    /// to forward output into the event channel.
    pub fn open(
        &mut self,
        cols: u16,
        rows: u16,
        command: Option<&str>,
        buffer_id: &str,
    ) -> Result<(String, String), String> {
        let id = format!("shell-{}", self.next_id);
        self.next_id += 1;

        let pty_system = NativePtySystem::default();
        let pair = pty_system
            .openpty(PtySize {
                rows,
                cols,
                pixel_width: 0,
                pixel_height: 0,
            })
            .map_err(|e| format!("Failed to open PTY: {e}"))?;

        let full_command = command
            .map(String::from)
            .or_else(|| std::env::var("SHELL").ok())
            .unwrap_or_else(|| "/bin/sh".to_string());

        // Split command into program + arguments for multi-word commands
        // (e.g. "vim /etc/hosts" → program="vim", args=["/etc/hosts"]).
        let mut parts = full_command.split_whitespace();
        let program = parts.next().unwrap_or("/bin/sh");

        let label = std::path::Path::new(program)
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("shell")
            .to_string();

        let mut cmd = CommandBuilder::new(program);
        for arg in parts {
            cmd.arg(arg);
        }
        // TERM tells programs what escape sequences the terminal supports.
        // xterm-256color is the standard for modern 256-color terminals.
        cmd.env("TERM", "xterm-256color");
        cmd.cwd(std::env::current_dir().unwrap_or_else(|_| "/".into()));

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| format!("Failed to spawn shell: {e}"))?;

        // Drop the slave side — only the master is needed after spawn.
        drop(pair.slave);

        let writer = pair
            .master
            .take_writer()
            .map_err(|e| format!("Failed to get PTY writer: {e}"))?;

        // 1000 lines of scrollback.
        let parser = vt100::Parser::new(rows, cols, 1000);

        // Spawn a dedicated reader thread (blocking I/O — not safe for tokio thread pool).
        let mut reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
        let tx = self.event_tx.clone();
        // Arc<str> avoids a String clone per read in the hot output path.
        let reader_id: std::sync::Arc<str> = id.as_str().into();
        std::thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) | Err(_) => {
                        let _ = tx.blocking_send(ShellEvent::Exited {
                            id: reader_id,
                            status: None,
                        });
                        break;
                    }
                    Ok(n) => {
                        if tx
                            .blocking_send(ShellEvent::Output {
                                id: std::sync::Arc::clone(&reader_id),
                                bytes: buf[..n].to_vec(),
                            })
                            .is_err()
                        {
                            break;
                        }
                    }
                }
            }
        });

        let label_out = label.clone();
        self.sessions.insert(
            id.clone(),
            ShellSession {
                parser,
                writer,
                child,
                master: pair.master,
                buffer_id: buffer_id.to_string(),
                label,
            },
        );

        Ok((id, label_out))
    }

    /// Close a shell session, killing the child process.
    pub fn close(&mut self, id: &str) {
        if let Some(mut session) = self.sessions.remove(id) {
            let _ = session.child.kill();
        }
    }

    /// Write raw bytes to a shell's PTY master.
    pub fn write(&mut self, id: &str, data: &[u8]) {
        if let Some(session) = self.sessions.get_mut(id) {
            let _ = session.writer.write_all(data);
            let _ = session.writer.flush();
        }
    }

    /// Resize a shell's PTY and vt100 parser.
    /// Sends SIGWINCH to the child process via the OS PTY resize call.
    pub fn resize(&mut self, id: &str, cols: u16, rows: u16) {
        if let Some(session) = self.sessions.get_mut(id) {
            session.parser.screen_mut().set_size(rows, cols);
            // OS PTY resize — sends SIGWINCH to the child process.
            let _ = session.master.resize(PtySize {
                rows,
                cols,
                pixel_width: 0,
                pixel_height: 0,
            });
        }
    }

    /// Feed raw output bytes into the vt100 terminal emulator.
    ///
    /// Rewrites `CSI f` (HVP) to `CSI H` (CUP) before parsing, because the
    /// vt100 crate does not handle HVP. Both are functionally identical
    /// (absolute cursor positioning). Programs like btop use HVP exclusively.
    pub fn process_output(&mut self, id: &str, bytes: &[u8]) {
        let Some(session) = self.sessions.get_mut(id) else {
            return;
        };
        // Fast path: no 'f' byte means no HVP sequences to rewrite.
        if !bytes.contains(&b'f') {
            session.parser.process(bytes);
            return;
        }
        // Rewrite CSI...f → CSI...H for vt100 compatibility.
        let patched = rewrite_hvp_to_cup(bytes);
        session.parser.process(&patched);
    }

    /// Get the vt100 screen for rendering.
    pub fn screen(&self, id: &str) -> Option<&vt100::Screen> {
        self.sessions.get(id).map(|s| s.parser.screen())
    }

    /// Return the current PTY column count for a shell session.
    pub fn screen_cols(&self, id: &str) -> u16 {
        self.sessions
            .get(id)
            .map_or(80, |s| s.parser.screen().size().1)
    }

    /// Serialize a TUI shell screen for web transport.
    pub fn screen_to_web(
        &self,
        id: &str,
    ) -> Option<(Vec<crate::web::protocol::ShellScreenRow>, u16, u16, bool)> {
        let screen = self.sessions.get(id)?.parser.screen();
        Some(Self::serialize_screen(screen))
    }

    /// Convert a vt100 screen to styled rows for web transport.
    #[expect(
        clippy::similar_names,
        reason = "fg/bg are standard terminal color abbreviations"
    )]
    fn serialize_screen(
        screen: &vt100::Screen,
    ) -> (Vec<crate::web::protocol::ShellScreenRow>, u16, u16, bool) {
        let (screen_rows, screen_cols) = screen.size();
        let (cursor_row, cursor_col) = screen.cursor_position();
        let cursor_visible = !screen.hide_cursor();

        let mut rows = Vec::with_capacity(screen_rows as usize);

        for row in 0..screen_rows {
            let mut spans: Vec<crate::web::protocol::ShellSpan> = Vec::new();
            let mut cur_text = String::new();
            // B3 fix: track raw vt100::Color (Copy) to avoid String allocation
            // per cell. Only convert to CSS string when the color actually changes.
            let mut cur_fg_raw = vt100::Color::Default;
            let mut cur_bg_raw = vt100::Color::Default;
            let mut cur_fg = String::new();
            let mut cur_bg = String::new();
            let mut cur_bold = false;
            let mut cur_italic = false;
            let mut cur_underline = false;
            let mut cur_inverse = false;

            for col in 0..screen_cols {
                let Some(cell) = screen.cell(row, col) else {
                    continue;
                };
                let ch = cell.contents();

                let fg_raw = cell.fgcolor();
                let bg_raw = cell.bgcolor();
                let bold = cell.bold();
                let italic = cell.italic();
                let underline = cell.underline();
                let inverse = cell.inverse();

                // Only allocate CSS string when the raw color changed.
                let fg_changed = fg_raw != cur_fg_raw;
                let bg_changed = bg_raw != cur_bg_raw;

                // If style changed, flush current span and start new one.
                if !cur_text.is_empty()
                    && (fg_changed
                        || bg_changed
                        || bold != cur_bold
                        || italic != cur_italic
                        || underline != cur_underline
                        || inverse != cur_inverse)
                {
                    spans.push(crate::web::protocol::ShellSpan {
                        text: std::mem::take(&mut cur_text),
                        fg: std::mem::take(&mut cur_fg),
                        bg: std::mem::take(&mut cur_bg),
                        bold: cur_bold,
                        italic: cur_italic,
                        underline: cur_underline,
                        inverse: cur_inverse,
                    });
                    cur_bold = bold;
                    cur_italic = italic;
                    cur_underline = underline;
                    cur_inverse = inverse;
                    // After take(), cur_fg/cur_bg are empty. Re-set from raw.
                    cur_fg = vt100_color_to_css(fg_raw);
                    cur_bg = vt100_color_to_css(bg_raw);
                    cur_fg_raw = fg_raw;
                    cur_bg_raw = bg_raw;
                } else {
                    // No flush — only convert when color actually changed.
                    if fg_changed {
                        cur_fg_raw = fg_raw;
                        cur_fg = vt100_color_to_css(fg_raw);
                    }
                    if bg_changed {
                        cur_bg_raw = bg_raw;
                        cur_bg = vt100_color_to_css(bg_raw);
                    }
                }
                if cur_text.is_empty() {
                    cur_bold = bold;
                    cur_italic = italic;
                    cur_underline = underline;
                    cur_inverse = inverse;
                }

                if ch.is_empty() {
                    cur_text.push(' ');
                } else {
                    cur_text.push_str(ch);
                }
            }

            // Flush final span. Only trim trailing spaces if the span has
            // default styling — styled spaces (colored background, etc.)
            // must be preserved for status bars and separators.
            let has_styling = !cur_bg.is_empty()
                || !cur_fg.is_empty()
                || cur_bold
                || cur_italic
                || cur_underline
                || cur_inverse;
            let text = if has_styling {
                cur_text
            } else {
                cur_text.trim_end().to_string()
            };
            if !text.is_empty() {
                spans.push(crate::web::protocol::ShellSpan {
                    text,
                    fg: cur_fg,
                    bg: cur_bg,
                    bold: cur_bold,
                    italic: cur_italic,
                    underline: cur_underline,
                    inverse: cur_inverse,
                });
            }

            rows.push(crate::web::protocol::ShellScreenRow { spans });
        }

        (rows, cursor_row, cursor_col, cursor_visible)
    }

    /// Get the buffer ID associated with a shell session.
    pub fn buffer_id(&self, id: &str) -> Option<&str> {
        self.sessions.get(id).map(|s| s.buffer_id.as_str())
    }

    /// Get the display label for a shell session (e.g. "zsh", "htop").
    pub fn label(&self, id: &str) -> Option<&str> {
        self.sessions.get(id).map(|s| s.label.as_str())
    }

    /// Find a shell session ID by its buffer ID.
    pub fn session_id_for_buffer(&self, buffer_id: &str) -> Option<&str> {
        self.sessions
            .iter()
            .find(|(_, s)| s.buffer_id == buffer_id)
            .map(|(id, _)| id.as_str())
    }

    /// Return the number of active shell sessions.
    pub fn session_count(&self) -> usize {
        self.sessions.len()
    }

    /// List all sessions as `(shell_id, buffer_id, label)` tuples.
    pub fn list_sessions(&self) -> Vec<(&str, &str, &str)> {
        self.sessions
            .iter()
            .map(|(id, s)| (id.as_str(), s.buffer_id.as_str(), s.label.as_str()))
            .collect()
    }

    /// Kill all shell processes. Called on app shutdown.
    pub fn kill_all(&mut self) {
        for session in self.sessions.values_mut() {
            let _ = session.child.kill();
        }
        self.sessions.clear();
        for session in self.web_sessions.values_mut() {
            let _ = session.child.kill();
        }
        self.web_sessions.clear();
    }

    // ── Web-specific PTY sessions ────────────────────────────────────────

    /// Open a web-specific shell PTY, independent from the TUI session.
    /// Returns the web shell ID on success.
    pub fn open_web(
        &mut self,
        web_session_id: &str,
        cols: u16,
        rows: u16,
    ) -> Result<String, String> {
        let id = format!("web-{web_session_id}");
        if self.web_sessions.contains_key(&id) {
            return Ok(id);
        }
        if self.web_sessions.len() >= MAX_WEB_SESSIONS {
            return Err(format!(
                "maximum web shell sessions ({MAX_WEB_SESSIONS}) reached"
            ));
        }

        let pty_system = NativePtySystem::default();
        let pair = pty_system
            .openpty(PtySize {
                rows,
                cols,
                pixel_width: 0,
                pixel_height: 0,
            })
            .map_err(|e| format!("Failed to open web PTY: {e}"))?;

        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
        let program = std::path::Path::new(&shell)
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("sh");
        let label = program.to_string();

        let mut cmd = CommandBuilder::new(&shell);
        cmd.env("TERM", "xterm-256color");
        cmd.cwd(std::env::current_dir().unwrap_or_else(|_| "/".into()));

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| format!("Failed to spawn web shell: {e}"))?;
        drop(pair.slave);

        let writer = pair
            .master
            .take_writer()
            .map_err(|e| format!("Failed to get web PTY writer: {e}"))?;
        let parser = vt100::Parser::new(rows, cols, 1000);

        // Reader thread with "web-" prefixed ID so handle_shell_event routes correctly.
        let mut reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| format!("Failed to clone web PTY reader: {e}"))?;
        let tx = self.event_tx.clone();
        let reader_id: std::sync::Arc<str> = id.as_str().into();
        std::thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) | Err(_) => {
                        let _ = tx.blocking_send(ShellEvent::Exited {
                            id: reader_id,
                            status: None,
                        });
                        break;
                    }
                    Ok(n) => {
                        if tx
                            .blocking_send(ShellEvent::Output {
                                id: std::sync::Arc::clone(&reader_id),
                                bytes: buf[..n].to_vec(),
                            })
                            .is_err()
                        {
                            break;
                        }
                    }
                }
            }
        });

        self.web_sessions.insert(
            id.clone(),
            ShellSession {
                parser,
                writer,
                child,
                master: pair.master,
                buffer_id: String::new(),
                label,
            },
        );

        Ok(id)
    }

    /// Close a web shell session.
    pub fn close_web(&mut self, id: &str) {
        if let Some(mut session) = self.web_sessions.remove(id) {
            let _ = session.child.kill();
        }
    }

    /// Close all web sessions for a given web session UUID.
    pub fn close_web_by_session(&mut self, web_session_id: &str) {
        let key = format!("web-{web_session_id}");
        self.close_web(&key);
    }

    /// Write to a web shell PTY.
    pub fn write_web(&mut self, id: &str, data: &[u8]) {
        if let Some(session) = self.web_sessions.get_mut(id) {
            let _ = session.writer.write_all(data);
            let _ = session.writer.flush();
        }
    }

    /// Resize a web shell PTY.
    pub fn resize_web(&mut self, id: &str, cols: u16, rows: u16) {
        if let Some(session) = self.web_sessions.get_mut(id) {
            session.parser.screen_mut().set_size(rows, cols);
            let _ = session.master.resize(PtySize {
                rows,
                cols,
                pixel_width: 0,
                pixel_height: 0,
            });
        }
    }

    /// Process output for a web shell session.
    pub fn process_output_web(&mut self, id: &str, bytes: &[u8]) {
        let Some(session) = self.web_sessions.get_mut(id) else {
            return;
        };
        if !bytes.contains(&b'f') {
            session.parser.process(bytes);
            return;
        }
        let patched = rewrite_hvp_to_cup(bytes);
        session.parser.process(&patched);
    }

    /// Serialize a web shell screen for transport.
    pub fn screen_to_web_session(
        &self,
        id: &str,
    ) -> Option<(Vec<crate::web::protocol::ShellScreenRow>, u16, u16, bool)> {
        let session = self.web_sessions.get(id)?;
        let screen = session.parser.screen();
        Some(Self::serialize_screen(screen))
    }

    /// Get the column count for a web session.
    pub fn screen_cols_web(&self, id: &str) -> u16 {
        self.web_sessions
            .get(id)
            .map_or(80, |s| s.parser.screen().size().1)
    }

    /// Check if a shell event ID belongs to a web session.
    #[expect(
        clippy::unused_self,
        reason = "method semantically belongs on ShellManager — may use self in the future"
    )]
    pub fn is_web_session(&self, id: &str) -> bool {
        id.starts_with("web-")
    }

    /// Check if a web session exists for the given ID.
    pub fn has_web_session(&self, id: &str) -> bool {
        self.web_sessions.contains_key(id)
    }
}

/// Convert a vt100 color to a CSS color string.
/// Returns empty string for default (lets the web frontend use its own default).
fn vt100_color_to_css(color: vt100::Color) -> String {
    match color {
        vt100::Color::Default => String::new(),
        // All indexed colors (0-255) sent as ansi(N) so the web frontend
        // applies its own theme palette (ghostty colors).
        vt100::Color::Idx(n) => format!("ansi({n})"),
        vt100::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
    }
}

/// Rewrite `CSI ... f` (HVP) sequences to `CSI ... H` (CUP).
///
/// The vt100 crate handles CUP but not HVP, despite them being functionally
/// identical (ECMA-48 §8.3.25 / §8.3.21). This performs a single pass over
/// the byte stream, tracking CSI state to only replace `f` when it is the
/// final byte of a CSI sequence.
///
/// CSI format: `\x1b[` followed by parameter bytes (0x30..=0x3F),
/// then optional intermediate bytes (0x20..=0x2F), then a final byte (0x40..=0x7E).
fn rewrite_hvp_to_cup(input: &[u8]) -> Vec<u8> {
    let mut output = Vec::with_capacity(input.len());
    let mut i = 0;

    while i < input.len() {
        // Look for ESC [ (CSI introducer).
        if input[i] == 0x1b && i + 1 < input.len() && input[i + 1] == b'[' {
            // Start of CSI sequence. Copy the introducer.
            output.push(0x1b);
            output.push(b'[');
            i += 2;

            // Skip parameter bytes (0x30..=0x3F: digits, semicolons, etc.)
            // and intermediate bytes (0x20..=0x2F).
            while i < input.len() && (0x20..=0x3F).contains(&input[i]) {
                output.push(input[i]);
                i += 1;
            }

            // Final byte (0x40..=0x7E).
            if i < input.len() && (0x40..=0x7E).contains(&input[i]) {
                if input[i] == b'f' {
                    output.push(b'H'); // HVP → CUP
                } else {
                    output.push(input[i]);
                }
                i += 1;
            }
        } else {
            output.push(input[i]);
            i += 1;
        }
    }

    output
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn shell_manager_new_has_no_sessions() {
        let (mgr, _rx) = ShellManager::new();
        assert_eq!(mgr.session_count(), 0);
    }

    #[test]
    fn shell_manager_open_creates_session() {
        let (mut mgr, _rx) = ShellManager::new();
        let result = mgr.open(80, 24, Some("/bin/sh"), "shell/sh");
        assert!(result.is_ok(), "open failed: {:?}", result.err());
        assert_eq!(mgr.session_count(), 1);
        mgr.kill_all();
    }

    #[test]
    fn shell_manager_close_removes_session() {
        let (mut mgr, _rx) = ShellManager::new();
        let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
        assert_eq!(mgr.session_count(), 1);
        mgr.close(&id);
        assert_eq!(mgr.session_count(), 0);
    }

    #[test]
    fn shell_manager_session_id_for_buffer() {
        let (mut mgr, _rx) = ShellManager::new();
        let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
        assert_eq!(mgr.session_id_for_buffer("shell/sh"), Some(id.as_str()));
        assert_eq!(mgr.session_id_for_buffer("nonexistent"), None);
        mgr.kill_all();
    }

    #[test]
    fn shell_manager_buffer_id_and_label() {
        let (mut mgr, _rx) = ShellManager::new();
        let (id, label) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
        assert_eq!(mgr.buffer_id(&id), Some("shell/sh"));
        assert_eq!(label, "sh");
        assert_eq!(mgr.label(&id), Some("sh"));
        mgr.kill_all();
    }

    #[test]
    fn shell_manager_list_sessions() {
        let (mut mgr, _rx) = ShellManager::new();
        let _ = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
        let sessions = mgr.list_sessions();
        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].1, "shell/sh");
        assert_eq!(sessions[0].2, "sh");
        mgr.kill_all();
    }

    #[test]
    fn shell_manager_kill_all_clears_sessions() {
        let (mut mgr, _rx) = ShellManager::new();
        let _ = mgr.open(80, 24, Some("/bin/sh"), "shell/sh1").unwrap();
        let _ = mgr.open(80, 24, Some("/bin/sh"), "shell/sh2").unwrap();
        assert_eq!(mgr.session_count(), 2);
        mgr.kill_all();
        assert_eq!(mgr.session_count(), 0);
    }

    #[test]
    fn shell_manager_process_output_updates_screen() {
        let (mut mgr, _rx) = ShellManager::new();
        let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();

        // Feed known text directly into the vt100 parser.
        mgr.process_output(&id, b"hello world");

        let screen = mgr.screen(&id).unwrap();
        let contents = screen.contents();
        assert!(
            contents.contains("hello world"),
            "screen should contain 'hello world', got: {contents:?}"
        );
        mgr.kill_all();
    }

    #[test]
    fn shell_manager_resize_updates_parser() {
        let (mut mgr, _rx) = ShellManager::new();
        let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();

        let screen = mgr.screen(&id).unwrap();
        assert_eq!(screen.size(), (24, 80));

        mgr.resize(&id, 120, 40);

        let screen = mgr.screen(&id).unwrap();
        assert_eq!(screen.size(), (40, 120));
        mgr.kill_all();
    }

    #[test]
    fn shell_manager_screen_returns_none_for_unknown_id() {
        let (mgr, _rx) = ShellManager::new();
        assert!(mgr.screen("nonexistent").is_none());
    }

    #[test]
    fn shell_manager_open_returns_label_from_command() {
        let (mut mgr, _rx) = ShellManager::new();
        let (_, label) = mgr.open(80, 24, Some("/usr/bin/env"), "shell/env").unwrap();
        assert_eq!(label, "env");
        mgr.kill_all();
    }

    #[test]
    fn shell_manager_multiple_opens_get_unique_ids() {
        let (mut mgr, _rx) = ShellManager::new();
        let (id1, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh1").unwrap();
        let (id2, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh2").unwrap();
        assert_ne!(id1, id2);
        assert_eq!(mgr.session_count(), 2);
        mgr.kill_all();
    }

    // ── rewrite_hvp_to_cup tests ──────────────────────────────────────────

    #[test]
    fn hvp_rewrite_converts_csi_f_to_csi_h() {
        // CSI 5;10 f → CSI 5;10 H
        let input = b"\x1b[5;10f";
        let output = rewrite_hvp_to_cup(input);
        assert_eq!(output, b"\x1b[5;10H");
    }

    #[test]
    fn hvp_rewrite_preserves_plain_text_with_f() {
        let input = b"hello from foo";
        let output = rewrite_hvp_to_cup(input);
        assert_eq!(output, b"hello from foo");
    }

    #[test]
    fn hvp_rewrite_preserves_other_csi_sequences() {
        // CSI 2 J (clear screen) should not be modified.
        let input = b"\x1b[2J";
        let output = rewrite_hvp_to_cup(input);
        assert_eq!(output, b"\x1b[2J");
    }

    #[test]
    fn hvp_rewrite_handles_mixed_content() {
        // Text, then HVP, then more text, then a normal CSI.
        let input = b"abc\x1b[1;1fxyz\x1b[0m";
        let output = rewrite_hvp_to_cup(input);
        assert_eq!(output, b"abc\x1b[1;1Hxyz\x1b[0m");
    }

    #[test]
    fn hvp_rewrite_handles_bare_csi_f() {
        // CSI f with no params → CSI H (default position 1;1).
        let input = b"\x1b[f";
        let output = rewrite_hvp_to_cup(input);
        assert_eq!(output, b"\x1b[H");
    }

    #[test]
    fn hvp_rewrite_multiple_hvp_in_sequence() {
        let input = b"\x1b[1;1f\x1b[2;5f\x1b[10;20f";
        let output = rewrite_hvp_to_cup(input);
        assert_eq!(output, b"\x1b[1;1H\x1b[2;5H\x1b[10;20H");
    }

    #[test]
    fn hvp_rewrite_does_not_touch_sgr_with_f_in_params() {
        // CSI 38;2;255;0;0 m — the '0' params contain no 'f', final byte is 'm'.
        let input = b"\x1b[38;2;255;0;0m";
        let output = rewrite_hvp_to_cup(input);
        assert_eq!(output, b"\x1b[38;2;255;0;0m");
    }

    #[test]
    fn hvp_process_output_applies_cursor_position() {
        let (mut mgr, _rx) = ShellManager::new();
        let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();

        // Feed HVP sequence to move cursor to row 5, col 10 (1-based).
        mgr.process_output(&id, b"\x1b[5;10f");

        let screen = mgr.screen(&id).unwrap();
        // vt100 cursor_position() returns 0-based (row, col).
        assert_eq!(screen.cursor_position(), (4, 9));
        mgr.kill_all();
    }
}