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