Skip to main content

fresh/app/
terminal.rs

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