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