Skip to main content

fresh/app/
terminal.rs

1//! Terminal integration for the Editor
2//!
3//! This module provides methods for the Editor to interact with the terminal system:
4//! - Opening new terminal sessions
5//! - Closing terminals
6//! - Rendering terminal content
7//! - Handling terminal input
8//!
9//! # Role in Incremental Streaming Architecture
10//!
11//! This module handles mode switching between terminal and scrollback modes.
12//! See `crate::services::terminal` for the full architecture diagram.
13//!
14//! ## Mode Switching Methods
15//!
16//! - [`Window::sync_terminal_to_buffer`]: Terminal → Scrollback mode
17//!   - Appends visible screen (~50 lines) to backing file
18//!   - Loads backing file as read-only buffer
19//!   - Performance: O(screen_size) ≈ 5ms
20//!
21//! - [`Editor::enter_terminal_mode`]: Scrollback → Terminal mode
22//!   - Truncates backing file to remove visible screen tail
23//!   - Resumes live terminal rendering
24//!   - Performance: O(1) ≈ 1ms
25
26use super::window::Window;
27use super::{BufferId, BufferMetadata, Editor};
28use crate::model::event::LeafId;
29use crate::services::authority::TerminalWrapper;
30use crate::services::terminal::TerminalId;
31use crate::state::EditorState;
32use crate::view::split::SplitViewState;
33use rust_i18n::t;
34use std::path::PathBuf;
35
36/// How often [`Window::sync_terminal_titles`] polls each terminal's
37/// foreground process group for tmux-style tab auto-naming. Frequent enough
38/// to feel responsive when a command starts/exits, infrequent enough that
39/// the per-terminal `tcgetpgrp` + `/proc` read is negligible. Also drives
40/// the editor's periodic-redraw deadline so the tab refreshes while idle.
41pub(crate) const FG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1000);
42
43/// Combine the foreground process name with the program's OSC title into one
44/// tab label. The command leads (short, answers "what's running"); the OSC
45/// title follows as context, e.g. `python3 — root@host: ~/proj`.
46///
47/// Returns `None` only when both are absent, so the caller falls back to the
48/// default name. When the OSC title already names the command (e.g. vim's
49/// `file - VIM`), the command isn't prepended again to avoid `vim — … VIM`.
50fn combine_terminal_title(pty: Option<&str>, osc: Option<&str>) -> Option<String> {
51    match (pty, osc) {
52        (Some(p), Some(o)) => {
53            if o.to_lowercase().contains(&p.to_lowercase()) {
54                Some(o.to_string())
55            } else {
56                Some(format!("{p} \u{2014} {o}"))
57            }
58        }
59        (Some(p), None) => Some(p.to_string()),
60        (None, Some(o)) => Some(o.to_string()),
61        (None, None) => None,
62    }
63}
64
65impl Window {
66    /// Resolve the terminal wrapper used to spawn a new integrated
67    /// terminal in this window, applying the `terminal.shell` config
68    /// override on top of the authority's wrapper when appropriate.
69    ///
70    /// See `TerminalWrapper::with_user_shell_override` for the override
71    /// rules; this is just the per-window wiring that supplies the
72    /// active config.
73    pub(crate) fn resolved_terminal_wrapper(&self) -> TerminalWrapper {
74        self.authority()
75            .terminal_wrapper
76            .clone()
77            .with_user_shell_override(self.resources.config.terminal.shell.as_ref())
78    }
79
80    /// The activated-environment delta (venv/direnv/mise) to apply to a newly
81    /// spawned terminal, so it inherits the same env that LSP servers and
82    /// `spawnProcess` already get (issue #2355; see
83    /// docs/internal/uniform-env-activation-design.md). Captured only for a
84    /// **local** host shell: `manages_cwd` marks docker/ssh-style wrappers whose
85    /// inner shell runs on another host, where this locally-captured delta would
86    /// be both wrong and unreachable (the env this `CommandBuilder` sets lands on
87    /// the `docker`/`ssh` client process, not the remote shell). Those backends
88    /// apply their own delta in the wrapper (the per-backend apply paths in the
89    /// design doc). Empty when no env is active or capture fails — the terminal
90    /// degrades to the inherited env exactly as before.
91    pub(crate) fn terminal_env_delta(
92        &self,
93        wrapper: &TerminalWrapper,
94    ) -> crate::services::env_provider::EnvDelta {
95        if wrapper.manages_cwd {
96            return crate::services::env_provider::EnvDelta::default();
97        }
98        self.authority().env_provider.current_local_delta_blocking()
99    }
100
101    /// Apply the activated environment to a *re-parented* terminal wrapper
102    /// (SSH / container), the remote counterpart of [`Self::terminal_env_delta`]
103    /// (which handles the local host shell via `CommandBuilder.env`). For SSH,
104    /// rewrite the remote login-shell `exec` into a python3 launcher that
105    /// captures + applies the activation on the remote before handing off to the
106    /// user's shell, so the SSH terminal sees the same env LSP/`spawnProcess`
107    /// already get (issue #2355). Returns the wrapper unchanged when no env is
108    /// active or the wrapper isn't an SSH re-parent. (Container backends apply
109    /// their captured env through their own wrapper flags; see the design doc.)
110    pub(crate) fn apply_remote_terminal_env(
111        &self,
112        mut wrapper: TerminalWrapper,
113    ) -> TerminalWrapper {
114        use crate::services::remote::{ssh_remote_env_launcher, SSH_EXEC_LOGIN_SHELL};
115
116        if wrapper.command == "ssh" && self.authority().env_provider.is_active() {
117            let recipe = self.authority().env_provider.snippet();
118            if let Some(last) = wrapper.args.last_mut() {
119                if last.contains(SSH_EXEC_LOGIN_SHELL) {
120                    *last = last.replace(SSH_EXEC_LOGIN_SHELL, &ssh_remote_env_launcher(&recipe));
121                }
122            }
123        }
124        wrapper
125    }
126
127    /// Get terminal dimensions appropriate for spawning a PTY in this
128    /// window. Derived from the window's cached screen size minus a
129    /// small constant for menu/status chrome.
130    pub(crate) fn get_terminal_dimensions(&self) -> (u16, u16) {
131        let cols = self.terminal_width.saturating_sub(2).max(40);
132        let rows = self.terminal_height.saturating_sub(4).max(10);
133        (cols, rows)
134    }
135
136    /// Spawn a new PTY-backed terminal session in this window and
137    /// record its log/backing files. Returns the terminal id on
138    /// success — does **not** create a buffer or attach to any
139    /// split. Callers are responsible for the rest of the wiring
140    /// (see `create_terminal_buffer_attached` /
141    /// `create_terminal_buffer_detached`).
142    ///
143    /// `cwd` defaults to this window's `root` when None. `persistent`
144    /// controls whether the backing files use stable names
145    /// (`fresh-terminal-N.{log,txt}`) so workspace restore can find
146    /// them, or per-spawn ephemeral suffixes
147    /// (`fresh-terminal-eph-N-<ts>.{log,txt}`); non-persistent
148    /// terminals are also added to `ephemeral_terminals` so the
149    /// workspace serialiser skips them.
150    ///
151    /// On spawn failure the error is logged and a status message is
152    /// set on this window; the caller gets `None` back.
153    pub fn spawn_terminal_session(
154        &mut self,
155        cwd: Option<PathBuf>,
156        persistent: bool,
157        command_override: Option<Vec<String>>,
158    ) -> Option<TerminalId> {
159        let (cols, rows) = self.get_terminal_dimensions();
160
161        // Per-window async bridge — terminal output flows back through
162        // the window that owns the PTY.
163        let bridge = self.bridge.clone();
164        self.terminal_manager.set_async_bridge(bridge);
165
166        let working_dir = cwd.unwrap_or_else(|| self.root.clone());
167        let terminal_root = self.resources.dir_context.terminal_dir_for(&working_dir);
168        if let Err(e) = self.authority().filesystem.create_dir_all(&terminal_root) {
169            tracing::warn!("Failed to create terminal directory: {}", e);
170        }
171
172        // Precompute paths using the next terminal ID so we capture
173        // from the first byte. Ephemeral terminals get a per-spawn
174        // suffix so there is no possibility of picking up scrollback
175        // a previous run (with the same numeric terminal ID) wrote
176        // to the same path.
177        let predicted_terminal_id = self.terminal_manager.next_terminal_id();
178        let name_stem = if persistent {
179            format!("fresh-terminal-{}", predicted_terminal_id.0)
180        } else {
181            let nanos = std::time::SystemTime::now()
182                .duration_since(std::time::UNIX_EPOCH)
183                .map(|d| d.as_nanos())
184                .unwrap_or(0);
185            format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
186        };
187        let log_path = terminal_root.join(format!("{}.log", name_stem));
188        let backing_path = terminal_root.join(format!("{}.txt", name_stem));
189        self.terminal_backing_files
190            .insert(predicted_terminal_id, backing_path.clone());
191
192        // When the caller supplies an explicit argv, build a wrapper
193        // that runs it *inside this session's backend* via the authority:
194        // local runs it directly as the PTY child; a container authority
195        // prepends `docker exec -it … <id>` so an agent terminal runs in the
196        // container rather than on the host (see `Authority::terminal_command`).
197        // Empty argv falls back to the interactive shell.
198        let wrapper = match command_override {
199            Some(argv) if !argv.is_empty() => self.authority().terminal_command(&argv),
200            _ => self.resolved_terminal_wrapper(),
201        };
202        let wrapper = self.apply_remote_terminal_env(wrapper);
203        let env_delta = self.terminal_env_delta(&wrapper);
204        match self.terminal_manager.spawn(
205            cols,
206            rows,
207            Some(working_dir),
208            Some(log_path.clone()),
209            Some(backing_path),
210            wrapper,
211            env_delta,
212        ) {
213            Ok(terminal_id) => {
214                self.terminal_log_files.insert(terminal_id, log_path);
215                // If the actual terminal id differs from the predicted
216                // one, move the backing-file entry to the real id and
217                // rename to the persistent (no-eph-suffix) form. This
218                // mirrors the pre-migration behaviour exactly.
219                if terminal_id != predicted_terminal_id {
220                    self.terminal_backing_files.remove(&predicted_terminal_id);
221                    let backing_path =
222                        terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0));
223                    self.terminal_backing_files
224                        .insert(terminal_id, backing_path);
225                }
226                if !persistent {
227                    self.ephemeral_terminals.insert(terminal_id);
228                }
229                Some(terminal_id)
230            }
231            Err(e) => {
232                self.set_status_message(
233                    t!("terminal.failed_to_open", error = e.to_string()).to_string(),
234                );
235                tracing::error!("Failed to open terminal: {}", e);
236                None
237            }
238        }
239    }
240
241    /// Create a buffer for a terminal session in this window, attached
242    /// to the specified split. Mirrors the pre-migration body of
243    /// `Editor::create_terminal_buffer_attached`.
244    pub fn create_terminal_buffer_attached(
245        &mut self,
246        terminal_id: TerminalId,
247        split_id: LeafId,
248    ) -> BufferId {
249        let buffer_id = self.alloc_buffer_id();
250        let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
251
252        // Rendered backing file for scrollback view (reuse if already
253        // recorded by `spawn_terminal_session`).
254        let backing_file = self
255            .terminal_backing_files
256            .get(&terminal_id)
257            .cloned()
258            .unwrap_or_else(|| {
259                let root = self.resources.dir_context.terminal_dir_for(&self.root);
260                if let Err(e) = self.authority().filesystem.create_dir_all(&root) {
261                    tracing::warn!("Failed to create terminal directory: {}", e);
262                }
263                root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
264            });
265
266        // Ensure the file exists — but DON'T truncate if it already has
267        // content. The PTY read loop may have already started writing
268        // scrollback.
269        if !self.authority().filesystem.exists(&backing_file) {
270            if let Err(e) = self.authority().filesystem.write_file(&backing_file, &[]) {
271                tracing::warn!("Failed to create terminal backing file: {}", e);
272            }
273        }
274
275        self.terminal_backing_files
276            .insert(terminal_id, backing_file.clone());
277
278        let mut state = EditorState::new_with_path(
279            large_file_threshold,
280            std::sync::Arc::clone(&self.authority().filesystem),
281            backing_file.clone(),
282        );
283        state.margins.configure_for_line_numbers(false);
284        self.buffers.insert(buffer_id, state);
285
286        // Virtual metadata so the tab shows "*Terminal N*" and LSP
287        // stays off.
288        let metadata = BufferMetadata::virtual_buffer(
289            format!("*Terminal {}*", terminal_id.0),
290            "terminal".into(),
291            false,
292        );
293        self.buffer_metadata.insert(buffer_id, metadata);
294        self.terminal_buffers.insert(buffer_id, terminal_id);
295        self.event_logs
296            .insert(buffer_id, crate::model::event::EventLog::new());
297
298        if let Some(view_states) = self.split_view_states_mut() {
299            if let Some(view_state) = view_states.get_mut(&split_id) {
300                view_state.add_buffer(buffer_id);
301                // Terminal buffers should not wrap lines so escape
302                // sequences stay intact.
303                view_state.viewport.line_wrap_enabled = false;
304                // Disable line numbers + current-line highlight for the
305                // terminal buffer's per-buffer view state so exiting
306                // terminal mode doesn't suddenly add a gutter / row
307                // highlight. The render path overwrites the buffer's
308                // margin config every frame from this view-state flag,
309                // so setting it here is required even though
310                // `state.margins.configure_for_line_numbers(false)` was
311                // already called above.
312                let buf_state = view_state.ensure_buffer_state(buffer_id);
313                buf_state.show_line_numbers = false;
314                buf_state.highlight_current_line = false;
315                buf_state.viewport.line_wrap_enabled = false;
316            }
317        }
318
319        buffer_id
320    }
321
322    /// Plugin-facing terminal creation in this window. Handles all
323    /// the variants the JS `editor.createTerminal` API exposes:
324    ///
325    /// - `direction = None`: attach the terminal as a new tab in the
326    ///   window's active split (or seed a fresh split layout rooted
327    ///   at the terminal if the window has never been activated and
328    ///   therefore has no layout yet).
329    /// - `direction = Some(dir)`: create a new horizontal/vertical
330    ///   split off the active split and place the terminal there.
331    ///   `ratio` controls the split's size (default 0.5). `focus`
332    ///   controls whether the new split becomes the window's active
333    ///   split.
334    ///
335    /// In all cases the leader pid is registered with the window's
336    /// `process_groups` tracker so cross-window signal operations
337    /// (Stop / Archive / Delete) can reach the spawned process group.
338    ///
339    /// Returns `(terminal_id, buffer_id, created_split_id)` on
340    /// success. `created_split_id` is `Some` when a split was created
341    /// (either explicitly via `direction = Some` or implicitly when
342    /// seeding a fresh layout in a never-activated window).
343    pub fn create_plugin_terminal(
344        &mut self,
345        cwd: Option<PathBuf>,
346        direction: Option<crate::model::event::SplitDirection>,
347        ratio: Option<f32>,
348        focus: bool,
349        persistent: bool,
350        command: Option<Vec<String>>,
351        title: Option<String>,
352    ) -> Result<(TerminalId, BufferId, Option<LeafId>), String> {
353        // Derive the auto-title from the command's executable name
354        // (basename of argv[0]). The host writes this into the
355        // terminal buffer's `BufferMetadata::name` so the tab reads
356        // e.g. "python3" instead of "*Terminal N*" when the plugin
357        // runs python3 directly. Explicit `title` overrides.
358        let auto_title = command.as_ref().and_then(|argv| {
359            argv.first().map(|cmd| {
360                std::path::Path::new(cmd)
361                    .file_name()
362                    .and_then(|os| os.to_str())
363                    .unwrap_or(cmd.as_str())
364                    .to_string()
365            })
366        });
367        let resolved_title = title.or(auto_title);
368        let terminal_id = self
369            .spawn_terminal_session(cwd, persistent, command)
370            .ok_or_else(|| "Failed to spawn terminal".to_string())?;
371
372        // Register the leader pid with this window's process_groups
373        // so window-level signal operations reach the spawned group.
374        if let Some(pid) = self.terminal_manager.get(terminal_id).and_then(|h| h.pid()) {
375            let label = format!("terminal #{}", terminal_id.0);
376            self.process_groups.register(pid, label);
377        }
378
379        // Compute split-creation behaviour. The two cases (with /
380        // without direction) diverge in whether we attach to the
381        // active split as a new tab or create a fresh split off it.
382        // The "never-activated, no layout yet" case is handled in
383        // both branches by seeding a SplitManager rooted at the new
384        // terminal buffer.
385        let active_split = self.buffers.splits().map(|(mgr, _)| mgr.active_split());
386
387        let (buffer_id, created_split_id) = if let Some(split_dir) = direction {
388            let buffer_id = self.create_terminal_buffer_detached(terminal_id);
389            match active_split {
390                Some(parent) => {
391                    let split_ratio = ratio.unwrap_or(0.5);
392                    let line_numbers = self.resources.config.editor.line_numbers;
393                    let highlight_current_line =
394                        self.resources.config.editor.highlight_current_line;
395                    let rulers = self.resources.config.editor.rulers.clone();
396                    let terminal_width = self.terminal_width;
397                    let terminal_height = self.terminal_height;
398                    let split_result = self
399                        .split_manager_mut()
400                        .expect("active split implies populated layout")
401                        .split_active(split_dir, buffer_id, split_ratio);
402                    match split_result {
403                        Ok(new_split_id) => {
404                            let mut view_state = SplitViewState::with_buffer(
405                                terminal_width,
406                                terminal_height,
407                                buffer_id,
408                            );
409                            // Terminal-dedicated splits never show
410                            // line numbers or current-line highlight
411                            // — the buffer is a PTY scrollback view,
412                            // not source code. (Pre-fix the config
413                            // default was applied, so a default-on
414                            // line-numbers user saw `1 │ Python …`
415                            // in every orchestrator agent split.)
416                            // Other splits in the window aren't
417                            // affected because each `SplitViewState`
418                            // is independent.
419                            let _ = line_numbers;
420                            let _ = highlight_current_line;
421                            view_state
422                                .apply_config_defaults(false, false, false, false, None, rulers, 0);
423                            // Terminal output is ANSI-sequenced and
424                            // assumes a fixed column count; wrapping
425                            // would mangle cursor positioning.
426                            view_state.viewport.line_wrap_enabled = false;
427                            self.split_view_states_mut()
428                                .expect("active split implies populated layout")
429                                .insert(new_split_id, view_state);
430                            if focus {
431                                self.split_manager_mut()
432                                    .expect("active split implies populated layout")
433                                    .set_active_split(new_split_id);
434                            }
435                            (buffer_id, Some(new_split_id))
436                        }
437                        Err(e) => {
438                            tracing::error!(
439                                "Failed to create split for terminal: {e}; \
440                                 falling back to attaching to active split"
441                            );
442                            // Graceful fallback: attach to the active
443                            // split so the buffer isn't orphaned.
444                            if let Some(view_state) = self
445                                .split_view_states_mut()
446                                .and_then(|m| m.get_mut(&parent))
447                            {
448                                view_state.add_buffer(buffer_id);
449                                view_state.viewport.line_wrap_enabled = false;
450                            }
451                            self.set_active_buffer(buffer_id);
452                            (buffer_id, None)
453                        }
454                    }
455                }
456                None => {
457                    // Never-activated window with no layout — seed
458                    // one rooted at the terminal buffer. First dive
459                    // picks it up and the terminal is the active leaf.
460                    let manager = crate::view::split::SplitManager::new(buffer_id);
461                    let active_leaf = manager.active_split();
462                    let mut view_states = std::collections::HashMap::new();
463                    let mut vs = SplitViewState::with_buffer(
464                        self.terminal_width,
465                        self.terminal_height,
466                        buffer_id,
467                    );
468                    vs.viewport.line_wrap_enabled = false;
469                    view_states.insert(active_leaf, vs);
470                    self.buffers.set_splits((manager, view_states));
471                    (buffer_id, Some(active_leaf))
472                }
473            }
474        } else {
475            match active_split {
476                Some(split_id) => {
477                    let buffer_id = self.create_terminal_buffer_attached(terminal_id, split_id);
478                    // Switch tabs to the terminal. Window-side
479                    // mutation only — the editor-wide
480                    // `buffer_activated` hook is fired by the
481                    // Editor wrapper iff this window is the
482                    // editor-active one.
483                    self.set_active_buffer(buffer_id);
484                    (buffer_id, None)
485                }
486                None => {
487                    let buffer_id = self.create_terminal_buffer_detached(terminal_id);
488                    let manager = crate::view::split::SplitManager::new(buffer_id);
489                    let active_leaf = manager.active_split();
490                    let mut view_states = std::collections::HashMap::new();
491                    let mut vs = SplitViewState::with_buffer(
492                        self.terminal_width,
493                        self.terminal_height,
494                        buffer_id,
495                    );
496                    vs.viewport.line_wrap_enabled = false;
497                    view_states.insert(active_leaf, vs);
498                    self.buffers.set_splits((manager, view_states));
499                    (buffer_id, Some(active_leaf))
500                }
501            }
502        };
503
504        // Override the auto-generated `*Terminal N*` display name
505        // when the plugin requested an explicit title (or one was
506        // derived from `command[0]`). Disambiguates against other
507        // terminals in this window using a `name (k)` suffix so two
508        // simultaneous python3 sessions read as "python3" and
509        // "python3 (2)" instead of colliding.
510        if let Some(title) = resolved_title {
511            let final_name = self.disambiguate_terminal_title(&title, buffer_id);
512            if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
513                meta.display_name = final_name;
514            }
515            // Mark this tab as explicitly titled so foreground-process
516            // auto-naming leaves it alone (an OSC title still overrides).
517            self.terminal_explicit_titles.insert(buffer_id);
518        }
519
520        // When the new terminal ended up as this window's active
521        // buffer, switch the window into terminal mode so the live
522        // grid renders immediately. Without this, the renderer
523        // skips the grid (see `render_terminal_splits` — it defers
524        // to the file-backed scrollback view whenever the active
525        // tab is a terminal buffer but the window is not in
526        // terminal mode) and the user sees a blank tab until the
527        // next event flips `terminal_mode` — typically the next
528        // printable keystroke via `should_enter_terminal_mode`.
529        // Mirrors `open_terminal_in_window`'s post-spawn flip.
530        if self.active_buffer() == buffer_id {
531            self.terminal_mode = true;
532            self.key_context = crate::input::keybindings::KeyContext::Terminal;
533        }
534
535        self.resize_visible_terminals();
536        Ok((terminal_id, buffer_id, created_split_id))
537    }
538
539    /// Pick the next free `name (k)` variant of `desired` for this
540    /// window's set of terminal buffers. `for_buffer` is the
541    /// freshly-created buffer being titled — its own metadata is
542    /// excluded from the scan so we don't collide with ourselves
543    /// when callers pre-set it.
544    ///
545    /// Returns `desired` verbatim when no collision exists, otherwise
546    /// `desired (2)`, `desired (3)`, … as needed.
547    fn disambiguate_terminal_title(&self, desired: &str, for_buffer: BufferId) -> String {
548        // Collect existing terminal-buffer display names that share
549        // the desired prefix. Only inspect buffers that are actually
550        // terminals — non-terminal buffers happen to use the same
551        // metadata map but their names don't collide semantically.
552        let used: std::collections::HashSet<&str> = self
553            .terminal_buffers
554            .keys()
555            .filter(|bid| **bid != for_buffer)
556            .filter_map(|bid| {
557                self.buffer_metadata
558                    .get(bid)
559                    .map(|m| m.display_name.as_str())
560            })
561            .collect();
562        if !used.contains(desired) {
563            return desired.to_string();
564        }
565        // Linear scan from k=2 upward. Two simultaneous duplicates is
566        // already rare; ten is unheard of, so the loop bound is fine.
567        for k in 2..=1024 {
568            let candidate = format!("{} ({})", desired, k);
569            if !used.contains(candidate.as_str()) {
570                return candidate;
571            }
572        }
573        // Fall back to `desired (∞)` if for some reason 1024 names
574        // are taken — still unique because the loop exhausted the
575        // numeric variants we considered. Practically unreachable.
576        format!("{} (n)", desired)
577    }
578
579    /// Refresh terminal buffers' tab titles, tmux-style. Runs every frame,
580    /// but the expensive part — reading each terminal's foreground process
581    /// group (`tcgetpgrp` + `/proc`) — is throttled to [`FG_POLL_INTERVAL`]
582    /// and cached; the cached name is re-applied to the tab on every frame
583    /// so the title is responsive to renders without re-running the syscall.
584    ///
585    /// The tab label **combines** two sources (see [`combine_terminal_title`]):
586    ///
587    /// - **Foreground process name** — the command currently in the
588    ///   terminal's foreground process group (e.g. `python3` while a REPL
589    ///   runs, `bash` at the prompt). Mirrors tmux's
590    ///   `#{pane_current_command}`; read on Linux, `None` elsewhere.
591    /// - **OSC title** — what a program set via OSC 0/1/2 (e.g. a shell's
592    ///   `user@host: ~/dir` prompt title, or vim's `file - VIM`).
593    ///
594    /// e.g. `python3 — root@host: ~/proj`. When only one is present that one
595    /// is used; when neither is, the default `*Terminal N*` stands.
596    ///
597    /// Terminals with an explicit (plugin-/command-derived) title are left
598    /// untouched — like a tmux manual rename, an intentional name opts out
599    /// of auto-naming.
600    ///
601    /// Both parts are sanitized (control characters stripped, length capped)
602    /// the same way as the host window title, and applied without the
603    /// `name (k)` disambiguation used for plugin titles.
604    pub fn sync_terminal_titles(&mut self) {
605        // Gated by config: when off, tabs keep their static `*Terminal N*`
606        // (or plugin) names. Clearing the cache lets a later enable start
607        // fresh.
608        if !self.config().editor.terminal_auto_title {
609            self.terminal_fg_cache.clear();
610            return;
611        }
612
613        // Refresh the foreground-name cache. A terminal is re-read when the
614        // poll interval has elapsed, or eagerly while it has no cached name
615        // yet (its first prompt may not have a foreground pgid the instant
616        // it spawns, and renders are event-driven — so keep trying until it
617        // resolves rather than waiting a full interval).
618        let now = std::time::Instant::now();
619        let interval_due = self
620            .terminal_fg_poll_at
621            .is_none_or(|last| now.duration_since(last) >= FG_POLL_INTERVAL);
622        if interval_due {
623            self.terminal_fg_poll_at = Some(now);
624        }
625        for (buffer_id, terminal_id) in self.terminal_buffers.iter() {
626            if self.terminal_explicit_titles.contains(buffer_id) {
627                continue;
628            }
629            if !interval_due && self.terminal_fg_cache.contains_key(buffer_id) {
630                continue;
631            }
632            let name = self
633                .terminal_manager
634                .get(*terminal_id)
635                .and_then(|h| h.foreground_process_name())
636                .map(|n| crate::services::terminal_title::sanitize_title(&n))
637                .filter(|n| !n.is_empty());
638            match name {
639                Some(n) => {
640                    self.terminal_fg_cache.insert(*buffer_id, n);
641                }
642                None => {
643                    self.terminal_fg_cache.remove(buffer_id);
644                }
645            }
646        }
647
648        // Apply a title to every (non-explicit) terminal tab every frame,
649        // combining the cached foreground name with the current OSC title.
650        // Snapshot first so the mutable `buffer_metadata` borrow doesn't
651        // overlap the immutable reads above.
652        let mut updates: Vec<(BufferId, String)> = Vec::new();
653        for (buffer_id, terminal_id) in self.terminal_buffers.iter() {
654            if self.terminal_explicit_titles.contains(buffer_id) {
655                continue;
656            }
657            let pty = self.terminal_fg_cache.get(buffer_id).cloned();
658            let osc = self.terminal_manager.get(*terminal_id).and_then(|handle| {
659                let osc = handle.state.lock().ok()?.title().to_string();
660                let sanitized = crate::services::terminal_title::sanitize_title(&osc);
661                (!sanitized.is_empty()).then_some(sanitized)
662            });
663            let name = combine_terminal_title(pty.as_deref(), osc.as_deref())
664                .unwrap_or_else(|| format!("*Terminal {}*", terminal_id.0));
665            updates.push((*buffer_id, name));
666        }
667
668        for (buffer_id, title) in updates {
669            if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
670                if meta.display_name != title {
671                    meta.display_name = title;
672                }
673            }
674        }
675    }
676
677    /// Open a new terminal in this window: spawn the PTY, create
678    /// the buffer, attach to the active split, switch this window's
679    /// active buffer to it, enable terminal mode, and resize the PTY
680    /// to match the split's content area. Returns `(terminal_id,
681    /// buffer_id)` on success.
682    ///
683    /// Editor-wide effects (the `buffer_activated` plugin hook, the
684    /// status-bar exit-key message) are NOT fired here — that's the
685    /// caller's responsibility, gated on whether this window is the
686    /// editor-active one. See `Editor::open_terminal` for the
687    /// active-window wrapper that does both.
688    pub fn open_terminal_in_window(&mut self) -> Option<(TerminalId, BufferId)> {
689        // `None` command override — `Open Terminal` always spawns the
690        // user's shell, never a one-off command. Plugin-driven
691        // terminals route through `create_plugin_terminal` instead.
692        let terminal_id = self.spawn_terminal_session(None, true, None)?;
693        let split_id = self
694            .buffers
695            .splits()
696            .map(|(mgr, _)| mgr.active_split())
697            .expect("window must have a populated split layout");
698        let buffer_id = self.create_terminal_buffer_attached(terminal_id, split_id);
699        // Window-side activation: per-window mutation only — the
700        // editor-wide plugin hook fires in the Editor wrapper.
701        self.set_active_buffer(buffer_id);
702        self.terminal_mode = true;
703        self.key_context = crate::input::keybindings::KeyContext::Terminal;
704        self.resize_visible_terminals();
705        Some((terminal_id, buffer_id))
706    }
707
708    /// Create a buffer for a terminal session in this window without
709    /// attaching to any split (used during session restore).
710    pub fn create_terminal_buffer_detached(&mut self, terminal_id: TerminalId) -> BufferId {
711        let buffer_id = self.alloc_buffer_id();
712        let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
713
714        let backing_file = self
715            .terminal_backing_files
716            .get(&terminal_id)
717            .cloned()
718            .unwrap_or_else(|| {
719                let root = self.resources.dir_context.terminal_dir_for(&self.root);
720                if let Err(e) = self.authority().filesystem.create_dir_all(&root) {
721                    tracing::warn!("Failed to create terminal directory: {}", e);
722                }
723                root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
724            });
725
726        if !self.authority().filesystem.exists(&backing_file) {
727            if let Err(e) = self.authority().filesystem.write_file(&backing_file, &[]) {
728                tracing::warn!("Failed to create terminal backing file: {}", e);
729            }
730        }
731
732        let mut state = EditorState::new_with_path(
733            large_file_threshold,
734            std::sync::Arc::clone(&self.authority().filesystem),
735            backing_file.clone(),
736        );
737        state.margins.configure_for_line_numbers(false);
738        self.buffers.insert(buffer_id, state);
739
740        let metadata = BufferMetadata::virtual_buffer(
741            format!("*Terminal {}*", terminal_id.0),
742            "terminal".into(),
743            false,
744        );
745        self.buffer_metadata.insert(buffer_id, metadata);
746        self.terminal_buffers.insert(buffer_id, terminal_id);
747        self.event_logs
748            .insert(buffer_id, crate::model::event::EventLog::new());
749
750        buffer_id
751    }
752
753    /// The terminal the user interacted with most recently: the latest
754    /// split in the focus LRU whose current buffer is a terminal. Falls
755    /// back to the newest open terminal when no split currently shows
756    /// one (e.g. the terminal sits in a background tab), and `None`
757    /// when the window has no terminals at all.
758    pub fn last_focused_terminal(&self) -> Option<TerminalId> {
759        if let Some((mgr, _)) = self.buffers.splits() {
760            let terminal_of_leaf = |leaf: LeafId| {
761                mgr.get_buffer_id(leaf.into())
762                    .and_then(|buffer_id| self.terminal_buffers.get(&buffer_id).copied())
763            };
764            if let Some(leaf) = mgr.last_focused_where(|leaf| terminal_of_leaf(leaf).is_some()) {
765                return terminal_of_leaf(leaf);
766            }
767        }
768        self.terminal_buffers.values().copied().max_by_key(|t| t.0)
769    }
770}
771
772impl Editor {
773    /// Spawn a new PTY-backed terminal session in the active window
774    /// using its `root` as cwd. Editor-side thin wrapper; per-window
775    /// body lives in `Window::spawn_terminal_session`.
776    ///
777    /// Used by `open_terminal` (regular spawn into the active split)
778    /// and by `Action::OpenTerminalInDock` (which needs the buffer
779    /// id *before* it has a split to attach to, so the dock leaf can
780    /// be seeded with the terminal directly rather than with a
781    /// placeholder buffer that would linger as a phantom tab).
782    pub(crate) fn spawn_terminal_session(&mut self) -> Option<TerminalId> {
783        // No command override — see comment on `Window::open_terminal_in_window`.
784        self.active_window_mut()
785            .spawn_terminal_session(None, true, None)
786    }
787
788    /// Open a new terminal in the active window's current split, fire
789    /// the editor-wide `buffer_activated` plugin hook, and post a
790    /// status-bar message with the terminal-mode exit key.
791    ///
792    /// Window-side body lives in `Window::open_terminal_in_window`;
793    /// this router adds only the cross-cutting effects that require
794    /// editor-level state (the plugin hook + status message).
795    pub fn open_terminal(&mut self) {
796        let Some((terminal_id, buffer_id)) = self.active_window_mut().open_terminal_in_window()
797        else {
798            return;
799        };
800
801        // Editor-wide: refresh the plugin-state snapshot so plugin
802        // hooks see the new active buffer, then fire `buffer_activated`.
803        #[cfg(feature = "plugins")]
804        self.update_plugin_state_snapshot();
805        #[cfg(feature = "plugins")]
806        self.plugin_manager.read().unwrap().run_hook(
807            "buffer_activated",
808            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
809        );
810
811        // Status bar with the terminal-mode exit key. Looked up here
812        // (not in Window) because the keybinding resolver is shared
813        // editor state read through the `Arc<RwLock<…>>`.
814        let exit_key = self
815            .keybindings
816            .read()
817            .unwrap()
818            .find_keybinding_for_action(
819                "terminal_escape",
820                crate::input::keybindings::KeyContext::Terminal,
821            )
822            .unwrap_or_else(|| "Ctrl+Space".to_string());
823        self.set_status_message(
824            t!("terminal.opened", id = terminal_id.0, exit_key = exit_key).to_string(),
825        );
826        tracing::info!(
827            "Opened terminal {:?} with buffer {:?}",
828            terminal_id,
829            buffer_id
830        );
831    }
832
833    /// Editor-side thin wrapper. Delegates to the active window's
834    /// `Window::create_terminal_buffer_detached` (used during session
835    /// restore by `input.rs`).
836    pub(crate) fn create_terminal_buffer_detached(&mut self, terminal_id: TerminalId) -> BufferId {
837        self.active_window_mut()
838            .create_terminal_buffer_detached(terminal_id)
839    }
840
841    /// Close the current terminal (if viewing a terminal buffer)
842    pub fn close_terminal(&mut self) {
843        let buffer_id = self.active_buffer();
844
845        if let Some(&terminal_id) = self.active_window().terminal_buffers.get(&buffer_id) {
846            // Close the terminal
847            self.active_window_mut().terminal_manager.close(terminal_id);
848            self.active_window_mut().terminal_buffers.remove(&buffer_id);
849            self.active_window_mut()
850                .ephemeral_terminals
851                .remove(&terminal_id);
852
853            // Clean up backing/rendering file
854            let backing_file = self
855                .active_window_mut()
856                .terminal_backing_files
857                .remove(&terminal_id);
858            if let Some(ref path) = backing_file {
859                // Best-effort cleanup of temporary terminal files.
860                #[allow(clippy::let_underscore_must_use)]
861                let _ = self.authority().filesystem.remove_file(path);
862            }
863            // Clean up raw log file
864            if let Some(log_file) = self
865                .active_window_mut()
866                .terminal_log_files
867                .remove(&terminal_id)
868            {
869                if backing_file.as_ref() != Some(&log_file) {
870                    // Best-effort cleanup of temporary terminal files.
871                    #[allow(clippy::let_underscore_must_use)]
872                    let _ = self.authority().filesystem.remove_file(&log_file);
873                }
874            }
875
876            // Exit terminal mode
877            self.active_window_mut().terminal_mode = false;
878            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
879
880            // Close the buffer
881            if let Err(e) = self.close_buffer(buffer_id) {
882                tracing::warn!("Failed to close terminal buffer: {}", e);
883            }
884
885            self.set_status_message(t!("terminal.closed", id = terminal_id.0).to_string());
886        } else {
887            self.set_status_message(t!("status.not_viewing_terminal").to_string());
888        }
889    }
890
891    /// Send the current selection (or the cursor's line when nothing is
892    /// selected) to the most recently focused terminal, terminated with
893    /// a newline so shells/REPLs execute it — the "Run Selected Text In
894    /// Active Terminal" workflow from VS Code (issue #1871). The
895    /// terminal is then focused (jumping to its split or bringing its
896    /// tab forward) in terminal mode, so the user lands at the prompt.
897    pub fn send_selection_to_terminal(&mut self) {
898        // Only meaningful from an editor buffer; a terminal buffer has
899        // no text selection to send.
900        if self
901            .active_window()
902            .is_terminal_buffer(self.active_buffer())
903        {
904            return;
905        }
906
907        let Some(terminal_id) = self.active_window().last_focused_terminal() else {
908            self.set_status_message(t!("terminal.no_terminal_open").to_string());
909            return;
910        };
911
912        let text = self.selection_or_cursor_line_text();
913
914        // Same normalization as the terminal paste path (CRLF/CR →
915        // LF), plus a terminating newline so the last line runs.
916        let mut normalized = text.replace("\r\n", "\n").replace('\r', "\n");
917        if !normalized.ends_with('\n') {
918            normalized.push('\n');
919        }
920
921        if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
922            handle.write(normalized.as_bytes());
923            self.focus_terminal_buffer(terminal_id);
924            // After `enter_terminal_mode`'s generic message — the send
925            // destination is the more useful thing to surface.
926            self.set_status_message(t!("terminal.sent_selection", id = terminal_id.0).to_string());
927        }
928    }
929
930    /// Focus the buffer of the given terminal: jump to the split that
931    /// shows it, or — when it sits in a background tab — focus its host
932    /// split and bring the tab forward; then enable terminal mode so
933    /// keystrokes go to the prompt.
934    fn focus_terminal_buffer(&mut self, terminal_id: TerminalId) {
935        let Some(buffer_id) = self
936            .active_window()
937            .terminal_buffers
938            .iter()
939            .find_map(|(buffer, terminal)| (*terminal == terminal_id).then_some(*buffer))
940        else {
941            return;
942        };
943
944        // Prefer a split currently showing the terminal; otherwise the
945        // split holding it as a background tab. `focus_split` handles
946        // both (it delegates to the tab-switch path when the target is
947        // the active split).
948        let target_split = self.active_window().buffers.splits().and_then(|(mgr, vs)| {
949            mgr.splits_for_buffer(buffer_id)
950                .into_iter()
951                .next()
952                .or_else(|| {
953                    vs.iter()
954                        .find(|(_, view_state)| view_state.has_buffer(buffer_id))
955                        .map(|(split_id, _)| *split_id)
956                })
957        });
958        if let Some(split_id) = target_split {
959            self.focus_split(split_id, buffer_id);
960        } else {
961            self.switch_buffer(buffer_id);
962        }
963
964        // `focus_split` enables terminal mode for the cross-split case,
965        // but a tab switch resumes it only when the terminal was left in
966        // terminal mode. Enter it explicitly — this also re-enables
967        // editing and scrolls a previously-synced scrollback view back
968        // to the live prompt.
969        self.enter_terminal_mode();
970    }
971
972    /// Text that "send to terminal" operates on, mirroring
973    /// `copy_selection`'s precedence: block selection first, then
974    /// regular selections (joined by newline), else each cursor's
975    /// current line (without its line ending).
976    fn selection_or_cursor_line_text(&mut self) -> String {
977        if self
978            .active_cursors()
979            .iter()
980            .any(|(_, cursor)| cursor.has_block_selection())
981        {
982            return self.copy_block_selection_text();
983        }
984
985        let ranges: Vec<_> = self
986            .active_cursors()
987            .iter()
988            .filter_map(|(_, cursor)| cursor.selection_range())
989            .collect();
990        if !ranges.is_empty() {
991            let state = self.active_state_mut();
992            let mut text = String::new();
993            for range in ranges {
994                if !text.is_empty() {
995                    text.push('\n');
996                }
997                text.push_str(&state.get_text_range(range.start, range.end));
998            }
999            return text;
1000        }
1001
1002        let estimated_line_length = 80;
1003        let positions: Vec<_> = self
1004            .active_cursors()
1005            .iter()
1006            .map(|(_, cursor)| cursor.position)
1007            .collect();
1008        let state = self.active_state_mut();
1009        let mut text = String::new();
1010        for pos in positions {
1011            let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
1012            if let Some((_start, content)) = iter.next_line() {
1013                if !text.is_empty() {
1014                    text.push('\n');
1015                }
1016                text.push_str(content.trim_end_matches(['\n', '\r']));
1017            }
1018        }
1019        text
1020    }
1021
1022    // `is_terminal_buffer` and `get_terminal_id` moved to `impl Window`
1023    // (in `window.rs`). Editor callers reach them via
1024    // `self.active_window().is_terminal_buffer(...)` /
1025    // `.get_terminal_id(...)`.
1026
1027    // `get_active_terminal_state`, `send_terminal_input`,
1028    // `send_terminal_key`, `send_terminal_mouse`, and
1029    // `is_terminal_in_alternate_screen` live on `impl Window` — they
1030    // only touch this window's `terminal_buffers` + `terminal_manager`.
1031    // Call them via `self.active_window()` / `self.active_window_mut()`.
1032
1033    /// Handle terminal input when in terminal mode
1034    pub fn handle_terminal_key(
1035        &mut self,
1036        code: crossterm::event::KeyCode,
1037        modifiers: crossterm::event::KeyModifiers,
1038    ) -> bool {
1039        // Check for escape sequences to exit terminal mode
1040        // Ctrl+Space, Ctrl+], or Ctrl+` to exit (Ctrl+\ sends SIGQUIT on Unix)
1041        if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1042            match code {
1043                crossterm::event::KeyCode::Char(' ')
1044                | crossterm::event::KeyCode::Char(']')
1045                | crossterm::event::KeyCode::Char('`') => {
1046                    // Exit terminal mode and sync buffer
1047                    self.active_window_mut().terminal_mode = false;
1048                    self.active_window_mut().key_context =
1049                        crate::input::keybindings::KeyContext::Normal;
1050                    {
1051                        let __b = self.active_buffer();
1052                        self.active_window_mut().sync_terminal_to_buffer(__b);
1053                    };
1054                    self.set_status_message(
1055                        "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
1056                    );
1057                    return true;
1058                }
1059                _ => {}
1060            }
1061        }
1062
1063        // Send the key to the terminal
1064        self.active_window_mut().send_terminal_key(code, modifiers);
1065        true
1066    }
1067
1068    /// Re-enter terminal mode from read-only buffer view
1069    ///
1070    /// This truncates the backing file to remove the visible screen tail
1071    /// that was appended when we exited terminal mode, leaving only the
1072    /// incrementally-streamed scrollback history.
1073    pub fn enter_terminal_mode(&mut self) {
1074        if self
1075            .active_window()
1076            .is_terminal_buffer(self.active_buffer())
1077        {
1078            self.active_window_mut().terminal_mode = true;
1079            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
1080
1081            // Re-enable editing when in terminal mode (input goes to PTY)
1082            let __buffer_id = self.active_buffer();
1083            if let Some(state) = self
1084                .windows
1085                .get_mut(&self.active_window)
1086                .map(|w| &mut w.buffers)
1087                .expect("active window present")
1088                .get_mut(&__buffer_id)
1089            {
1090                state.editing_disabled = false;
1091                state.margins.configure_for_line_numbers(false);
1092            }
1093            let __active_split = self.split_manager().active_split();
1094            if let Some(view_state) = self.split_view_states_mut().get_mut(&__active_split) {
1095                view_state.viewport.line_wrap_enabled = false;
1096            }
1097
1098            // Truncate backing file to remove visible screen tail and scroll to bottom
1099            if let Some(&terminal_id) = self
1100                .active_window()
1101                .terminal_buffers
1102                .get(&self.active_buffer())
1103            {
1104                // Truncate backing file to remove visible screen that was appended
1105                if let Some(backing_path) = self
1106                    .active_window()
1107                    .terminal_backing_files
1108                    .get(&terminal_id)
1109                {
1110                    if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
1111                        if let Ok(state) = handle.state.lock() {
1112                            let truncate_pos = state.backing_file_history_end();
1113                            // Always truncate to remove appended visible screen
1114                            // (even if truncate_pos is 0, meaning no scrollback yet)
1115                            if let Err(e) = self
1116                                .authority()
1117                                .filesystem
1118                                .set_file_length(backing_path, truncate_pos)
1119                            {
1120                                tracing::warn!("Failed to truncate terminal backing file: {}", e);
1121                            }
1122                        }
1123                    }
1124                }
1125
1126                // Scroll terminal to bottom when re-entering
1127                if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
1128                    if let Ok(mut state) = handle.state.lock() {
1129                        state.scroll_to_bottom();
1130                    }
1131                }
1132            }
1133
1134            // Ensure terminal PTY is sized correctly for current split dimensions
1135            self.active_window_mut().resize_visible_terminals();
1136
1137            self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1138        }
1139    }
1140
1141    /// Get terminal content for rendering
1142    pub fn get_terminal_content(
1143        &self,
1144        buffer_id: BufferId,
1145    ) -> Option<Vec<Vec<crate::services::terminal::TerminalCell>>> {
1146        let terminal_id = self.active_window().terminal_buffers.get(&buffer_id)?;
1147        let handle = self.active_window().terminal_manager.get(*terminal_id)?;
1148        let state = handle.state.lock().ok()?;
1149
1150        let (_, rows) = state.size();
1151        let mut content = Vec::with_capacity(rows as usize);
1152
1153        for row in 0..rows {
1154            content.push(state.get_line(row));
1155        }
1156
1157        Some(content)
1158    }
1159}
1160
1161impl Window {
1162    /// Get the terminal state for the active buffer (if it's a terminal buffer).
1163    pub fn get_active_terminal_state(
1164        &self,
1165    ) -> Option<std::sync::MutexGuard<'_, crate::services::terminal::TerminalState>> {
1166        let terminal_id = self.terminal_buffers.get(&self.active_buffer())?;
1167        let handle = self.terminal_manager.get(*terminal_id)?;
1168        handle.state.lock().ok()
1169    }
1170
1171    /// Send input bytes to this window's active terminal (no-op if the
1172    /// active buffer is not a terminal).
1173    pub fn send_terminal_input(&mut self, data: &[u8]) {
1174        if let Some(&terminal_id) = self.terminal_buffers.get(&self.active_buffer()) {
1175            if let Some(handle) = self.terminal_manager.get(terminal_id) {
1176                handle.write(data);
1177            }
1178        }
1179    }
1180
1181    /// Send a key event to this window's active terminal. Picks
1182    /// "application cursor" vs "normal cursor" escape sequences
1183    /// based on the terminal's current state.
1184    pub fn send_terminal_key(
1185        &mut self,
1186        code: crossterm::event::KeyCode,
1187        modifiers: crossterm::event::KeyModifiers,
1188    ) {
1189        let app_cursor = self
1190            .get_active_terminal_state()
1191            .map(|s| s.is_app_cursor())
1192            .unwrap_or(false);
1193        if let Some(bytes) =
1194            crate::services::terminal::pty::key_to_pty_bytes(code, modifiers, app_cursor)
1195        {
1196            self.send_terminal_input(&bytes);
1197        }
1198    }
1199
1200    /// Send a mouse event to this window's active terminal.
1201    pub fn send_terminal_mouse(
1202        &mut self,
1203        col: u16,
1204        row: u16,
1205        kind: crate::input::handler::TerminalMouseEventKind,
1206        modifiers: crossterm::event::KeyModifiers,
1207    ) {
1208        use crate::input::handler::TerminalMouseEventKind;
1209
1210        // Check if terminal uses SGR mouse encoding.
1211        let use_sgr = self
1212            .get_active_terminal_state()
1213            .map(|s| s.uses_sgr_mouse())
1214            .unwrap_or(true);
1215
1216        // Alternate-scroll mode converts the wheel into arrow keys so the
1217        // wheel scrolls pagers like `less`/`man` that don't track the mouse.
1218        // It must be suppressed whenever the program is itself tracking the
1219        // mouse: such a program (e.g. Claude Code in its full-screen
1220        // "no-flicker" mode) requested mouse reporting precisely so it can
1221        // scroll its own viewport from wheel events. Forwarding synthesized
1222        // Up/Down arrows instead leaks them into the program's input — for
1223        // Claude Code that cycles prompt/message history rather than
1224        // scrolling. This mirrors xterm/alacritty, where alternate scroll is
1225        // inactive while any mouse-tracking mode is on.
1226        //
1227        // Note `ALTERNATE_SCROLL` is on by default in alacritty_terminal, so
1228        // this branch would otherwise fire for every wheel event forwarded to
1229        // an alternate-screen program — the `wants_mouse` guard is what keeps
1230        // mouse-aware programs receiving real wheel reports.
1231        let wants_mouse = self
1232            .get_active_terminal_state()
1233            .map(|s| s.wants_mouse_events())
1234            .unwrap_or(false);
1235        let uses_alt_scroll = !wants_mouse
1236            && self
1237                .get_active_terminal_state()
1238                .map(|s| s.uses_alternate_scroll())
1239                .unwrap_or(false);
1240
1241        if uses_alt_scroll {
1242            match kind {
1243                TerminalMouseEventKind::ScrollUp => {
1244                    for _ in 0..3 {
1245                        self.send_terminal_input(b"\x1b[A");
1246                    }
1247                    return;
1248                }
1249                TerminalMouseEventKind::ScrollDown => {
1250                    for _ in 0..3 {
1251                        self.send_terminal_input(b"\x1b[B");
1252                    }
1253                    return;
1254                }
1255                _ => {}
1256            }
1257        }
1258
1259        let bytes = if use_sgr {
1260            encode_sgr_mouse(col, row, kind, modifiers)
1261        } else {
1262            encode_x10_mouse(col, row, kind, modifiers)
1263        };
1264
1265        if let Some(bytes) = bytes {
1266            self.send_terminal_input(&bytes);
1267        }
1268    }
1269
1270    /// Check if the given terminal buffer in this window is in
1271    /// alternate-screen mode (vim/less/htop etc.).
1272    pub fn is_terminal_in_alternate_screen(&self, buffer_id: BufferId) -> bool {
1273        if let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) {
1274            if let Some(handle) = self.terminal_manager.get(terminal_id) {
1275                if let Ok(state) = handle.state.lock() {
1276                    return state.is_alternate_screen();
1277                }
1278            }
1279        }
1280        false
1281    }
1282
1283    /// Resize a single terminal buffer's PTY (only if `buffer_id`
1284    /// belongs to this window's terminal_buffers map).
1285    pub fn resize_terminal(&mut self, buffer_id: BufferId, cols: u16, rows: u16) {
1286        if let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) {
1287            if let Some(handle) = self.terminal_manager.get_mut(terminal_id) {
1288                handle.resize(cols, rows);
1289            }
1290        }
1291    }
1292
1293    /// The rect the editor splits lay out into, mirroring the renderer
1294    /// (`render.rs::compute_dock_split` + the file-explorer split): the
1295    /// editor-global dock claims the leftmost `dock_cols`, then the file
1296    /// explorer claims a slice of the remaining chrome, and the splits get
1297    /// what's left. `dock_cols` is pushed down by `Editor::relayout`.
1298    /// Computing the file-explorer width against the post-dock chrome
1299    /// width (not the full screen) matches the renderer exactly, so split
1300    /// geometry derived from this lines up with the cells actually drawn.
1301    pub(crate) fn editor_content_area(&self) -> ratatui::layout::Rect {
1302        let chrome_width = self.terminal_width.saturating_sub(self.dock_cols);
1303        let file_explorer_width = if self.file_explorer_visible {
1304            self.file_explorer_width.to_cols(chrome_width)
1305        } else {
1306            0
1307        };
1308        let editor_x = match self.file_explorer_side {
1309            crate::config::FileExplorerSide::Left => {
1310                self.dock_cols.saturating_add(file_explorer_width)
1311            }
1312            crate::config::FileExplorerSide::Right => self.dock_cols,
1313        };
1314        let editor_width = chrome_width.saturating_sub(file_explorer_width);
1315        ratatui::layout::Rect::new(
1316            editor_x,
1317            1, // menu bar
1318            editor_width,
1319            self.terminal_height.saturating_sub(2), // menu bar + status bar
1320        )
1321    }
1322
1323    /// Resize all this window's visible terminal PTYs to match their
1324    /// current split dimensions. Reads the window's cached
1325    /// `terminal_width` / `terminal_height` for the screen size.
1326    pub fn resize_visible_terminals(&mut self) {
1327        let editor_area = self.editor_content_area();
1328
1329        let Some((mgr, _)) = self.buffers.splits() else {
1330            return;
1331        };
1332        let visible_buffers = mgr.get_visible_buffers(editor_area);
1333
1334        for (_split_id, buffer_id, split_area) in visible_buffers {
1335            if self.terminal_buffers.contains_key(&buffer_id) {
1336                // Tab bar takes 1 row, scrollbar takes 1 column on the right.
1337                let content_height = split_area.height.saturating_sub(2);
1338                let content_width = split_area.width.saturating_sub(2);
1339
1340                if content_width > 0 && content_height > 0 {
1341                    self.resize_terminal(buffer_id, content_width, content_height);
1342                }
1343            }
1344        }
1345    }
1346
1347    /// Sync terminal content to the active terminal buffer's text view
1348    /// for read-only viewing / selection.
1349    ///
1350    /// Incremental streaming architecture:
1351    /// 1. Scrollback has already been streamed to the backing file during PTY reads.
1352    /// 2. We append the visible screen (~50 lines) to the backing file.
1353    /// 3. Reload the buffer from the backing file (lazy load for large files).
1354    ///
1355    /// Performance: O(screen_size) instead of O(total_history).
1356    pub fn sync_terminal_to_buffer(&mut self, buffer_id: BufferId) {
1357        let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) else {
1358            return;
1359        };
1360        // Get the backing file path
1361        let backing_file = match self.terminal_backing_files.get(&terminal_id) {
1362            Some(path) => path.clone(),
1363            None => return,
1364        };
1365
1366        // Append visible screen to backing file
1367        // The scrollback has already been incrementally streamed by the PTY read loop.
1368        // Capture the file size *just before* the append so the viewport
1369        // can anchor to it below — that byte offset is the first byte of
1370        // the visible screen we're about to append, which is exactly
1371        // where the live PTY grid drew its row 0.
1372        let mut history_end_byte: Option<u64> = None;
1373        if let Some(handle) = self.terminal_manager.get(terminal_id) {
1374            if let Ok(mut state) = handle.state.lock() {
1375                use std::io::BufWriter;
1376
1377                // Flush any scrollback that has scrolled off but isn't in the
1378                // file yet — in particular the lines a resize spilled from the
1379                // screen into history. The PTY read loop also flushes on output,
1380                // but an idle terminal that was only resized has pending lines;
1381                // capturing them here guarantees the scroll-back view is complete.
1382                if let Ok(mut file) = self
1383                    .authority()
1384                    .filesystem
1385                    .open_file_for_append(&backing_file)
1386                {
1387                    let mut writer = BufWriter::new(&mut *file);
1388                    if let Err(e) = state.flush_new_scrollback(&mut writer) {
1389                        tracing::error!("Failed to flush terminal scrollback: {}", e);
1390                    }
1391                }
1392
1393                // Record the current file size as the history end point
1394                // (before appending visible screen) so we can truncate back to it
1395                if let Ok(metadata) = self.authority().filesystem.metadata(&backing_file) {
1396                    state.set_backing_file_history_end(metadata.size);
1397                    history_end_byte = Some(metadata.size);
1398                }
1399
1400                // Open backing file in append mode to add visible screen
1401                if let Ok(mut file) = self
1402                    .authority()
1403                    .filesystem
1404                    .open_file_for_append(&backing_file)
1405                {
1406                    let mut writer = BufWriter::new(&mut *file);
1407                    if let Err(e) = state.append_visible_screen(&mut writer) {
1408                        tracing::error!("Failed to append visible screen to backing file: {}", e);
1409                    }
1410                }
1411            }
1412        }
1413
1414        // Reload buffer from the backing file (reusing existing file loading)
1415        let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
1416        if let Ok(new_state) = EditorState::from_file_with_languages(
1417            &backing_file,
1418            self.terminal_width,
1419            self.terminal_height,
1420            large_file_threshold,
1421            &self.resources.grammar_registry,
1422            &self.resources.config.languages,
1423            std::sync::Arc::clone(&self.authority().filesystem),
1424        ) {
1425            let total_bytes = new_state.buffer.total_bytes();
1426            if let Some(state) = self.buffers.get_mut(&buffer_id) {
1427                *state = new_state;
1428                // Terminal buffers should never be considered "modified"
1429                state.buffer.set_modified(false);
1430            }
1431            // Anchor the viewport at the first byte of the appended
1432            // visible screen and place the cursor there too. The scroll-
1433            // back view now opens with the just-appended PTY rows at the
1434            // top — exactly where the live grid drew them — so exit is
1435            // pixel-identical to the last terminal-mode tick even when
1436            // most of the screen is blank (post-`clear` / `reset`). The
1437            // old `cursor = total_bytes` + `ensure_cursor_visible` path
1438            // anchored the bottom row instead, which pulled older
1439            // scrollback into rows the PTY had drawn blank.
1440            let anchor_byte = history_end_byte
1441                .map(|h| (h as usize).min(total_bytes))
1442                .unwrap_or(total_bytes);
1443            if let Some((mgr, view_states)) = self.buffers.splits_mut() {
1444                let active_split = mgr.active_split();
1445                if let Some(view_state) = view_states.get_mut(&active_split) {
1446                    view_state.cursors.primary_mut().position = anchor_byte;
1447                    view_state.viewport.top_byte = anchor_byte;
1448                    view_state.viewport.top_view_line_offset = 0;
1449                    view_state.viewport.left_column = 0;
1450                }
1451            }
1452        }
1453
1454        // Mark buffer as editing-disabled while in non-terminal mode
1455        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1456            state.editing_disabled = true;
1457            state.margins.configure_for_line_numbers(false);
1458        }
1459
1460        // Refresh line-wrap state for the scroll-back view and arm the
1461        // skip_ensure_visible flag so the next render does *not* run
1462        // `Viewport::ensure_visible` against the cursor we just pinned.
1463        // Without this the renderer would notice that the cursor sits
1464        // on the viewport's top row, treat that as "above the scroll
1465        // margin", and scroll `top_byte` up by `scroll_offset` lines —
1466        // pulling pre-existing scrollback above the appended visible
1467        // screen and undoing the anchor. The flag is consumed
1468        // (cleared) by the first navigation / scroll action, so normal
1469        // scrolling still works after that.
1470        //
1471        // Also force the per-buffer gutter / current-line-highlight off
1472        // here as the exit-path's last line of defense. Spawn /
1473        // workspace-restore code paths each have their own setup, and a
1474        // single missed spot leaks a gutter pop-in on exit — pinning
1475        // them on this path covers any terminal regardless of how its
1476        // view state was created.
1477        if let Some((mgr, view_states)) = self.buffers.splits_mut() {
1478            let active_split = mgr.active_split();
1479            // The active split's view state may not yet have a keyed
1480            // entry for the terminal buffer (e.g. user just pressed
1481            // Alt+] into a split that has the terminal as a tab but
1482            // never displayed it before). ensure_buffer_state will
1483            // create one with defaults (show_line_numbers=true) the
1484            // very first time — so we have to *immediately* override
1485            // those defaults here, otherwise the next render flashes
1486            // a gutter for restored terminals.
1487            //
1488            // Also force the gutter / current-line-highlight off on
1489            // every other split that has this terminal as a tab. A
1490            // single missed BufferViewState (e.g. created lazily by
1491            // workspace restore + Alt+]) leaks a gutter pop-in.
1492            for vs in view_states.values_mut() {
1493                if vs.has_buffer(buffer_id) {
1494                    let buf_state = vs.ensure_buffer_state(buffer_id);
1495                    buf_state.show_line_numbers = false;
1496                    buf_state.highlight_current_line = false;
1497                    // Scrollback is stored as unwrapped logical lines, so soft-wrap
1498                    // the read-only view to reflow long lines to the current width.
1499                    // (Visible-screen rows are ≤ the view width and so never wrap,
1500                    // keeping the exit frame aligned with the live grid.)
1501                    buf_state.viewport.line_wrap_enabled = true;
1502                }
1503            }
1504            if let Some(view_state) = view_states.get_mut(&active_split) {
1505                view_state.viewport.line_wrap_enabled = true;
1506                view_state.viewport.set_skip_ensure_visible();
1507                let buf_state = view_state.ensure_buffer_state(buffer_id);
1508                buf_state.show_line_numbers = false;
1509                buf_state.highlight_current_line = false;
1510            }
1511        }
1512    }
1513
1514    /// Render terminal content for terminal buffers in this window's
1515    /// split areas. Overlays the live PTY grid (colors, attributes,
1516    /// optional cursor) on top of the buffer's regular text content
1517    /// inside `content_rect`.
1518    ///
1519    /// `cursor_visible_if_active` controls whether the cursor is
1520    /// painted at all. The active-window render passes `true` so a
1521    /// focused terminal in `terminal_mode` blinks normally; the
1522    /// preview path passes `false` so the picker preview stays
1523    /// read-only.
1524    ///
1525    /// Window-local in every respect — reads `terminal_buffers`,
1526    /// `terminal_manager`, `terminal_mode`, `active_buffer()`, and
1527    /// `resources.theme` from `self`. The caller picks the window
1528    /// (active vs previewed); this method never reaches back to an
1529    /// `Editor` or to any other window.
1530    pub fn render_terminal_splits(
1531        &self,
1532        frame: &mut ratatui::Frame,
1533        split_areas: &[(
1534            crate::model::event::LeafId,
1535            BufferId,
1536            ratatui::layout::Rect,
1537            ratatui::layout::Rect,
1538            usize,
1539            usize,
1540        )],
1541        cursor_visible_if_active: bool,
1542    ) {
1543        for (_split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1544            split_areas
1545        {
1546            let Some(&terminal_id) = self.terminal_buffers.get(buffer_id) else {
1547                continue;
1548            };
1549            // When the user's current tab is a terminal but they're
1550            // *not* in terminal mode, the buffer is showing the
1551            // synced scrollback view — defer to the normal text
1552            // rendering so the user can scroll. The live grid only
1553            // overlays when terminal mode is active, or when the
1554            // tab isn't the active one (so a split's hidden tab
1555            // still gets live updates).
1556            let is_active = *buffer_id == self.active_buffer();
1557            if is_active && !self.terminal_mode {
1558                continue;
1559            }
1560            let Some(handle) = self.terminal_manager.get(terminal_id) else {
1561                continue;
1562            };
1563            let Ok(state) = handle.state.lock() else {
1564                continue;
1565            };
1566            let cursor_pos = state.cursor_position();
1567            let cursor_visible = state.cursor_visible()
1568                && is_active
1569                && self.terminal_mode
1570                && cursor_visible_if_active;
1571            let (_, rows) = state.size();
1572            let mut content = Vec::with_capacity(rows as usize);
1573            for row in 0..rows {
1574                content.push(state.get_line(row));
1575            }
1576            // Ctrl+hover underline: highlight the link span when it's in this
1577            // terminal buffer.
1578            let link_highlight = self
1579                .terminal_link_hover
1580                .as_ref()
1581                .and_then(|h| (h.buffer_id == *buffer_id).then(|| (h.row, h.cols.clone())));
1582            frame.render_widget(ratatui::widgets::Clear, *content_rect);
1583            let theme = self.resources.theme.read().unwrap();
1584            render::render_terminal_content(
1585                &content,
1586                cursor_pos,
1587                cursor_visible,
1588                *content_rect,
1589                frame.buffer_mut(),
1590                theme.terminal_fg,
1591                theme.terminal_bg,
1592                link_highlight,
1593            );
1594        }
1595    }
1596}
1597
1598impl Editor {
1599    /// Check if terminal mode is active (for testing)
1600    pub fn is_terminal_mode(&self) -> bool {
1601        self.active_window().terminal_mode
1602    }
1603
1604    /// Check if a buffer is in terminal_mode_resume set (for testing/debugging)
1605    pub fn is_in_terminal_mode_resume(&self, buffer_id: BufferId) -> bool {
1606        self.active_window()
1607            .terminal_mode_resume
1608            .contains(&buffer_id)
1609    }
1610
1611    /// Check if keyboard capture is enabled in terminal mode (for testing)
1612    pub fn is_keyboard_capture(&self) -> bool {
1613        self.active_window().keyboard_capture
1614    }
1615
1616    /// Set terminal jump_to_end_on_output config option (for testing)
1617    pub fn set_terminal_jump_to_end_on_output(&mut self, value: bool) {
1618        self.config_mut().terminal.jump_to_end_on_output = value;
1619    }
1620
1621    /// Get read-only access to the active window's terminal manager
1622    /// (for testing). After Step 0d, terminal state lives on each
1623    /// window — this routes to the active one.
1624    pub fn terminal_manager(&self) -> &crate::services::terminal::TerminalManager {
1625        &self
1626            .windows
1627            .get(&self.active_window)
1628            .expect("active window must exist")
1629            .terminal_manager
1630    }
1631
1632    /// Get read-only access to the active window's terminal backing
1633    /// files map (for testing).
1634    pub fn terminal_backing_files(
1635        &self,
1636    ) -> &std::collections::HashMap<crate::services::terminal::TerminalId, std::path::PathBuf> {
1637        &self
1638            .windows
1639            .get(&self.active_window)
1640            .expect("active window must exist")
1641            .terminal_backing_files
1642    }
1643
1644    /// Get the currently active buffer ID
1645    pub fn active_buffer_id(&self) -> BufferId {
1646        self.active_buffer()
1647    }
1648
1649    /// Get buffer content as a string (for testing)
1650    pub fn get_buffer_content(&self, buffer_id: BufferId) -> Option<String> {
1651        self.windows
1652            .get(&self.active_window)
1653            .map(|w| &w.buffers)
1654            .expect("active window present")
1655            .get(&buffer_id)
1656            .and_then(|state| state.buffer.to_string())
1657    }
1658
1659    /// Get cursor position for a buffer (for testing)
1660    pub fn get_cursor_position(&self, buffer_id: BufferId) -> Option<usize> {
1661        // Find cursor from any split view state that has this buffer
1662        self.windows
1663            .get(&self.active_window)
1664            .and_then(|w| w.buffers.splits())
1665            .map(|(_, vs)| vs)
1666            .expect("active window must have a populated split layout")
1667            .values()
1668            .find_map(|vs| {
1669                if vs.keyed_states.contains_key(&buffer_id) {
1670                    Some(vs.keyed_states.get(&buffer_id)?.cursors.primary().position)
1671                } else {
1672                    None
1673                }
1674            })
1675            .or_else(|| {
1676                // Fallback: check active cursors
1677                self.windows
1678                    .get(&self.active_window)
1679                    .and_then(|w| w.buffers.splits())
1680                    .map(|(_, vs)| vs)
1681                    .expect("active window must have a populated split layout")
1682                    .values()
1683                    .map(|vs| vs.cursors.primary().position)
1684                    .next()
1685            })
1686    }
1687
1688    // `render_terminal_splits` moved to `impl Window`. Active-window
1689    // callers reach it via `self.active_window().render_terminal_splits(...)`;
1690    // the picker preview path reaches it via the previewed window
1691    // directly, so the live PTY grid renders into the preview embed
1692    // without going through the active-window state.
1693}
1694
1695/// Terminal rendering utilities
1696pub mod render {
1697    use crate::services::terminal::TerminalCell;
1698    use ratatui::buffer::Buffer;
1699    use ratatui::layout::Rect;
1700    use ratatui::style::{Color, Modifier, Style};
1701
1702    /// Render terminal content to a ratatui buffer
1703    #[allow(clippy::too_many_arguments)]
1704    pub fn render_terminal_content(
1705        content: &[Vec<TerminalCell>],
1706        cursor_pos: (u16, u16),
1707        cursor_visible: bool,
1708        area: Rect,
1709        buf: &mut Buffer,
1710        default_fg: Color,
1711        default_bg: Color,
1712        link_highlight: Option<(u16, std::ops::Range<usize>)>,
1713    ) {
1714        // Fill the rendered area with the theme's terminal bg first so any
1715        // cells past the PTY grid (e.g. transiently smaller than the rect
1716        // mid-resize) show the theme background rather than leaking the
1717        // host terminal's default bg. Issue #1890.
1718        buf.set_style(area, Style::default().fg(default_fg).bg(default_bg));
1719
1720        for (row_idx, row) in content.iter().enumerate() {
1721            if row_idx as u16 >= area.height {
1722                break;
1723            }
1724
1725            let y = area.y + row_idx as u16;
1726
1727            for (col_idx, cell) in row.iter().enumerate() {
1728                if col_idx as u16 >= area.width {
1729                    break;
1730                }
1731
1732                let x = area.x + col_idx as u16;
1733
1734                // Build style from cell attributes, using theme defaults
1735                let mut style = Style::default().fg(default_fg).bg(default_bg);
1736
1737                // Override with cell-specific colors if present
1738                if let Some((r, g, b)) = cell.fg {
1739                    style = style.fg(Color::Rgb(r, g, b));
1740                }
1741
1742                if let Some((r, g, b)) = cell.bg {
1743                    style = style.bg(Color::Rgb(r, g, b));
1744                }
1745
1746                // Apply modifiers
1747                if cell.bold {
1748                    style = style.add_modifier(Modifier::BOLD);
1749                }
1750                if cell.italic {
1751                    style = style.add_modifier(Modifier::ITALIC);
1752                }
1753                if cell.underline {
1754                    style = style.add_modifier(Modifier::UNDERLINED);
1755                }
1756                if cell.inverse {
1757                    style = style.add_modifier(Modifier::REVERSED);
1758                }
1759
1760                // Ctrl+hover link highlight: underline the link span so it
1761                // reads as clickable.
1762                if let Some((link_row, ref cols)) = link_highlight {
1763                    if row_idx as u16 == link_row && cols.contains(&col_idx) {
1764                        style = style.add_modifier(Modifier::UNDERLINED);
1765                    }
1766                }
1767
1768                // Check if this is the cursor position
1769                if cursor_visible
1770                    && row_idx as u16 == cursor_pos.1
1771                    && col_idx as u16 == cursor_pos.0
1772                {
1773                    style = style.add_modifier(Modifier::REVERSED);
1774                }
1775
1776                buf.set_string(x, y, cell.c.to_string(), style);
1777            }
1778        }
1779    }
1780
1781    #[cfg(test)]
1782    mod tests {
1783        use super::*;
1784        use crate::services::terminal::TerminalCell;
1785
1786        #[test]
1787        fn cells_past_pty_grid_get_theme_bg() {
1788            // PTY grid is 2x2, render area is 4x3 — the cells outside
1789            // the grid must still carry the theme's terminal_bg so the
1790            // nostalgia theme's blue fully covers the terminal pane
1791            // (issue #1890).
1792            let area = Rect::new(0, 0, 4, 3);
1793            let mut buf = Buffer::empty(area);
1794            let row = vec![TerminalCell::default(), TerminalCell::default()];
1795            let content = vec![row.clone(), row];
1796
1797            let default_bg = Color::Rgb(0, 0, 170);
1798            let default_fg = Color::Rgb(255, 255, 85);
1799
1800            render_terminal_content(
1801                &content,
1802                (0, 0),
1803                false,
1804                area,
1805                &mut buf,
1806                default_fg,
1807                default_bg,
1808                None,
1809            );
1810
1811            for y in area.top()..area.bottom() {
1812                for x in area.left()..area.right() {
1813                    assert_eq!(
1814                        buf[(x, y)].bg,
1815                        default_bg,
1816                        "cell ({x}, {y}) bg should be the theme terminal_bg",
1817                    );
1818                }
1819            }
1820        }
1821
1822        /// The Ctrl+hover link highlight underlines exactly the cells in the
1823        /// given (row, col-range) span and leaves the rest untouched.
1824        #[test]
1825        fn link_highlight_underlines_only_its_span() {
1826            // One 6-wide row of text "abcdef".
1827            let area = Rect::new(0, 0, 6, 1);
1828            let mut buf = Buffer::empty(area);
1829            let row: Vec<TerminalCell> = "abcdef"
1830                .chars()
1831                .map(|c| TerminalCell {
1832                    c,
1833                    ..Default::default()
1834                })
1835                .collect();
1836            let content = vec![row];
1837
1838            render_terminal_content(
1839                &content,
1840                (0, 0),
1841                false,
1842                area,
1843                &mut buf,
1844                Color::White,
1845                Color::Black,
1846                Some((0, 2..5)), // underline columns 2,3,4
1847            );
1848
1849            for x in 0..area.width {
1850                let underlined = buf[(x, 0)].modifier.contains(Modifier::UNDERLINED);
1851                let expected = (2..5).contains(&(x as usize));
1852                assert_eq!(
1853                    underlined, expected,
1854                    "cell col {x} underline = {underlined}, expected {expected}",
1855                );
1856            }
1857        }
1858    }
1859}
1860
1861/// Encode a mouse event in SGR format (modern protocol).
1862/// Format: CSI < Cb ; Cx ; Cy M (press) or CSI < Cb ; Cx ; Cy m (release)
1863fn encode_sgr_mouse(
1864    col: u16,
1865    row: u16,
1866    kind: crate::input::handler::TerminalMouseEventKind,
1867    modifiers: crossterm::event::KeyModifiers,
1868) -> Option<Vec<u8>> {
1869    use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
1870
1871    // SGR uses 1-based coordinates
1872    let cx = col + 1;
1873    let cy = row + 1;
1874
1875    // Build button code
1876    let (button_code, is_release) = match kind {
1877        TerminalMouseEventKind::Down(btn) => {
1878            let code = match btn {
1879                TerminalMouseButton::Left => 0,
1880                TerminalMouseButton::Middle => 1,
1881                TerminalMouseButton::Right => 2,
1882            };
1883            (code, false)
1884        }
1885        TerminalMouseEventKind::Up(btn) => {
1886            let code = match btn {
1887                TerminalMouseButton::Left => 0,
1888                TerminalMouseButton::Middle => 1,
1889                TerminalMouseButton::Right => 2,
1890            };
1891            (code, true)
1892        }
1893        TerminalMouseEventKind::Drag(btn) => {
1894            let code = match btn {
1895                TerminalMouseButton::Left => 32,   // 0 + 32 (motion flag)
1896                TerminalMouseButton::Middle => 33, // 1 + 32
1897                TerminalMouseButton::Right => 34,  // 2 + 32
1898            };
1899            (code, false)
1900        }
1901        TerminalMouseEventKind::Moved => (35, false), // 3 + 32 (no button + motion)
1902        TerminalMouseEventKind::ScrollUp => (64, false),
1903        TerminalMouseEventKind::ScrollDown => (65, false),
1904    };
1905
1906    // Add modifier flags
1907    let mut cb = button_code;
1908    if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
1909        cb += 4;
1910    }
1911    if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
1912        cb += 8;
1913    }
1914    if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1915        cb += 16;
1916    }
1917
1918    // Build escape sequence
1919    let terminator = if is_release { 'm' } else { 'M' };
1920    Some(format!("\x1b[<{};{};{}{}", cb, cx, cy, terminator).into_bytes())
1921}
1922
1923/// Encode a mouse event in X10/normal format (legacy protocol).
1924/// Format: CSI M Cb Cx Cy (with 32 added to all values for ASCII safety)
1925fn encode_x10_mouse(
1926    col: u16,
1927    row: u16,
1928    kind: crate::input::handler::TerminalMouseEventKind,
1929    modifiers: crossterm::event::KeyModifiers,
1930) -> Option<Vec<u8>> {
1931    use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
1932
1933    // X10 uses 1-based coordinates with 32 offset for ASCII safety
1934    // Maximum coordinate is 223 (255 - 32)
1935    let cx = (col.min(222) + 1 + 32) as u8;
1936    let cy = (row.min(222) + 1 + 32) as u8;
1937
1938    // Build button code
1939    let button_code: u8 = match kind {
1940        TerminalMouseEventKind::Down(btn) | TerminalMouseEventKind::Drag(btn) => match btn {
1941            TerminalMouseButton::Left => 0,
1942            TerminalMouseButton::Middle => 1,
1943            TerminalMouseButton::Right => 2,
1944        },
1945        TerminalMouseEventKind::Up(_) => 3, // Release is button 3 in X10
1946        TerminalMouseEventKind::Moved => 3 + 32,
1947        TerminalMouseEventKind::ScrollUp => 64,
1948        TerminalMouseEventKind::ScrollDown => 65,
1949    };
1950
1951    // Add modifier flags and motion flag for drag
1952    let mut cb = button_code;
1953    if matches!(kind, TerminalMouseEventKind::Drag(_)) {
1954        cb += 32; // Motion flag
1955    }
1956    if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
1957        cb += 4;
1958    }
1959    if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
1960        cb += 8;
1961    }
1962    if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1963        cb += 16;
1964    }
1965
1966    // Add 32 offset for ASCII safety
1967    let cb = cb + 32;
1968
1969    Some(vec![0x1b, b'[', b'M', cb, cx, cy])
1970}
1971
1972#[cfg(test)]
1973mod title_tests {
1974    use super::combine_terminal_title;
1975
1976    #[test]
1977    fn combines_command_and_osc_title() {
1978        assert_eq!(
1979            combine_terminal_title(Some("python3"), Some("root@host: ~/proj")).as_deref(),
1980            Some("python3 \u{2014} root@host: ~/proj")
1981        );
1982    }
1983
1984    #[test]
1985    fn uses_single_source_when_only_one_present() {
1986        assert_eq!(
1987            combine_terminal_title(Some("bash"), None).as_deref(),
1988            Some("bash")
1989        );
1990        assert_eq!(
1991            combine_terminal_title(None, Some("root@host: ~/proj")).as_deref(),
1992            Some("root@host: ~/proj")
1993        );
1994    }
1995
1996    #[test]
1997    fn does_not_duplicate_command_already_in_osc_title() {
1998        // vim sets its own OSC title; don't prepend "vim — … VIM".
1999        assert_eq!(
2000            combine_terminal_title(Some("vim"), Some("README.md (~/proj) - VIM")).as_deref(),
2001            Some("README.md (~/proj) - VIM")
2002        );
2003    }
2004
2005    #[test]
2006    fn none_when_neither_present() {
2007        assert_eq!(combine_terminal_title(None, None), None);
2008    }
2009}