Skip to main content

oo_ide/views/
terminal.rs

1//! Terminal view — full-screen view hosting one or more PTY-backed tabs.
2//!
3//! Layout (no separators, maximum terminal space):
4//!
5//!   ┌──────────────────────────────────────┐
6//!   │  bash  ▏  cargo test  ▏  zsh         │   ← 1 row: TerminalTabBar
7//!   │$                                     │   ← remaining rows: TerminalWidget
8//!   │                                      │
9//!   └──────────────────────────────────────┘
10//!
11//! Key bindings (normal mode):
12//!   Ctrl+T          — new tab
13//!   Ctrl+W          — close active tab
14//!   Ctrl+R          — open rename/close popup for active tab
15//!   Alt+Left/Right  — switch tabs
16//!   PgUp / PgDn     — scroll scrollback
17//!   Esc             — return to editor / file selector
18//!   Everything else — forwarded to the PTY as raw bytes
19
20use std::cell::{Cell, RefCell};
21use std::io::Write as _;
22use std::path::{Path, PathBuf};
23
24use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
25use ratatui::{
26    Frame,
27    layout::Rect,
28    style::{Color, Style},
29    widgets::{Block, Borders, Paragraph},
30};
31
32/// Strip ANSI/VT100 escape sequences from a string, returning plain text.
33///
34/// Handles CSI sequences (`\x1b[...`), OSC sequences (`\x1b]...`), and
35/// simple two-character escapes.  Used to produce plain text for diagnostic
36/// extraction from raw PTY output.
37/// Extract diagnostic issues from a `StyledLine`, handling two formats:
38///
39/// 1. **GNU-style** (`path:line:col: error: msg`) — matched directly.
40/// 2. **rustc/cargo multi-line** — the caller passes a `prev_sev` slot:
41///    - A severity-header line (`error[E0425]: msg`) sets `prev_sev`.
42///    - The following ` --> path:line:col` arrow line consumes `prev_sev`
43///      and produces the combined issue.
44fn extract_issues_with_state(
45    ex: &crate::diagnostics_extractor::DiagnosticsExtractor,
46    line: &crate::vt_parser::StyledLine,
47    prev_sev: &mut Option<(crate::issue_registry::Severity, String)>,
48) -> Vec<crate::issue_registry::NewIssue> {
49    let text = &line.text;
50
51    // Try rustc arrow line: ` --> path:line:col`
52    if let Some((path, ln, col)) = ex.try_rustc_arrow(text) {
53        if let Some((sev, msg)) = prev_sev.take() {
54            return vec![ex.make_issue(sev, msg, Some(path), Some(ln), Some(col))];
55        }
56        // Arrow without a preceding header — ignore.
57        return Vec::new();
58    }
59
60    // Try rustc header line: `error[...]: msg` or `warning: msg`
61    if let Some(header) = ex.try_rustc_header(text) {
62        *prev_sev = Some(header);
63        return Vec::new();
64    }
65
66    // Non-rustc line: clear any buffered header and try GNU patterns.
67    *prev_sev = None;
68    ex.extract_from_line(line)
69}
70
71const MAX_SCROLL_STEP: u16 = 1000;
72/// Default scroll size when settings not loaded
73const DEFAULT_SCROLL_SIZE: u16 = 5;
74/// Maximum configurable scroll size
75const MAX_SCROLL_SIZE: u16 = 100;
76use serde::{Deserialize, Serialize};
77use tokio::sync::mpsc::UnboundedSender;
78
79use crate::prelude::*;
80
81use input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
82use operation::{Event, Operation, TerminalOp};
83use settings::Settings;
84use settings::adapters::terminal;
85use views::View;
86use widgets::button::Menu;
87use widgets::input_field::InputField;
88
89// ---------------------------------------------------------------------------
90// Types stored per tab
91// ---------------------------------------------------------------------------
92
93pub struct TerminalTab {
94    pub id: u64,
95    pub title: String,
96    /// Original command used to launch the shell/process.
97    pub command: String,
98    pub cwd: PathBuf,
99    pub master: Box<dyn MasterPty + Send>,
100    pub writer: Box<dyn std::io::Write + Send>,
101    pub parser: RefCell<vt100::Parser>,
102    /// Detected clickable links in the visible buffer (row/col coordinates).
103    pub links: Vec<crate::widgets::terminal::Link>,
104    /// Number of lines scrolled back from live view (0 = live).
105    pub scroll_offset: u16,
106    /// Maximum scrollback buffer size (lines).
107    pub scrollback_len: usize,
108    pub exited: bool,
109    /// Styled scrollback produced by the PTY async task via
110    /// `TerminalOp::AppendScrollback`.  Enables state persistence and reuse
111    /// of `DiagnosticsExtractor::extract_from_line` on the main thread.
112    pub scrollback_lines: Vec<crate::vt_parser::StyledLine>,
113}
114
115// ---------------------------------------------------------------------------
116// Popup state
117// ---------------------------------------------------------------------------
118
119enum TabPopup {
120    /// Two-item context menu: 0 = Rename, 1 = Close.
121    Menu { id: u64, cursor: usize },
122    /// Inline rename input.
123    Rename { id: u64, input: InputField },
124    /// Tab-switcher list showing all tab names.
125    Selector { cursor: usize },
126}
127
128const MENU_ITEMS: &[&str] = &["Rename", "Close"];
129
130// ---------------------------------------------------------------------------
131// Terminal search state
132// ---------------------------------------------------------------------------
133
134pub struct TerminalSearch {
135    /// Query input field (reuses editor InputField UI).
136    pub query: crate::widgets::input_field::InputField,
137    /// Search options (regex, ignore-case, smart-case).
138    pub opts: crate::views::editor::SearchOptions,
139    /// Matches found across scrollback: (row_index, byte_start, byte_end)
140    pub matches: Vec<(usize, usize, usize)>,
141    /// Index into `matches` currently active/selected.
142    pub current: Option<usize>,
143}
144
145/// Find matches across StyledLine buffer using the same semantics as editor.find_matches.
146fn find_matches_styled(
147    lines: &[crate::vt_parser::StyledLine],
148    query: &str,
149    opts: &crate::views::editor::SearchOptions,
150) -> Vec<(usize, usize, usize)> {
151    if query.is_empty() {
152        return Vec::new();
153    }
154    let ignore = opts.ignore_case || (opts.smart_case && !query.chars().any(|c| c.is_uppercase()));
155    let mut out = Vec::new();
156    if opts.regex {
157        if let Ok(re) = regex::RegexBuilder::new(query).case_insensitive(ignore).build() {
158            for (row, line) in lines.iter().enumerate() {
159                for m in re.find_iter(&line.text) {
160                    out.push((row, m.start(), m.end()));
161                }
162            }
163        }
164    } else {
165        let q = if ignore { query.to_lowercase() } else { query.to_owned() };
166        for (row, line) in lines.iter().enumerate() {
167            let l = if ignore { line.text.to_lowercase() } else { line.text.clone() };
168            let mut start = 0usize;
169            while let Some(pos) = l[start..].find(&q) {
170                let abs = start + pos;
171                out.push((row, abs, abs + q.len()));
172                start = abs + 1;
173            }
174        }
175    }
176    out
177}
178
179// ---------------------------------------------------------------------------
180// TerminalView
181// ---------------------------------------------------------------------------
182
183pub struct TerminalView {
184    pub tabs: Vec<TerminalTab>,
185    pub active: usize,
186    popup: Option<TabPopup>,
187    /// Active search bar state (None = bar closed).
188    pub search: Option<TerminalSearch>,
189    /// Retained search state for post-close F3 navigation (like editor's last_search).
190    pub last_search: Option<TerminalSearch>,
191    /// Monotonically-increasing counter for new tab IDs.
192    pub next_id: u64,
193    /// Last known terminal dimensions (cols, rows) — used to detect resize.
194    last_size: Cell<(u16, u16)>,
195    /// Number of lines to scroll with PageUp/PageDown and mouse wheel.
196    scroll_size: u16,
197}
198
199impl std::fmt::Debug for TerminalView {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.debug_struct("TerminalView")
202            .field("active", &self.active)
203            .field("next_id", &self.next_id)
204            .field("tabs_len", &self.tabs.len())
205            .finish()
206    }
207}
208
209impl Default for TerminalView {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215impl TerminalView {
216    pub fn new() -> Self {
217        // Try to get the current terminal size from crossterm
218        let size = crossterm::terminal::size().unwrap_or((0, 0));
219        Self {
220            tabs: Vec::new(),
221            active: 0,
222            popup: None,
223            search: None,
224            last_search: None,
225            next_id: 1,
226            last_size: Cell::new(size),
227            scroll_size: DEFAULT_SCROLL_SIZE,
228        }
229    }
230
231    /// Update configuration from settings. Call this when settings change
232    /// or when the terminal view becomes active.
233    pub fn update_from_settings(&mut self, settings: &Settings) {
234        let size = *terminal::scroll_size(settings);
235        // Clamp to reasonable range [1, MAX_SCROLL_SIZE] to avoid extreme values
236        self.scroll_size = size.clamp(1, MAX_SCROLL_SIZE as i64) as u16;
237    }
238
239    // -----------------------------------------------------------------------
240    // Tab management (called from app.rs apply_operation)
241    // -----------------------------------------------------------------------
242
243    /// Allocate a unique tab ID.
244    fn alloc_id(&mut self) -> u64 {
245        let id = self.next_id;
246        self.next_id += 1;
247        id
248    }
249
250    /// Spawn a new PTY tab running `command` (defaults to configured/env shell).
251    /// Starts an async read task that forwards output via `op_tx`.
252    /// `scrollback` is the number of history lines to keep per tab.
253    pub fn spawn_tab(
254        &mut self,
255        command: Option<String>,
256        shell_setting: &str,
257        cwd: &Path,
258        op_tx: &UnboundedSender<Vec<Operation>>,
259        scrollback: usize,
260    ) {
261        let id = self.alloc_id();
262
263        // Determine terminal size: use last known or safe defaults.
264        let (cols, rows) = self.last_size.get();
265        let cols = if cols == 0 { 80 } else { cols };
266        let rows = if rows == 0 { 24 } else { rows };
267        // Reserve one row for the global status bar so child PTY and the
268        // vt100 parser use the visible area size. Without this the child
269        // process thinks the terminal is larger and draws under the status bar.
270        let term_rows = rows.saturating_sub(1).max(1);
271        log::debug!(
272            "terminal.spawn_tab: cols={} rows={} term_rows={}",
273            cols,
274            rows,
275            term_rows
276        );
277
278        // Determine the shell command.
279        let shell = if let Some(ref cmd) = command {
280            cmd.clone()
281        } else if !shell_setting.is_empty() {
282            shell_setting.to_string()
283        } else {
284            std::env::var("SHELL").unwrap_or_else(|_| {
285                if cfg!(windows) {
286                    std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
287                } else {
288                    "/bin/bash".to_string()
289                }
290            })
291        };
292
293        // Derive the tab title from the executable name.
294        let title = shell
295            .split_whitespace()
296            .next()
297            .and_then(|s| s.rsplit('/').next())
298            .unwrap_or("shell")
299            .to_string();
300
301        // Open PTY pair.
302        let pty_system = native_pty_system();
303        let pair = match pty_system.openpty(PtySize {
304            rows: term_rows,
305            cols,
306            pixel_width: 0,
307            pixel_height: 0,
308        }) {
309            Ok(p) => p,
310            Err(e) => {
311                log::error!("terminal: openpty failed: {e}");
312                return;
313            }
314        };
315
316        // Build and launch the command.
317        let mut cmd = CommandBuilder::new(&shell);
318        cmd.cwd(cwd);
319
320        let child = match pair.slave.spawn_command(cmd) {
321            Ok(c) => c,
322            Err(e) => {
323                log::error!("terminal: spawn_command failed: {e}");
324                return;
325            }
326        };
327        // Drop the slave end in the parent — the child holds it open.
328        drop(pair.slave);
329
330        let writer = match pair.master.take_writer() {
331            Ok(w) => w,
332            Err(e) => {
333                log::error!("terminal: take_writer failed: {e}");
334                return;
335            }
336        };
337        let reader = match pair.master.try_clone_reader() {
338            Ok(r) => r,
339            Err(e) => {
340                log::error!("terminal: try_clone_reader failed: {e}");
341                return;
342            }
343        };
344
345        let parser = RefCell::new(vt100::Parser::new(term_rows, cols, scrollback));
346
347        self.tabs.push(TerminalTab {
348            id,
349            title,
350            command: shell,
351            cwd: cwd.to_path_buf(),
352            master: pair.master,
353            writer,
354            parser,
355            links: Vec::new(),
356            scroll_offset: 0,
357            scrollback_len: scrollback,
358            exited: false,
359            scrollback_lines: Vec::new(),
360        });
361        self.active = self.tabs.len() - 1;
362
363        // Async read task: forwards PTY output through the operation channel.
364        // Uses `TerminalParser` (the same SGR parser used by task runners) to
365        // produce `StyledLine` objects, which are forwarded via
366        // `AppendScrollback` and used for diagnostic extraction via
367        // `extract_from_line` (same API as task output).
368        let tx = op_tx.clone();
369        let extractor = std::sync::Arc::new(
370            crate::diagnostics_extractor::DiagnosticsExtractor::new(
371                format!("terminal:{id}"),
372                "terminal",
373            ),
374        );
375        let ex = std::sync::Arc::clone(&extractor);
376        tokio::task::spawn_blocking(move || {
377            let _child = child; // keep child handle alive until the process exits
378            let mut reader = reader;
379            let mut buf = [0u8; 4096];
380            // SGR parser — produces StyledLines from raw PTY bytes.
381            let mut tp = crate::vt_parser::TerminalParser::new();
382            // Pending rustc/cargo header: (severity, message) waiting for ` --> ` arrow.
383            let mut prev_sev: Option<(crate::issue_registry::Severity, String)> = None;
384
385            loop {
386                match std::io::Read::read(&mut reader, &mut buf) {
387                    Ok(0) | Err(_) => {
388                        // Flush any partial line remaining in the parser.
389                        if let Some(line) = tp.flush() {
390                            let issues = extract_issues_with_state(&ex, &line, &mut prev_sev);
391                            let mut ops =
392                                vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
393                                    id,
394                                    lines: vec![line],
395                                })];
396                            ops.extend(issues.into_iter().map(|i| Operation::AddIssue { issue: i }));
397                            let _ = tx.send(ops);
398                        }
399                        let _ =
400                            tx.send(vec![Operation::TerminalLocal(TerminalOp::ProcessExited {
401                                id,
402                            })]);
403                        break;
404                    }
405                    Ok(n) => {
406                        let data = buf[..n].to_vec();
407                        // Forward raw bytes to the vt100 screen parser for rendering.
408                        let _ = tx.send(vec![Operation::TerminalLocal(TerminalOp::Output {
409                            id,
410                            data: data.clone(),
411                        })]);
412                        // Run through the SGR parser to get completed StyledLines.
413                        let styled_lines = tp.push(&data);
414                        if !styled_lines.is_empty() {
415                            let mut issue_ops: Vec<Operation> = Vec::new();
416                            for line in &styled_lines {
417                                issue_ops.extend(
418                                    extract_issues_with_state(&ex, line, &mut prev_sev)
419                                        .into_iter()
420                                        .map(|i| Operation::AddIssue { issue: i }),
421                                );
422                            }
423                            let mut ops =
424                                vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
425                                    id,
426                                    lines: styled_lines,
427                                })];
428                            ops.extend(issue_ops);
429                            let _ = tx.send(ops);
430                        }
431                    }
432                }
433            }
434        });
435    }
436
437    /// Write raw bytes to the PTY of the tab with the given ID.
438    pub fn write_input(&mut self, id: u64, data: &[u8]) {
439        if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
440            && let Err(e) = tab.writer.write_all(data)
441        {
442            log::warn!("terminal: write_input failed for tab {id}: {e}");
443        }
444    }
445
446    /// Resize the PTY and vt100 parser for every tab.
447    pub fn resize_all(&mut self, rows: u16, cols: u16) {
448        let term_rows = rows.saturating_sub(1).max(1); // subtract tab bar row
449        for tab in &mut self.tabs {
450            let _ = tab.master.resize(PtySize {
451                rows: term_rows,
452                cols,
453                pixel_width: 0,
454                pixel_height: 0,
455            });
456            tab.parser.borrow_mut().set_size(term_rows, cols);
457        }
458        self.last_size.set((cols, rows));
459    }
460
461    // -----------------------------------------------------------------------
462    // Internal helpers
463    // -----------------------------------------------------------------------
464
465    fn active_id(&self) -> Option<u64> {
466        self.tabs.get(self.active).map(|t| t.id)
467    }
468
469    pub fn detect_links_for_tab(tab: &TerminalTab) -> Vec<crate::widgets::terminal::Link> {
470        crate::widgets::terminal::detect_links_from_screen(&tab.parser.borrow(), &tab.cwd)
471    }
472
473    fn close_tab_by_id(&mut self, id: u64) {
474        if let Some(idx) = self.tabs.iter().position(|t| t.id == id) {
475            self.tabs.remove(idx);
476            if self.active >= self.tabs.len() && !self.tabs.is_empty() {
477                self.active = self.tabs.len() - 1;
478            }
479        }
480    }
481
482    // -----------------------------------------------------------------------
483    // Persistence
484    // -----------------------------------------------------------------------
485
486    pub fn to_state(&self) -> TerminalStateStore {
487        TerminalStateStore {
488            tabs: self
489                .tabs
490                .iter()
491                .map(|t| TerminalTabState {
492                    id: t.id,
493                    title: t.title.clone(),
494                    command: t.command.clone(),
495                    cwd: t.cwd.clone(),
496                })
497                .collect(),
498            active: self.active,
499            next_id: self.next_id,
500        }
501    }
502
503    // -----------------------------------------------------------------------
504    // Key → PTY byte encoding
505    // -----------------------------------------------------------------------
506
507    fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
508        let ctrl = key.modifiers.contains(Modifiers::CTRL);
509        let alt = key.modifiers.contains(Modifiers::ALT);
510
511        match key.key {
512            Key::Char(c) if ctrl => {
513                let lc = c.to_ascii_lowercase();
514                if lc.is_ascii_lowercase() {
515                    Some(vec![lc as u8 - b'a' + 1])
516                } else {
517                    None
518                }
519            }
520            Key::Char(c) if alt => {
521                // Alt+char → ESC + char
522                Some(vec![0x1b, c as u8])
523            }
524            Key::Char(c) => Some(c.to_string().into_bytes()),
525            Key::Enter => Some(b"\r".to_vec()),
526            Key::Backspace => Some(vec![0x7f]),
527            Key::Tab => Some(b"\t".to_vec()),
528            Key::Delete => Some(b"\x1b[3~".to_vec()),
529            Key::ArrowUp => Some(b"\x1b[A".to_vec()),
530            Key::ArrowDown => Some(b"\x1b[B".to_vec()),
531            Key::ArrowRight => Some(b"\x1b[C".to_vec()),
532            Key::ArrowLeft => Some(b"\x1b[D".to_vec()),
533            Key::Home => Some(b"\x1b[H".to_vec()),
534            Key::End => Some(b"\x1b[F".to_vec()),
535            Key::PageUp => Some(b"\x1b[5~".to_vec()),
536            Key::PageDown => Some(b"\x1b[6~".to_vec()),
537            Key::F(1) => Some(b"\x1bOP".to_vec()),
538            Key::F(2) => Some(b"\x1bOQ".to_vec()),
539            Key::F(3) => Some(b"\x1bOR".to_vec()),
540            Key::F(4) => Some(b"\x1bOS".to_vec()),
541            Key::F(n) => {
542                // F5–F12 use the Xterm encoding.
543                let code: u8 = match n {
544                    5 => 15,
545                    6 => 17,
546                    7 => 18,
547                    8 => 19,
548                    9 => 20,
549                    10 => 21,
550                    11 => 23,
551                    12 => 24,
552                    _ => return None,
553                };
554                Some(format!("\x1b[{}~", code).into_bytes())
555            }
556            _ => None,
557        }
558    }
559
560    // -----------------------------------------------------------------------
561    // Popup rendering helpers
562    // -----------------------------------------------------------------------
563
564    fn render_menu_popup(&self, frame: &mut Frame, _id: u64, cursor: usize) {
565        let area = frame.area();
566        let popup_rect = Rect {
567            x: area.x,
568            y: area.y + 1,
569            width: 18,
570            height: (MENU_ITEMS.len() + 2) as u16,
571        };
572        if popup_rect.bottom() > area.bottom() || popup_rect.right() > area.right() {
573            return;
574        }
575
576        let menu = Menu::new(MENU_ITEMS).cursor(cursor);
577        frame.render_widget(menu, popup_rect);
578    }
579
580    fn render_rename_popup(&self, frame: &mut Frame, _id: u64, input: &str) {
581        let area = frame.area();
582        let popup_rect = Rect {
583            x: area.x,
584            y: area.y + 1,
585            width: 30.min(area.width),
586            height: 3,
587        };
588        if popup_rect.bottom() > area.bottom() {
589            return;
590        }
591        let display = format!(" {}_ ", input);
592        let p = Paragraph::new(display).block(
593            Block::default()
594                .borders(Borders::ALL)
595                .title(" Rename ")
596                .style(
597                    Style::default()
598                        .fg(Color::Rgb(100, 100, 100))
599                        .bg(Color::Rgb(40, 40, 40)),
600                ),
601        );
602        frame.render_widget(p, popup_rect);
603    }
604
605
606    fn render_selector_popup(&self, frame: &mut Frame, area: Rect, cursor: usize) {
607        let n = self.tabs.len();
608        if n == 0 {
609            return;
610        }
611        let height = (n as u16 + 2).min(area.height);
612        let width = self
613            .tabs
614            .iter()
615            .map(|t| t.title.chars().count())
616            .max()
617            .unwrap_or(10) as u16
618            + 4;
619        let width = width.min(area.width).max(14);
620        // Anchor to bottom-left of the terminal area (just above the status bar).
621        let y = area.bottom().saturating_sub(height);
622        let popup_rect = Rect { x: area.x + 1, y, width, height };
623        let tab_names: Vec<&str> = self.tabs.iter().map(|t| t.title.as_str()).collect();
624        let menu = Menu::new(&tab_names).cursor(cursor);
625        frame.render_widget(menu, popup_rect);
626    }
627}
628
629// ---------------------------------------------------------------------------
630// Paste forwarding
631// ---------------------------------------------------------------------------
632
633impl TerminalView {
634    /// Forward bracketed-paste content to the active PTY tab as raw bytes.
635    pub fn handle_paste(&self, text: &str) -> Vec<Operation> {
636        let Some(id) = self.active_id() else {
637            return vec![];
638        };
639        let data = text.as_bytes().to_vec();
640        vec![
641            Operation::TerminalLocal(TerminalOp::ScrollReset),
642            Operation::TerminalInput { id, data },
643        ]
644    }
645}
646
647// ---------------------------------------------------------------------------
648// View trait
649// ---------------------------------------------------------------------------
650
651impl View for TerminalView {
652    const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
653
654    fn save_state(&mut self, app: &mut crate::app_state::AppState) {
655        crate::views::save_state::terminal_pre_save(self, app);
656    }
657
658    fn status_bar(
659        &self,
660        _state: &crate::app_state::AppState,
661        bar: &mut crate::widgets::status_bar::StatusBarBuilder,
662    ) {
663        let name = self
664            .tabs
665            .get(self.active)
666            .map(|t| t.title.as_str())
667            .unwrap_or("(no tab)");
668        bar.menu(
669            format!("tab: {}", name),
670            crate::commands::CommandId::new_static("terminal", "open_tab_selector"),
671        );
672    }
673
674    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
675        // ── Popup: rename input ──────────────────────────────────────────────
676        if let Some(TabPopup::Rename { input, .. }) = &self.popup {
677            return match key.key {
678                Key::Escape => vec![Operation::TerminalLocal(TerminalOp::RenameCancel)],
679                Key::Enter => vec![Operation::TerminalLocal(TerminalOp::RenameConfirm)],
680                _ => {
681                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
682                        let mut f = input.clone();
683                        f.apply(&field_op);
684                        vec![Operation::TerminalLocal(TerminalOp::RenameChanged(
685                            f.text().to_owned(),
686                        ))]
687                    } else {
688                        vec![]
689                    }
690                }
691            };
692        }
693
694        // ── Popup: tab selector ──────────────────────────────────────────────
695        if let Some(TabPopup::Selector { .. }) = &self.popup {
696            return match key.key {
697                Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
698                Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
699                Key::ArrowUp => vec![Operation::NavigateUp],
700                Key::ArrowDown => vec![Operation::NavigateDown],
701                _ => vec![],
702            };
703        }
704
705        // ── Popup: context menu ──────────────────────────────────────────────
706        if let Some(TabPopup::Menu { .. }) = &self.popup {
707            return match key.key {
708                Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
709                Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
710                Key::ArrowUp => vec![Operation::NavigateUp],
711                Key::ArrowDown => vec![Operation::NavigateDown],
712                _ => vec![],
713            };
714        }
715
716        // ── Search bar active — capture all keys ─────────────────────────────
717        if self.search.is_some() {
718            let alt = key.modifiers.contains(Modifiers::ALT);
719            return match (alt, key.key) {
720                (_, Key::Escape) => vec![Operation::SearchLocal(crate::operation::SearchOp::Close)],
721                (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
722                    vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)]
723                }
724                (_, Key::F(3)) => {
725                    vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)]
726                }
727                (true, Key::Char('c')) | (true, Key::Char('C')) => {
728                    vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleIgnoreCase)]
729                }
730                (true, Key::Char('r')) | (true, Key::Char('R')) => {
731                    vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleRegex)]
732                }
733                (true, Key::Char('s')) | (true, Key::Char('S')) => {
734                    vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleSmartCase)]
735                }
736                _ => {
737                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
738                        vec![Operation::SearchLocal(crate::operation::SearchOp::QueryInput(field_op))]
739                    } else {
740                        vec![]
741                    }
742                }
743            };
744        }
745
746        // ── Normal terminal mode ─────────────────────────────────────────────
747        let ctrl = key.modifiers.contains(Modifiers::CTRL);
748        let alt = key.modifiers.contains(Modifiers::ALT);
749
750        // Tab management shortcuts (not forwarded to PTY).
751        if ctrl && !alt {
752            match key.key {
753                Key::Char('t') | Key::Char('T') => {
754                    return vec![Operation::TerminalLocal(TerminalOp::NewTab {
755                        command: None,
756                    })];
757                }
758                Key::Char('w') | Key::Char('W') => {
759                    if let Some(id) = self.active_id() {
760                        return vec![Operation::TerminalLocal(TerminalOp::CloseTab { id })];
761                    }
762                    return vec![];
763                }
764                Key::Char('r') | Key::Char('R') => {
765                    if let Some(id) = self.active_id() {
766                        return vec![Operation::TerminalLocal(TerminalOp::OpenMenu { id })];
767                    }
768                    return vec![];
769                }
770                Key::Char('f') | Key::Char('F') => {
771                    return vec![Operation::SearchLocal(crate::operation::SearchOp::Open { replace: false })];
772                }
773                _ => {}
774            }
775        }
776
777        if alt {
778            match key.key {
779                Key::ArrowLeft => return vec![Operation::TerminalLocal(TerminalOp::PrevTab)],
780                Key::ArrowRight => return vec![Operation::TerminalLocal(TerminalOp::NextTab)],
781                _ => {}
782            }
783        }
784
785        // Escape returns to the previous screen.
786
787        // PageUp/PageDown scroll the scrollback buffer.
788        if !ctrl && !alt {
789            match key.key {
790                Key::PageUp => {
791                    return vec![Operation::NavigatePageUp];
792                }
793                Key::PageDown => {
794                    return vec![Operation::NavigatePageDown];
795                }
796                _ => {}
797            }
798        }
799
800        // Function keys: F3/Shift+F3 navigate search matches (uses last_search when bar closed).
801        if let Key::F(3) = key.key {
802            if key.modifiers.contains(Modifiers::SHIFT) {
803                return vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)];
804            } else {
805                return vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)];
806            }
807        }
808
809        // Everything else: encode as PTY input bytes.
810        if let Some(id) = self.active_id()
811            && let Some(data) = Self::key_to_bytes(key)
812        {
813            // Also reset scrollback so the user sees the live view.
814            return vec![
815                Operation::TerminalLocal(TerminalOp::ScrollReset),
816                Operation::TerminalInput { id, data },
817            ];
818        }
819
820        vec![]
821    }
822
823    fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
824        // Handle scroll events (mouse wheel)
825        match mouse.kind {
826            MouseEventKind::ScrollUp => {
827                return vec![Operation::NavigatePageUp];
828            }
829            MouseEventKind::ScrollDown => {
830                return vec![Operation::NavigatePageDown];
831            }
832            _ => {}
833        }
834
835        // Only react to button-press events — ignore Up, Move, Drag.
836        let is_down = matches!(mouse.kind, MouseEventKind::Down(_));
837
838        // Right-click anywhere in the terminal → open generic context menu.
839        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
840            use crate::commands::CommandId;
841            return vec![Operation::OpenContextMenu {
842                items: vec![
843                    ("New Tab".to_string(),    CommandId::new_static("terminal", "new_tab"), Some(true)),
844                    ("Rename Tab".to_string(), CommandId::new_static("terminal", "rename_active_tab"), Some(true)),
845                    ("Close Tab".to_string(),  CommandId::new_static("terminal", "close_active_tab"), Some(true)),
846                    ("Clear".to_string(),      CommandId::new_static("terminal", "clear_active_tab"), Some(true)),
847                ],
848                x: mouse.column,
849                y: mouse.row,
850            }];
851        }
852
853        // ── Menu popup ────────────────────────────────────────────────────────
854        if let Some(TabPopup::Menu { .. }) = &self.popup {
855            let menu = Menu::new(MENU_ITEMS);
856            let menu_area = Rect {
857                x: 0,
858                y: 1,
859                width: 18,
860                height: (MENU_ITEMS.len() + 2) as u16,
861            };
862
863            if let Some(clicked_item) = menu.hit_test((mouse.column, mouse.row), menu_area) {
864                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
865                    let current_cursor = if let Some(TabPopup::Menu { cursor, .. }) = &self.popup {
866                        *cursor
867                    } else {
868                        0
869                    };
870                    let mut ops: Vec<Operation> = vec![];
871                    if clicked_item < current_cursor {
872                        for _ in 0..(current_cursor - clicked_item) {
873                            ops.push(Operation::NavigateUp);
874                        }
875                    } else {
876                        for _ in 0..(clicked_item - current_cursor) {
877                            ops.push(Operation::NavigateDown);
878                        }
879                    }
880                    ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
881                    return ops;
882                }
883                return vec![];
884            }
885
886            // Click outside menu → close it.
887            if is_down {
888                return vec![Operation::TerminalLocal(TerminalOp::CloseMenu)];
889            }
890            return vec![];
891        }
892
893        // ── Tab selector popup ───────────────────────────────────────────────
894        if let Some(TabPopup::Selector { cursor }) = &self.popup {
895            let n = self.tabs.len();
896            let (cols, rows) = self.last_size.get();
897            // Subtract 1 from rows to match the view_area height used by
898            // render_selector_popup (the status bar occupies the last row).
899            let view_rows = rows.saturating_sub(1).max(1);
900            let width = self
901                .tabs
902                .iter()
903                .map(|t| t.title.chars().count())
904                .max()
905                .unwrap_or(10) as u16
906                + 4;
907            let width = width.min(cols).max(14);
908            let height = (n as u16 + 2).min(view_rows);
909            let y = view_rows.saturating_sub(height);
910            let selector_area = Rect { x: 1, y, width, height };
911
912            if let Some(clicked_item) = Menu::new(&self.tabs.iter().map(|t| t.title.as_str()).collect::<Vec<_>>()).hit_test((mouse.column, mouse.row), selector_area) {
913                if is_down {
914                    let current = *cursor;
915                    let mut ops: Vec<Operation> = vec![];
916                    if clicked_item < current {
917                        for _ in 0..(current - clicked_item) { ops.push(Operation::NavigateUp); }
918                    } else {
919                        for _ in 0..(clicked_item - current) { ops.push(Operation::NavigateDown); }
920                    }
921                    ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
922                    return ops;
923                }
924                return vec![];
925            }
926            if is_down {
927                return vec![Operation::TerminalLocal(TerminalOp::CloseMenu)];
928            }
929        }
930
931        // Clicks on links (left button) — open URL or file.
932        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
933            && let Some(tab) = self.tabs.get(self.active) {
934                let col = mouse.column;
935                let row = mouse.row;
936                if let Some(link) = tab.links.iter().find(|l| l.row == row && col >= l.start_col && col < l.end_col) {
937                    match &link.kind {
938                        crate::widgets::terminal::LinkKind::Url(u) => {
939                            return vec![Operation::OpenUrl { url: u.clone() }];
940                        }
941                        crate::widgets::terminal::LinkKind::File { path, line, column } => {
942                            let mut ops = vec![Operation::OpenFile { path: path.clone() }];
943                            if let Some(l) = line {
944                                // Convert 1-based file:line[:col] to 0-based Position
945                                let row_idx = l.saturating_sub(1);
946                                let col_idx = column.unwrap_or(1).saturating_sub(1);
947                                ops.push(Operation::MoveCursor {
948                                    path: Some(path.clone()),
949                                    cursor: crate::editor::Position::new(row_idx, col_idx),
950                                });
951                            }
952                            return ops;
953                        }
954                        // Diagnostic links are row-level visual indicators; they don't
955                        // have a distinct click action (the File link within the same row
956                        // handles navigation). Search links are visual only as well.
957                        crate::widgets::terminal::LinkKind::Diagnostic { .. } => {}
958                        crate::widgets::terminal::LinkKind::Search | crate::widgets::terminal::LinkKind::SearchCurrent => {}
959                    }
960                }
961            }
962
963        vec![]
964    }
965
966    fn handle_operation(&mut self, op: &Operation, settings: &Settings) -> Option<Event> {
967        match op {
968            // ── PTY output ─────────────────────────────────────────────────
969            Operation::TerminalLocal(TerminalOp::Output { id, data }) => {
970                if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
971                    tab.parser.borrow_mut().process(data);
972                    // Reset scroll to live view when new output arrives
973                    tab.scroll_offset = 0;
974                    // Recompute clickable links based on the visible screen.
975                    tab.links = Self::detect_links_for_tab(tab);
976                }
977                Some(Event::applied("terminal", op.clone()))
978            }
979
980            // ── Styled scrollback lines produced by the async task ──────────
981            Operation::TerminalLocal(TerminalOp::AppendScrollback { id, lines }) => {
982                if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
983                    tab.scrollback_lines.extend(lines.iter().cloned());
984                }
985                Some(Event::applied("terminal", op.clone()))
986            }
987
988            // ── Process exited (handled here after app.rs spawns replacement) ─
989            Operation::TerminalLocal(TerminalOp::ProcessExited { id }) => {
990                if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
991                    tab.exited = true;
992                }
993                // Removal and optional new-tab spawning is done in apply_operation.
994                Some(Event::applied("terminal", op.clone()))
995            }
996
997            // ── Tab navigation ──────────────────────────────────────────────
998            Operation::TerminalLocal(TerminalOp::SwitchToTab { index }) => {
999                if *index < self.tabs.len() {
1000                    self.active = *index;
1001                }
1002                Some(Event::applied("terminal", op.clone()))
1003            }
1004            Operation::TerminalLocal(TerminalOp::NextTab) => {
1005                if self.tabs.len() > 1 {
1006                    self.active = (self.active + 1) % self.tabs.len();
1007                }
1008                Some(Event::applied("terminal", op.clone()))
1009            }
1010            Operation::TerminalLocal(TerminalOp::PrevTab) => {
1011                if self.tabs.len() > 1 {
1012                    self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
1013                }
1014                Some(Event::applied("terminal", op.clone()))
1015            }
1016
1017            // ── Scrollback ──────────────────────────────────────────────────
1018            // Note: scroll_offset must not exceed the parser's scrollback buffer size
1019            Operation::NavigatePageUp => {
1020                if let Some(tab) = self.tabs.get_mut(self.active) {
1021                    let lines = self.scroll_size.min(MAX_SCROLL_STEP);
1022                    let max_scrollback = tab.scrollback_len as u16;
1023                    tab.scroll_offset = (tab.scroll_offset + lines).min(max_scrollback);
1024                }
1025                Some(Event::applied("terminal", op.clone()))
1026            }
1027            Operation::NavigatePageDown => {
1028                if let Some(tab) = self.tabs.get_mut(self.active) {
1029                    let lines = self.scroll_size.min(MAX_SCROLL_STEP);
1030                    tab.scroll_offset = tab.scroll_offset.saturating_sub(lines);
1031                }
1032                Some(Event::applied("terminal", op.clone()))
1033            }
1034            Operation::TerminalLocal(TerminalOp::ScrollReset) => {
1035                if let Some(tab) = self.tabs.get_mut(self.active) {
1036                    tab.scroll_offset = 0;
1037                }
1038                Some(Event::applied("terminal", op.clone()))
1039            }
1040
1041            // ── Popup: open menu ────────────────────────────────────────────
1042            Operation::TerminalLocal(TerminalOp::OpenMenu { id }) => {
1043                self.popup = Some(TabPopup::Menu { id: *id, cursor: 0 });
1044                Some(Event::applied("terminal", op.clone()))
1045            }
1046            // ── Popup: open tab selector ─────────────────────────────────────
1047            Operation::TerminalLocal(TerminalOp::OpenTabSelector) => {
1048                self.popup = Some(TabPopup::Selector { cursor: self.active });
1049                Some(Event::applied("terminal", op.clone()))
1050            }
1051            // ── Popup: open rename input directly (from generic context menu) ──
1052            Operation::TerminalLocal(TerminalOp::OpenRename { id }) => {
1053                let current = self
1054                    .tabs
1055                    .iter()
1056                    .find(|t| t.id == *id)
1057                    .map(|t| t.title.clone())
1058                    .unwrap_or_default();
1059                let mut field = InputField::new("Rename");
1060                field.set_text(current);
1061                self.popup = Some(TabPopup::Rename { id: *id, input: field });
1062                Some(Event::applied("terminal", op.clone()))
1063            }
1064            // ── Clear tab screen ─────────────────────────────────────────────
1065            Operation::TerminalLocal(TerminalOp::ClearTab { id }) => {
1066                if let Some(idx) = self.tabs.iter().position(|t| t.id == *id) {
1067                    // Route clear request through the PTY so the shell/child can handle it.
1068                    // Use Ctrl+L (0x0c) which shells commonly interpret as "clear screen".
1069                    self.write_input(*id, b"\x0c");
1070                    if let Some(tab) = self.tabs.get_mut(idx) {
1071                        tab.scroll_offset = 0;
1072                    }
1073                }
1074                Some(Event::applied("terminal", op.clone()))
1075            }
1076            Operation::NavigateUp => {
1077                match &mut self.popup {
1078                    Some(TabPopup::Menu { cursor, .. }) if *cursor > 0 => {
1079                        *cursor -= 1;
1080                    }
1081                    Some(TabPopup::Selector { cursor }) if *cursor > 0 => {
1082                        *cursor -= 1;
1083                    }
1084                    _ => {}
1085                }
1086                Some(Event::applied("terminal", op.clone()))
1087            }
1088            Operation::NavigateDown => {
1089                match &mut self.popup {
1090                    Some(TabPopup::Menu { cursor, .. }) => {
1091                        *cursor = (*cursor + 1).min(MENU_ITEMS.len() - 1);
1092                    }
1093                    Some(TabPopup::Selector { cursor }) => {
1094                        *cursor = (*cursor + 1).min(self.tabs.len().saturating_sub(1));
1095                    }
1096                    _ => {}
1097                }
1098                Some(Event::applied("terminal", op.clone()))
1099            }
1100            Operation::TerminalLocal(TerminalOp::CloseMenu) => {
1101                self.popup = None;
1102                Some(Event::applied("terminal", op.clone()))
1103            }
1104            Operation::TerminalLocal(TerminalOp::MenuConfirm) => {
1105                match self.popup.take() {
1106                    Some(TabPopup::Menu { id, cursor }) => match cursor {
1107                        0 => {
1108                            // Rename: transition to rename popup pre-filled with current title.
1109                            let current = self
1110                                .tabs
1111                                .iter()
1112                                .find(|t| t.id == id)
1113                                .map(|t| t.title.clone())
1114                                .unwrap_or_default();
1115                            let mut field = InputField::new("Rename");
1116                            field.set_text(current);
1117                            self.popup = Some(TabPopup::Rename { id, input: field });
1118                        }
1119                        _ => {
1120                            // Close tab.
1121                            self.close_tab_by_id(id);
1122                        }
1123                    },
1124                    Some(TabPopup::Selector { cursor }) => {
1125                        // Switch to the selected tab.
1126                        self.active = cursor.min(self.tabs.len().saturating_sub(1));
1127                    }
1128                    _ => {
1129                        self.popup = None;
1130                    }
1131                }
1132                Some(Event::applied("terminal", op.clone()))
1133            }
1134
1135            // ── Popup: rename ────────────────────────────────────────────────
1136            Operation::TerminalLocal(TerminalOp::RenameChanged(s)) => {
1137                if let Some(TabPopup::Rename { input, .. }) = &mut self.popup {
1138                    input.set_text(s.clone());
1139                }
1140                Some(Event::applied("terminal", op.clone()))
1141            }
1142            Operation::TerminalLocal(TerminalOp::RenameConfirm) => {
1143                if let Some(TabPopup::Rename { id, input }) = self.popup.take()
1144                    && let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
1145                    && !input.is_empty()
1146                {
1147                    tab.title = input.text().to_owned();
1148                }
1149                Some(Event::applied("terminal", op.clone()))
1150            }
1151            Operation::TerminalLocal(TerminalOp::RenameCancel) => {
1152                self.popup = None;
1153                Some(Event::applied("terminal", op.clone()))
1154            }
1155            // ── Inline search bar (reuses editor SearchOp semantics) ─────────
1156            Operation::SearchLocal(sop) => {
1157                use crate::operation::SearchOp;
1158                // Helper: scroll to a match line
1159                fn scroll_to_match(
1160                    match_line: usize,
1161                    tab: &mut TerminalTab,
1162                    _rows: usize,
1163                ) {
1164                    // match_line is an index into the combined vector used for matching
1165                    // (scrollback_lines followed by the live screen rows). Compute a
1166                    // top_index clamped to at most the scrollback length so we can
1167                    // derive the correct scroll_offset.
1168                    let total = tab.scrollback_lines.len();
1169                    let top_index = if match_line <= total { match_line } else { total };
1170                    tab.scroll_offset = total.saturating_sub(top_index) as u16;
1171                    tab.links = TerminalView::detect_links_for_tab(tab);
1172                }
1173                let rows = self.last_size.get().1.saturating_sub(1).max(1) as usize;
1174                match sop {
1175                    SearchOp::Open { .. } => {
1176                        let mut field = InputField::new("Search");
1177                        // Restore previous query + opts from last_search when available.
1178                        let (prev_text, prev_opts) = self.last_search.as_ref()
1179                            .map(|s| (s.query.text().to_owned(), s.opts.clone()))
1180                            .unwrap_or_else(|| (String::new(), crate::views::editor::SearchOptions::default()));
1181                        field.set_text(prev_text.clone());
1182                        let opts = prev_opts;
1183                        let matches = if let Some(tab) = self.tabs.get(self.active) {
1184                            // Combine stored scrollback lines with the current visible screen
1185                            // so searches include the freshest output that may not yet be
1186                            // present in `tab.scrollback_lines`.
1187                            let mut all_lines = tab.scrollback_lines.clone();
1188                            let parser_ref = tab.parser.borrow();
1189                            let screen = parser_ref.screen();
1190                            let (rows, cols) = screen.size();
1191                            for r in 0..rows {
1192                                let mut row_text = String::with_capacity(cols as usize);
1193                                for c in 0..cols {
1194                                    if let Some(cell) = screen.cell(r, c) {
1195                                        let s = cell.contents();
1196                                        row_text.push_str(if s.is_empty() { " " } else { &s });
1197                                    } else {
1198                                        row_text.push(' ');
1199                                    }
1200                                }
1201                                all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1202                            }
1203                            find_matches_styled(&all_lines, &prev_text, &opts)
1204                        } else { Vec::new() };
1205                        self.search = Some(TerminalSearch { query: field, opts, matches, current: None });
1206                    }
1207                    SearchOp::Close => {
1208                        // Save to last_search for post-close F3.
1209                        self.last_search = self.search.take();
1210                    }
1211                    SearchOp::QueryInput(field_op) => {
1212                        if let Some(s) = &mut self.search {
1213                            s.query.apply(field_op);
1214                            if let Some(tab) = self.tabs.get(self.active) {
1215                                let q = s.query.text().to_owned();
1216                                // Combine scrollback and visible screen for freshest results.
1217                                let mut all_lines = tab.scrollback_lines.clone();
1218                                let parser_ref = tab.parser.borrow();
1219                                let screen = parser_ref.screen();
1220                                let (rows, cols) = screen.size();
1221                                for r in 0..rows {
1222                                    let mut row_text = String::with_capacity(cols as usize);
1223                                    for c in 0..cols {
1224                                        if let Some(cell) = screen.cell(r, c) {
1225                                            let s_cell = cell.contents();
1226                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1227                                        } else {
1228                                            row_text.push(' ');
1229                                        }
1230                                    }
1231                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1232                                }
1233                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1234                                s.current = None;
1235                                log::debug!("terminal: QueryInput q='{}' matches_total={}", q, s.matches.len());
1236                            }
1237                        }
1238                    }
1239                    SearchOp::ToggleIgnoreCase => {
1240                        if let Some(s) = &mut self.search {
1241                            s.opts.ignore_case = !s.opts.ignore_case;
1242                            if let Some(tab) = self.tabs.get(self.active) {
1243                                let q = s.query.text().to_owned();
1244                                // Combine scrollback and visible screen for freshest results.
1245                                let mut all_lines = tab.scrollback_lines.clone();
1246                                let parser_ref = tab.parser.borrow();
1247                                let screen = parser_ref.screen();
1248                                let (rows, cols) = screen.size();
1249                                for r in 0..rows {
1250                                    let mut row_text = String::with_capacity(cols as usize);
1251                                    for c in 0..cols {
1252                                        if let Some(cell) = screen.cell(r, c) {
1253                                            let s_cell = cell.contents();
1254                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1255                                        } else {
1256                                            row_text.push(' ');
1257                                        }
1258                                    }
1259                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1260                                }
1261                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1262                                s.current = None;
1263                                log::debug!("terminal: ToggleIgnoreCase q='{}' matches_total={}", q, s.matches.len());
1264                            }
1265                        }
1266                    }
1267                    SearchOp::ToggleRegex => {
1268                        if let Some(s) = &mut self.search {
1269                            s.opts.regex = !s.opts.regex;
1270                            if let Some(tab) = self.tabs.get(self.active) {
1271                                let q = s.query.text().to_owned();
1272                                let mut all_lines = tab.scrollback_lines.clone();
1273                                let parser_ref = tab.parser.borrow();
1274                                let screen = parser_ref.screen();
1275                                let (rows, cols) = screen.size();
1276                                for r in 0..rows {
1277                                    let mut row_text = String::with_capacity(cols as usize);
1278                                    for c in 0..cols {
1279                                        if let Some(cell) = screen.cell(r, c) {
1280                                            let s_cell = cell.contents();
1281                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1282                                        } else {
1283                                            row_text.push(' ');
1284                                        }
1285                                    }
1286                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1287                                }
1288                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1289                                s.current = None;
1290                                log::debug!("terminal: ToggleRegex q='{}' matches_total={}", q, s.matches.len());
1291                            }
1292                        }
1293                    }
1294                    SearchOp::ToggleSmartCase => {
1295                        if let Some(s) = &mut self.search {
1296                            s.opts.smart_case = !s.opts.smart_case;
1297                            if let Some(tab) = self.tabs.get(self.active) {
1298                                let q = s.query.text().to_owned();
1299                                let mut all_lines = tab.scrollback_lines.clone();
1300                                let parser_ref = tab.parser.borrow();
1301                                let screen = parser_ref.screen();
1302                                let (rows, cols) = screen.size();
1303                                for r in 0..rows {
1304                                    let mut row_text = String::with_capacity(cols as usize);
1305                                    for c in 0..cols {
1306                                        if let Some(cell) = screen.cell(r, c) {
1307                                            let s_cell = cell.contents();
1308                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1309                                        } else {
1310                                            row_text.push(' ');
1311                                        }
1312                                    }
1313                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1314                                }
1315                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1316                                s.current = None;
1317                                log::debug!("terminal: ToggleSmartCase q='{}' matches_total={}", q, s.matches.len());
1318                            }
1319                        }
1320                    }
1321                    SearchOp::NextMatch => {
1322                        // Operate on active search, or fall back to last_search (post-close F3).
1323                        if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
1324                                let next = match s.current { Some(i) => (i + 1) % s.matches.len(), None => 0 };
1325                                s.current = Some(next);
1326                                let (match_line, _, _) = s.matches[next];
1327                                log::debug!("terminal: NextMatch idx={} total={} match_line={}", next, s.matches.len(), match_line);
1328                                if let Some(tab) = self.tabs.get_mut(self.active) {
1329                                    scroll_to_match(match_line, tab, rows);
1330                                }
1331                        }
1332                    }
1333                    SearchOp::PrevMatch => {
1334                        if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
1335                                let prev = match s.current {
1336                                    Some(0) | None => s.matches.len().saturating_sub(1),
1337                                    Some(i) => i - 1,
1338                                };
1339                                s.current = Some(prev);
1340                                let (match_line, _, _) = s.matches[prev];
1341                                log::debug!("terminal: PrevMatch idx={} total={} match_line={}", prev, s.matches.len(), match_line);
1342                                if let Some(tab) = self.tabs.get_mut(self.active) {
1343                                    scroll_to_match(match_line, tab, rows);
1344                                }
1345                        }
1346                    }
1347                    _ => {}
1348                }
1349                Some(Event::applied("terminal", op.clone()))
1350            }
1351
1352            // ── Close via op (app.rs handles spawning the replacement if needed) ─
1353            Operation::TerminalLocal(TerminalOp::CloseTab { id }) => {
1354                self.close_tab_by_id(*id);
1355                Some(Event::applied("terminal", op.clone()))
1356            }
1357
1358            // ── Resize ──────────────────────────────────────────────────────
1359            Operation::TerminalLocal(TerminalOp::Resize { cols, rows }) => {
1360                self.last_size.set((*cols, *rows));
1361                self.update_from_settings(settings);
1362                self.resize_all(*rows, *cols);
1363                Some(Event::applied("terminal", op.clone()))
1364            }
1365
1366            _ => None,
1367        }
1368    }
1369
1370    fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
1371        // Fallback sizing: if crossterm::terminal::size() failed at startup and
1372        // we got (0, 0), use the actual frame area. Normal resize events from
1373        // the event loop handle size changes via TerminalOp::Resize.
1374        if self.last_size.get() == (0, 0) && area.width > 0 && area.height > 0 {
1375            self.last_size.set((area.width, area.height));
1376        }
1377        log::debug!(
1378            "TerminalView::render: area={}x{} last_size={:?}",
1379            area.width,
1380            area.height,
1381            self.last_size.get()
1382        );
1383
1384        // When search bar is open, reserve the top row for it.
1385        let search_active = self.search.is_some();
1386        let (content_area, bar_area) = if search_active && area.height > 1 {
1387            let bar = Rect { y: area.y, height: 1, ..area };
1388            let content = Rect { y: area.y + 1, height: area.height - 1, ..area };
1389            (content, Some(bar))
1390        } else {
1391            (area, None)
1392        };
1393
1394        // ── Terminal content ────────────────────────────────────────────────
1395        if let Some(tab) = self.tabs.get(self.active) {
1396            {
1397                let mut parser = tab.parser.borrow_mut();
1398                parser.set_scrollback(tab.scroll_offset as usize);
1399            }
1400            // Build visible search highlights: active search OR last_search (for post-close F3).
1401            let parser_ref = tab.parser.borrow();
1402            let mut links = tab.links.clone();
1403            let active_search = self.search.as_ref().or(self.last_search.as_ref());
1404            if let Some(search) = active_search {
1405                log::debug!("terminal.render: query='{}' matches_total={} current={:?} existing_links={}", search.query.text(), search.matches.len(), search.current, tab.links.len());
1406                let q = search.query.text();
1407                if !q.is_empty() {
1408                    let screen = parser_ref.screen();
1409                    let (rows, cols) = screen.size();
1410                    let ignore = search.opts.ignore_case
1411                        || (search.opts.smart_case && !q.chars().any(|c| c.is_uppercase()));
1412                    if search.opts.regex {
1413                        if let Ok(re) = regex::RegexBuilder::new(q).case_insensitive(ignore).build() {
1414                            for r in 0..rows {
1415                                let mut row_text = String::with_capacity(cols as usize);
1416                                for c in 0..cols {
1417                                    if let Some(cell) = screen.cell(r, c) {
1418                                        let s = cell.contents();
1419                                        row_text.push_str(if s.is_empty() { " " } else { &s });
1420                                    } else {
1421                                        row_text.push(' ');
1422                                    }
1423                                }
1424                                for m in re.find_iter(&row_text) {
1425                                    links.push(crate::widgets::terminal::Link {
1426                                        kind: crate::widgets::terminal::LinkKind::Search,
1427                                        row: r,
1428                                        start_col: m.start() as u16,
1429                                        end_col: m.end() as u16,
1430                                        text: row_text[m.start()..m.end()].to_string(),
1431                                    });
1432                                }
1433                            }
1434                        }
1435                    } else {
1436                        let q_cmp = if ignore { q.to_lowercase() } else { q.to_owned() };
1437                        for r in 0..rows {
1438                            let mut row_text = String::with_capacity(cols as usize);
1439                            for c in 0..cols {
1440                                if let Some(cell) = screen.cell(r, c) {
1441                                    let s = cell.contents();
1442                                    row_text.push_str(if s.is_empty() { " " } else { &s });
1443                                } else {
1444                                    row_text.push(' ');
1445                                }
1446                            }
1447                            let l = if ignore {row_text.to_lowercase() } else { row_text.clone() };
1448                            let mut start = 0usize;
1449                            while let Some(found) = l[start..].find(&q_cmp) {
1450                                let abs = start + found;
1451                                let end = abs + q.len();
1452                                links.push(crate::widgets::terminal::Link {
1453                                    kind: crate::widgets::terminal::LinkKind::Search,
1454                                    row: r,
1455                                    start_col: abs as u16,
1456                                    end_col: end as u16,
1457                                    text: row_text[abs..end].to_string(),
1458                                });
1459                                start = end;
1460                            }
1461                        }
1462                    }
1463
1464                                    // Additionally, if there is a current match index, add a SearchCurrent
1465                    // link so the widget can render it with a distinct style.
1466                    if let Some(cur_idx) = search.current && cur_idx < search.matches.len() {
1467                        let (match_line, start_col, end_col) = search.matches[cur_idx];
1468                        let total = tab.scrollback_lines.len();
1469                        let rows_usize = rows as usize;
1470                        // top_index is the combined-vector index of the first visible line.
1471                        // Matches were computed against a vector that is scrollback_lines
1472                        // followed by the current screen rows, so the first visible line
1473                        // has index == total - scroll_offset.
1474                        let top_index = total.saturating_sub(tab.scroll_offset as usize);
1475                        if match_line >= top_index && match_line < top_index + rows_usize {
1476                            let visible_row = (match_line - top_index) as u16;
1477                            // Extract matched text: from scrollback if within that range,
1478                            // otherwise from the live screen rows appended after scrollback.
1479                            let text = if match_line < total {
1480                                tab.scrollback_lines.get(match_line)
1481                                    .and_then(|l| l.text.get(start_col..end_col).map(|s| s.to_string()))
1482                                    .unwrap_or_default()
1483                            } else {
1484                                let vis_idx = match_line - total;
1485                                let mut row_text = String::with_capacity(cols as usize);
1486                                for c in 0..cols {
1487                                    if let Some(cell) = screen.cell(vis_idx as u16, c) {
1488                                        let s = cell.contents();
1489                                        row_text.push_str(if s.is_empty() { " " } else { &s });
1490                                    } else {
1491                                        row_text.push(' ');
1492                                    }
1493                                }
1494                                row_text.get(start_col..end_col).unwrap_or("").to_string()
1495                            };
1496                            links.push(crate::widgets::terminal::Link {
1497                                kind: crate::widgets::terminal::LinkKind::SearchCurrent,
1498                                row: visible_row,
1499                                start_col: start_col as u16,
1500                                end_col: end_col as u16,
1501                                text,
1502                            });
1503                        }
1504                    }
1505                }
1506            }
1507            frame.render_widget(
1508                crate::widgets::terminal::TerminalWidget { parser: parser_ref, links },
1509                content_area,
1510            );
1511        }
1512
1513        // ── Inline search bar (same visual as editor) ───────────────────────
1514        if let (Some(bar), Some(search)) = (bar_area, self.search.as_ref()) {
1515            self.render_search_bar(frame, bar, search);
1516        }
1517
1518        // ── Popup overlay ───────────────────────────────────────────────────
1519        let popup_snapshot = match &self.popup {
1520            Some(TabPopup::Menu { id, cursor }) => Some(PopupSnapshot::Menu { id: *id, cursor: *cursor }),
1521            Some(TabPopup::Rename { id, input }) => Some(PopupSnapshot::Rename { id: *id, input: input.text().to_owned() }),
1522            Some(TabPopup::Selector { cursor }) => Some(PopupSnapshot::Selector { cursor: *cursor }),
1523            None => None,
1524        };
1525        if let Some(snap) = popup_snapshot {
1526            match snap {
1527                PopupSnapshot::Menu { id, cursor } => self.render_menu_popup(frame, id, cursor),
1528                PopupSnapshot::Rename { id, input } => self.render_rename_popup(frame, id, &input),
1529                PopupSnapshot::Selector { cursor } => self.render_selector_popup(frame, area, cursor),
1530            }
1531        }
1532    }
1533}
1534
1535impl TerminalView {
1536    /// Render the one-row inline search bar — identical visual to the editor's search bar.
1537    fn render_search_bar(&self, frame: &mut Frame, area: Rect, search: &TerminalSearch) {
1538        use ratatui::layout::{Constraint, Direction, Layout};
1539        use ratatui::text::{Line, Span};
1540
1541        let bar_bg = Color::Rgb(30, 45, 70);
1542        let active_bg = Color::Rgb(50, 70, 110);
1543        let btn_on = Style::default().fg(Color::Black).bg(Color::Rgb(80, 170, 220));
1544        let btn_off = Style::default().fg(Color::DarkGray).bg(Color::Rgb(40, 55, 80));
1545
1546        const BTN_W: u16 = 27;
1547        let left_w = area.width.saturating_sub(BTN_W);
1548
1549        let btn_row = Line::from(vec![
1550            Span::styled(" ", Style::default().bg(bar_bg)),
1551            Span::styled(" IgnCase ", if search.opts.ignore_case { btn_on } else { btn_off }),
1552            Span::styled(" ", Style::default().bg(bar_bg)),
1553            Span::styled(" Regex ", if search.opts.regex { btn_on } else { btn_off }),
1554            Span::styled(" ", Style::default().bg(bar_bg)),
1555            Span::styled(" Smart ", if search.opts.smart_case { btn_on } else { btn_off }),
1556            Span::styled(" ", Style::default().bg(bar_bg)),
1557        ]);
1558
1559        let count_str = if search.matches.is_empty() {
1560            " No matches".to_owned()
1561        } else {
1562            let cur = search.current.map(|i| i + 1).unwrap_or(0);
1563            format!(" {}/{}", cur, search.matches.len())
1564        };
1565
1566        let query_line = format!(" Find: {}  {}", search.query.text(), count_str);
1567
1568        let [left_area, right_area] = Layout::default()
1569            .direction(Direction::Horizontal)
1570            .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
1571            .split(area)[..]
1572        else { return; };
1573
1574        frame.render_widget(
1575            Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(active_bg)),
1576            left_area,
1577        );
1578        frame.render_widget(
1579            Paragraph::new(btn_row).style(Style::default().bg(bar_bg)),
1580            right_area,
1581        );
1582    }
1583}
1584
1585
1586// Helper to avoid borrow-checker issues when rendering popup while borrowing self.
1587enum PopupSnapshot {
1588    Menu { id: u64, cursor: usize },
1589    Rename { id: u64, input: String },
1590    Selector { cursor: usize },
1591}
1592
1593// ---------------------------------------------------------------------------
1594// Persistence types
1595// ---------------------------------------------------------------------------
1596
1597#[derive(Debug, Default, Serialize, Deserialize)]
1598pub struct TerminalStateStore {
1599    pub tabs: Vec<TerminalTabState>,
1600    pub active: usize,
1601    pub next_id: u64,
1602}
1603
1604#[derive(Debug, Clone, Serialize, Deserialize)]
1605pub struct TerminalTabState {
1606    pub id: u64,
1607    pub title: String,
1608    pub command: String,
1609    pub cwd: PathBuf,
1610}