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