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