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::services::authority::TerminalWrapper;
29use crate::services::terminal::TerminalId;
30use crate::state::EditorState;
31use rust_i18n::t;
32
33impl Editor {
34    /// Resolve the terminal wrapper used to spawn a new integrated
35    /// terminal, applying the `terminal.shell` config override on top of
36    /// the authority's wrapper when appropriate.
37    ///
38    /// See `TerminalWrapper::with_user_shell_override` for the override
39    /// rules; this is just the Editor-side wiring that supplies the
40    /// active config.
41    pub(crate) fn resolved_terminal_wrapper(&self) -> TerminalWrapper {
42        self.authority
43            .terminal_wrapper
44            .clone()
45            .with_user_shell_override(self.config.terminal.shell.as_ref())
46    }
47
48    /// Spawn a new PTY-backed terminal session and record its
49    /// log/backing files. Returns the terminal id on success — does
50    /// **not** create a buffer or attach to any split. Callers are
51    /// responsible for the rest of the wiring (creating the terminal
52    /// buffer via `create_terminal_buffer_attached` /
53    /// `create_terminal_buffer_detached`, switching active buffer,
54    /// flipping terminal mode, etc.).
55    ///
56    /// Used by `open_terminal` (regular spawn into the active split)
57    /// and by `Action::OpenTerminalInDock` (which needs the buffer
58    /// id *before* it has a split to attach to, so the dock leaf can
59    /// be seeded with the terminal directly rather than with a
60    /// placeholder buffer that would linger as a phantom tab).
61    pub(crate) fn spawn_terminal_session(&mut self) -> Option<TerminalId> {
62        // Get the current split dimensions for the terminal size.
63        // For dock-creation callers the dock doesn't exist yet, so
64        // these dimensions are an initial guess — `resize_visible_terminals`
65        // (called after attach) will correct it once the dock split
66        // has actual rect dimensions.
67        let (cols, rows) = self.get_terminal_dimensions();
68
69        // Set up async bridge for terminal manager if not already done.
70        // Use the window's per-window bridge (terminals are per-window),
71        // not the editor-global bridge — terminal output then arrives
72        // on the window's channel and is drained per-window.
73        let __window_bridge = self.active_window().bridge.clone();
74        self.active_window_mut()
75            .terminal_manager
76            .set_async_bridge(__window_bridge);
77
78        // Prepare persistent storage paths under the user's data directory
79        let terminal_root = self.dir_context.terminal_dir_for(&self.working_dir);
80        if let Err(e) = self.authority.filesystem.create_dir_all(&terminal_root) {
81            tracing::warn!("Failed to create terminal directory: {}", e);
82        }
83        // Precompute paths using the next terminal ID so we capture from the first byte
84        let predicted_terminal_id = self.active_window().terminal_manager.next_terminal_id();
85        let log_path =
86            terminal_root.join(format!("fresh-terminal-{}.log", predicted_terminal_id.0));
87        let backing_path =
88            terminal_root.join(format!("fresh-terminal-{}.txt", predicted_terminal_id.0));
89        // Stash backing path now so buffer creation can reuse it
90        self.active_window_mut()
91            .terminal_backing_files
92            .insert(predicted_terminal_id, backing_path);
93
94        // Spawn terminal with incremental scrollback streaming.
95        // Pre-extract everything self-borrowing before grabbing the
96        // mutable terminal_manager so the args don't conflict.
97        let backing_path_for_spawn = self
98            .windows
99            .get(&self.active_window)
100            .map(|w| &w.terminal_backing_files)
101            .expect("active window present")
102            .get(&predicted_terminal_id)
103            .cloned();
104        let working_dir_for_spawn = self.working_dir.clone();
105        let wrapper_for_spawn = self.resolved_terminal_wrapper();
106        match self
107            .windows
108            .get_mut(&self.active_window)
109            .map(|w| &mut w.terminal_manager)
110            .expect("active window present")
111            .spawn(
112                cols,
113                rows,
114                Some(working_dir_for_spawn),
115                Some(log_path.clone()),
116                backing_path_for_spawn,
117                wrapper_for_spawn,
118            ) {
119            Ok(terminal_id) => {
120                // Track log file path (use actual ID in case it differs)
121                self.active_window_mut()
122                    .terminal_log_files
123                    .insert(terminal_id, log_path.clone());
124                // If predicted differs, move backing path entry
125                if terminal_id != predicted_terminal_id {
126                    self.active_window_mut()
127                        .terminal_backing_files
128                        .remove(&predicted_terminal_id);
129                    let backing_path =
130                        terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0));
131                    self.active_window_mut()
132                        .terminal_backing_files
133                        .insert(terminal_id, backing_path);
134                }
135                Some(terminal_id)
136            }
137            Err(e) => {
138                self.set_status_message(
139                    t!("terminal.failed_to_open", error = e.to_string()).to_string(),
140                );
141                tracing::error!("Failed to open terminal: {}", e);
142                None
143            }
144        }
145    }
146
147    /// Open a new terminal in the current split
148    pub fn open_terminal(&mut self) {
149        let Some(terminal_id) = self.spawn_terminal_session() else {
150            return;
151        };
152
153        // Create a buffer for this terminal, attached to the active split
154        let buffer_id = self.create_terminal_buffer_attached(
155            terminal_id,
156            self.windows
157                .get(&self.active_window)
158                .and_then(|w| w.buffers.splits())
159                .map(|(mgr, _)| mgr)
160                .expect("active window must have a populated split layout")
161                .active_split(),
162        );
163
164        // Switch to the terminal buffer
165        self.set_active_buffer(buffer_id);
166
167        // Enable terminal mode
168        self.active_window_mut().terminal_mode = true;
169        self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
170
171        // Resize terminal to match actual split content area
172        self.active_window_mut().resize_visible_terminals();
173
174        // Get the terminal escape keybinding dynamically
175        let exit_key = self
176            .keybindings
177            .read()
178            .unwrap()
179            .find_keybinding_for_action(
180                "terminal_escape",
181                crate::input::keybindings::KeyContext::Terminal,
182            )
183            .unwrap_or_else(|| "Ctrl+Space".to_string());
184        self.set_status_message(
185            t!("terminal.opened", id = terminal_id.0, exit_key = exit_key).to_string(),
186        );
187        tracing::info!(
188            "Opened terminal {:?} with buffer {:?}",
189            terminal_id,
190            buffer_id
191        );
192    }
193
194    /// Create a buffer for a terminal session
195    pub(crate) fn create_terminal_buffer_attached(
196        &mut self,
197        terminal_id: TerminalId,
198        split_id: crate::model::event::LeafId,
199    ) -> BufferId {
200        let buffer_id = self.alloc_buffer_id();
201
202        // Get config values
203        let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
204
205        // Rendered backing file for scrollback view (reuse if already recorded)
206        let backing_file = self
207            .active_window()
208            .terminal_backing_files
209            .get(&terminal_id)
210            .cloned()
211            .unwrap_or_else(|| {
212                let root = self.dir_context.terminal_dir_for(&self.working_dir);
213                if let Err(e) = self.authority.filesystem.create_dir_all(&root) {
214                    tracing::warn!("Failed to create terminal directory: {}", e);
215                }
216                root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
217            });
218
219        // Ensure the file exists - but DON'T truncate if it already has content
220        // The PTY read loop may have already started writing scrollback
221        if !self.authority.filesystem.exists(&backing_file) {
222            if let Err(e) = self.authority.filesystem.write_file(&backing_file, &[]) {
223                tracing::warn!("Failed to create terminal backing file: {}", e);
224            }
225        }
226
227        // Store the backing file path
228        self.active_window_mut()
229            .terminal_backing_files
230            .insert(terminal_id, backing_file.clone());
231
232        // Create editor state with the backing file
233        let mut state = EditorState::new_with_path(
234            large_file_threshold,
235            std::sync::Arc::clone(&self.authority.filesystem),
236            backing_file.clone(),
237        );
238        // Terminal buffers should never show line numbers
239        state.margins.configure_for_line_numbers(false);
240        self.windows
241            .get_mut(&self.active_window)
242            .map(|w| &mut w.buffers)
243            .expect("active window present")
244            .insert(buffer_id, state);
245        // Use virtual metadata so the tab shows "*Terminal N*" and LSP stays off.
246        // The backing file is still tracked separately for syncing scrollback.
247        let metadata = BufferMetadata::virtual_buffer(
248            format!("*Terminal {}*", terminal_id.0),
249            "terminal".into(),
250            false,
251        );
252        self.active_window_mut()
253            .buffer_metadata
254            .insert(buffer_id, metadata);
255
256        // Map buffer to terminal
257        self.active_window_mut()
258            .terminal_buffers
259            .insert(buffer_id, terminal_id);
260
261        // Initialize event log for undo/redo
262        self.active_window_mut()
263            .event_logs
264            .insert(buffer_id, crate::model::event::EventLog::new());
265
266        // Set up split view state
267        if let Some(view_state) = self
268            .windows
269            .get_mut(&self.active_window)
270            .and_then(|w| w.split_view_states_mut())
271            .expect("active window must have a populated split layout")
272            .get_mut(&split_id)
273        {
274            view_state.add_buffer(buffer_id);
275            // Terminal buffers should not wrap lines so escape sequences stay intact
276            view_state.viewport.line_wrap_enabled = false;
277        }
278
279        buffer_id
280    }
281
282    /// Create a terminal buffer without attaching it to any split (used during session restore).
283    pub(crate) fn create_terminal_buffer_detached(&mut self, terminal_id: TerminalId) -> BufferId {
284        let buffer_id = self.alloc_buffer_id();
285
286        // Get config values
287        let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
288
289        let backing_file = self
290            .active_window()
291            .terminal_backing_files
292            .get(&terminal_id)
293            .cloned()
294            .unwrap_or_else(|| {
295                let root = self.dir_context.terminal_dir_for(&self.working_dir);
296                if let Err(e) = self.authority.filesystem.create_dir_all(&root) {
297                    tracing::warn!("Failed to create terminal directory: {}", e);
298                }
299                root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
300            });
301
302        // Create the file only if it doesn't exist (preserve existing scrollback for restore)
303        if !self.authority.filesystem.exists(&backing_file) {
304            if let Err(e) = self.authority.filesystem.write_file(&backing_file, &[]) {
305                tracing::warn!("Failed to create terminal backing file: {}", e);
306            }
307        }
308
309        // Create editor state with the backing file
310        let mut state = EditorState::new_with_path(
311            large_file_threshold,
312            std::sync::Arc::clone(&self.authority.filesystem),
313            backing_file.clone(),
314        );
315        state.margins.configure_for_line_numbers(false);
316        self.windows
317            .get_mut(&self.active_window)
318            .map(|w| &mut w.buffers)
319            .expect("active window present")
320            .insert(buffer_id, state);
321        let metadata = BufferMetadata::virtual_buffer(
322            format!("*Terminal {}*", terminal_id.0),
323            "terminal".into(),
324            false,
325        );
326        self.active_window_mut()
327            .buffer_metadata
328            .insert(buffer_id, metadata);
329        self.active_window_mut()
330            .terminal_buffers
331            .insert(buffer_id, terminal_id);
332        self.active_window_mut()
333            .event_logs
334            .insert(buffer_id, crate::model::event::EventLog::new());
335
336        buffer_id
337    }
338
339    /// Close the current terminal (if viewing a terminal buffer)
340    pub fn close_terminal(&mut self) {
341        let buffer_id = self.active_buffer();
342
343        if let Some(&terminal_id) = self.active_window().terminal_buffers.get(&buffer_id) {
344            // Close the terminal
345            self.active_window_mut().terminal_manager.close(terminal_id);
346            self.active_window_mut().terminal_buffers.remove(&buffer_id);
347            self.active_window_mut()
348                .ephemeral_terminals
349                .remove(&terminal_id);
350
351            // Clean up backing/rendering file
352            let backing_file = self
353                .active_window_mut()
354                .terminal_backing_files
355                .remove(&terminal_id);
356            if let Some(ref path) = backing_file {
357                // Best-effort cleanup of temporary terminal files.
358                #[allow(clippy::let_underscore_must_use)]
359                let _ = self.authority.filesystem.remove_file(path);
360            }
361            // Clean up raw log file
362            if let Some(log_file) = self
363                .active_window_mut()
364                .terminal_log_files
365                .remove(&terminal_id)
366            {
367                if backing_file.as_ref() != Some(&log_file) {
368                    // Best-effort cleanup of temporary terminal files.
369                    #[allow(clippy::let_underscore_must_use)]
370                    let _ = self.authority.filesystem.remove_file(&log_file);
371                }
372            }
373
374            // Exit terminal mode
375            self.active_window_mut().terminal_mode = false;
376            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
377
378            // Close the buffer
379            if let Err(e) = self.close_buffer(buffer_id) {
380                tracing::warn!("Failed to close terminal buffer: {}", e);
381            }
382
383            self.set_status_message(t!("terminal.closed", id = terminal_id.0).to_string());
384        } else {
385            self.set_status_message(t!("status.not_viewing_terminal").to_string());
386        }
387    }
388
389    // `is_terminal_buffer` and `get_terminal_id` moved to `impl Window`
390    // (in `window.rs`). Editor callers reach them via
391    // `self.active_window().is_terminal_buffer(...)` /
392    // `.get_terminal_id(...)`.
393
394    // `get_active_terminal_state`, `send_terminal_input`,
395    // `send_terminal_key`, `send_terminal_mouse`, and
396    // `is_terminal_in_alternate_screen` live on `impl Window` — they
397    // only touch this window's `terminal_buffers` + `terminal_manager`.
398    // Call them via `self.active_window()` / `self.active_window_mut()`.
399
400    /// Get terminal dimensions based on split size
401    pub(crate) fn get_terminal_dimensions(&self) -> (u16, u16) {
402        // Use the visible area of the current split
403        // Subtract 1 for status bar, tab bar, etc.
404        let cols = self.terminal_width.saturating_sub(2).max(40);
405        let rows = self.terminal_height.saturating_sub(4).max(10);
406        (cols, rows)
407    }
408
409    /// Handle terminal input when in terminal mode
410    pub fn handle_terminal_key(
411        &mut self,
412        code: crossterm::event::KeyCode,
413        modifiers: crossterm::event::KeyModifiers,
414    ) -> bool {
415        // Check for escape sequences to exit terminal mode
416        // Ctrl+Space, Ctrl+], or Ctrl+` to exit (Ctrl+\ sends SIGQUIT on Unix)
417        if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
418            match code {
419                crossterm::event::KeyCode::Char(' ')
420                | crossterm::event::KeyCode::Char(']')
421                | crossterm::event::KeyCode::Char('`') => {
422                    // Exit terminal mode and sync buffer
423                    self.active_window_mut().terminal_mode = false;
424                    self.active_window_mut().key_context =
425                        crate::input::keybindings::KeyContext::Normal;
426                    {
427                        let __b = self.active_buffer();
428                        self.active_window_mut().sync_terminal_to_buffer(__b);
429                    };
430                    self.set_status_message(
431                        "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
432                    );
433                    return true;
434                }
435                _ => {}
436            }
437        }
438
439        // Send the key to the terminal
440        self.active_window_mut().send_terminal_key(code, modifiers);
441        true
442    }
443
444    /// Re-enter terminal mode from read-only buffer view
445    ///
446    /// This truncates the backing file to remove the visible screen tail
447    /// that was appended when we exited terminal mode, leaving only the
448    /// incrementally-streamed scrollback history.
449    pub fn enter_terminal_mode(&mut self) {
450        if self
451            .active_window()
452            .is_terminal_buffer(self.active_buffer())
453        {
454            self.active_window_mut().terminal_mode = true;
455            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
456
457            // Re-enable editing when in terminal mode (input goes to PTY)
458            let __buffer_id = self.active_buffer();
459            if let Some(state) = self
460                .windows
461                .get_mut(&self.active_window)
462                .map(|w| &mut w.buffers)
463                .expect("active window present")
464                .get_mut(&__buffer_id)
465            {
466                state.editing_disabled = false;
467                state.margins.configure_for_line_numbers(false);
468            }
469            let __active_split = self.split_manager().active_split();
470            if let Some(view_state) = self.split_view_states_mut().get_mut(&__active_split) {
471                view_state.viewport.line_wrap_enabled = false;
472            }
473
474            // Truncate backing file to remove visible screen tail and scroll to bottom
475            if let Some(&terminal_id) = self
476                .active_window()
477                .terminal_buffers
478                .get(&self.active_buffer())
479            {
480                // Truncate backing file to remove visible screen that was appended
481                if let Some(backing_path) = self
482                    .active_window()
483                    .terminal_backing_files
484                    .get(&terminal_id)
485                {
486                    if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
487                        if let Ok(state) = handle.state.lock() {
488                            let truncate_pos = state.backing_file_history_end();
489                            // Always truncate to remove appended visible screen
490                            // (even if truncate_pos is 0, meaning no scrollback yet)
491                            if let Err(e) = self
492                                .authority
493                                .filesystem
494                                .set_file_length(backing_path, truncate_pos)
495                            {
496                                tracing::warn!("Failed to truncate terminal backing file: {}", e);
497                            }
498                        }
499                    }
500                }
501
502                // Scroll terminal to bottom when re-entering
503                if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
504                    if let Ok(mut state) = handle.state.lock() {
505                        state.scroll_to_bottom();
506                    }
507                }
508            }
509
510            // Ensure terminal PTY is sized correctly for current split dimensions
511            self.active_window_mut().resize_visible_terminals();
512
513            self.set_status_message(t!("status.terminal_mode_enabled").to_string());
514        }
515    }
516
517    /// Get terminal content for rendering
518    pub fn get_terminal_content(
519        &self,
520        buffer_id: BufferId,
521    ) -> Option<Vec<Vec<crate::services::terminal::TerminalCell>>> {
522        let terminal_id = self.active_window().terminal_buffers.get(&buffer_id)?;
523        let handle = self.active_window().terminal_manager.get(*terminal_id)?;
524        let state = handle.state.lock().ok()?;
525
526        let (_, rows) = state.size();
527        let mut content = Vec::with_capacity(rows as usize);
528
529        for row in 0..rows {
530            content.push(state.get_line(row));
531        }
532
533        Some(content)
534    }
535}
536
537impl Window {
538    /// Get the terminal state for the active buffer (if it's a terminal buffer).
539    pub fn get_active_terminal_state(
540        &self,
541    ) -> Option<std::sync::MutexGuard<'_, crate::services::terminal::TerminalState>> {
542        let terminal_id = self.terminal_buffers.get(&self.active_buffer())?;
543        let handle = self.terminal_manager.get(*terminal_id)?;
544        handle.state.lock().ok()
545    }
546
547    /// Send input bytes to this window's active terminal (no-op if the
548    /// active buffer is not a terminal).
549    pub fn send_terminal_input(&mut self, data: &[u8]) {
550        if let Some(&terminal_id) = self.terminal_buffers.get(&self.active_buffer()) {
551            if let Some(handle) = self.terminal_manager.get(terminal_id) {
552                handle.write(data);
553            }
554        }
555    }
556
557    /// Send a key event to this window's active terminal. Picks
558    /// "application cursor" vs "normal cursor" escape sequences
559    /// based on the terminal's current state.
560    pub fn send_terminal_key(
561        &mut self,
562        code: crossterm::event::KeyCode,
563        modifiers: crossterm::event::KeyModifiers,
564    ) {
565        let app_cursor = self
566            .get_active_terminal_state()
567            .map(|s| s.is_app_cursor())
568            .unwrap_or(false);
569        if let Some(bytes) =
570            crate::services::terminal::pty::key_to_pty_bytes(code, modifiers, app_cursor)
571        {
572            self.send_terminal_input(&bytes);
573        }
574    }
575
576    /// Send a mouse event to this window's active terminal.
577    pub fn send_terminal_mouse(
578        &mut self,
579        col: u16,
580        row: u16,
581        kind: crate::input::handler::TerminalMouseEventKind,
582        modifiers: crossterm::event::KeyModifiers,
583    ) {
584        use crate::input::handler::TerminalMouseEventKind;
585
586        // Check if terminal uses SGR mouse encoding.
587        let use_sgr = self
588            .get_active_terminal_state()
589            .map(|s| s.uses_sgr_mouse())
590            .unwrap_or(true);
591
592        // For alternate scroll mode, convert scroll to arrow keys.
593        let uses_alt_scroll = self
594            .get_active_terminal_state()
595            .map(|s| s.uses_alternate_scroll())
596            .unwrap_or(false);
597
598        if uses_alt_scroll {
599            match kind {
600                TerminalMouseEventKind::ScrollUp => {
601                    for _ in 0..3 {
602                        self.send_terminal_input(b"\x1b[A");
603                    }
604                    return;
605                }
606                TerminalMouseEventKind::ScrollDown => {
607                    for _ in 0..3 {
608                        self.send_terminal_input(b"\x1b[B");
609                    }
610                    return;
611                }
612                _ => {}
613            }
614        }
615
616        let bytes = if use_sgr {
617            encode_sgr_mouse(col, row, kind, modifiers)
618        } else {
619            encode_x10_mouse(col, row, kind, modifiers)
620        };
621
622        if let Some(bytes) = bytes {
623            self.send_terminal_input(&bytes);
624        }
625    }
626
627    /// Check if the given terminal buffer in this window is in
628    /// alternate-screen mode (vim/less/htop etc.).
629    pub fn is_terminal_in_alternate_screen(&self, buffer_id: BufferId) -> bool {
630        if let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) {
631            if let Some(handle) = self.terminal_manager.get(terminal_id) {
632                if let Ok(state) = handle.state.lock() {
633                    return state.is_alternate_screen();
634                }
635            }
636        }
637        false
638    }
639
640    /// Resize a single terminal buffer's PTY (only if `buffer_id`
641    /// belongs to this window's terminal_buffers map).
642    pub fn resize_terminal(&mut self, buffer_id: BufferId, cols: u16, rows: u16) {
643        if let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) {
644            if let Some(handle) = self.terminal_manager.get_mut(terminal_id) {
645                handle.resize(cols, rows);
646            }
647        }
648    }
649
650    /// Resize all this window's visible terminal PTYs to match their
651    /// current split dimensions. Reads the window's cached
652    /// `terminal_width` / `terminal_height` for the screen size.
653    pub fn resize_visible_terminals(&mut self) {
654        // Get the content area excluding file explorer
655        let file_explorer_width = if self.file_explorer_visible {
656            self.file_explorer_width.to_cols(self.terminal_width)
657        } else {
658            0
659        };
660        let editor_width = self.terminal_width.saturating_sub(file_explorer_width);
661        let editor_area = ratatui::layout::Rect::new(
662            file_explorer_width,
663            1, // menu bar
664            editor_width,
665            self.terminal_height.saturating_sub(2), // menu bar + status bar
666        );
667
668        let Some((mgr, _)) = self.buffers.splits() else {
669            return;
670        };
671        let visible_buffers = mgr.get_visible_buffers(editor_area);
672
673        for (_split_id, buffer_id, split_area) in visible_buffers {
674            if self.terminal_buffers.contains_key(&buffer_id) {
675                // Tab bar takes 1 row, scrollbar takes 1 column on the right.
676                let content_height = split_area.height.saturating_sub(2);
677                let content_width = split_area.width.saturating_sub(2);
678
679                if content_width > 0 && content_height > 0 {
680                    self.resize_terminal(buffer_id, content_width, content_height);
681                }
682            }
683        }
684    }
685
686    /// Sync terminal content to the active terminal buffer's text view
687    /// for read-only viewing / selection.
688    ///
689    /// Incremental streaming architecture:
690    /// 1. Scrollback has already been streamed to the backing file during PTY reads.
691    /// 2. We append the visible screen (~50 lines) to the backing file.
692    /// 3. Reload the buffer from the backing file (lazy load for large files).
693    ///
694    /// Performance: O(screen_size) instead of O(total_history).
695    pub fn sync_terminal_to_buffer(&mut self, buffer_id: BufferId) {
696        let Some(&terminal_id) = self.terminal_buffers.get(&buffer_id) else {
697            return;
698        };
699        // Get the backing file path
700        let backing_file = match self.terminal_backing_files.get(&terminal_id) {
701            Some(path) => path.clone(),
702            None => return,
703        };
704
705        // Append visible screen to backing file
706        // The scrollback has already been incrementally streamed by the PTY read loop
707        if let Some(handle) = self.terminal_manager.get(terminal_id) {
708            if let Ok(mut state) = handle.state.lock() {
709                // Record the current file size as the history end point
710                // (before appending visible screen) so we can truncate back to it
711                if let Ok(metadata) = self.resources.authority.filesystem.metadata(&backing_file) {
712                    state.set_backing_file_history_end(metadata.size);
713                }
714
715                // Open backing file in append mode to add visible screen
716                if let Ok(mut file) = self
717                    .resources
718                    .authority
719                    .filesystem
720                    .open_file_for_append(&backing_file)
721                {
722                    use std::io::BufWriter;
723                    let mut writer = BufWriter::new(&mut *file);
724                    if let Err(e) = state.append_visible_screen(&mut writer) {
725                        tracing::error!("Failed to append visible screen to backing file: {}", e);
726                    }
727                }
728            }
729        }
730
731        // Reload buffer from the backing file (reusing existing file loading)
732        let large_file_threshold = self.resources.config.editor.large_file_threshold_bytes as usize;
733        if let Ok(new_state) = EditorState::from_file_with_languages(
734            &backing_file,
735            self.terminal_width,
736            self.terminal_height,
737            large_file_threshold,
738            &self.resources.grammar_registry,
739            &self.resources.config.languages,
740            std::sync::Arc::clone(&self.resources.authority.filesystem),
741        ) {
742            let total_bytes = new_state.buffer.total_bytes();
743            if let Some(state) = self.buffers.get_mut(&buffer_id) {
744                *state = new_state;
745                // Terminal buffers should never be considered "modified"
746                state.buffer.set_modified(false);
747            }
748            // Move cursor to end of buffer in SplitViewState
749            if let Some((mgr, view_states)) = self.buffers.splits_mut() {
750                let active_split = mgr.active_split();
751                if let Some(view_state) = view_states.get_mut(&active_split) {
752                    view_state.cursors.primary_mut().position = total_bytes;
753                }
754            }
755        }
756
757        // Mark buffer as editing-disabled while in non-terminal mode
758        if let Some(state) = self.buffers.get_mut(&buffer_id) {
759            state.editing_disabled = true;
760            state.margins.configure_for_line_numbers(false);
761        }
762
763        // In read-only view, keep line wrapping disabled for terminal buffers
764        // Also scroll viewport to show the end of the buffer where the cursor is.
765        let active_split = self
766            .buffers
767            .splits()
768            .expect("active window must have a populated split layout")
769            .0
770            .active_split();
771        self.enter_terminal_scrollback_view(buffer_id, active_split);
772    }
773}
774
775impl Editor {
776    /// Check if terminal mode is active (for testing)
777    pub fn is_terminal_mode(&self) -> bool {
778        self.active_window().terminal_mode
779    }
780
781    /// Check if a buffer is in terminal_mode_resume set (for testing/debugging)
782    pub fn is_in_terminal_mode_resume(&self, buffer_id: BufferId) -> bool {
783        self.active_window()
784            .terminal_mode_resume
785            .contains(&buffer_id)
786    }
787
788    /// Check if keyboard capture is enabled in terminal mode (for testing)
789    pub fn is_keyboard_capture(&self) -> bool {
790        self.active_window().keyboard_capture
791    }
792
793    /// Set terminal jump_to_end_on_output config option (for testing)
794    pub fn set_terminal_jump_to_end_on_output(&mut self, value: bool) {
795        self.config_mut().terminal.jump_to_end_on_output = value;
796    }
797
798    /// Get read-only access to the active window's terminal manager
799    /// (for testing). After Step 0d, terminal state lives on each
800    /// window — this routes to the active one.
801    pub fn terminal_manager(&self) -> &crate::services::terminal::TerminalManager {
802        &self
803            .windows
804            .get(&self.active_window)
805            .expect("active window must exist")
806            .terminal_manager
807    }
808
809    /// Get read-only access to the active window's terminal backing
810    /// files map (for testing).
811    pub fn terminal_backing_files(
812        &self,
813    ) -> &std::collections::HashMap<crate::services::terminal::TerminalId, std::path::PathBuf> {
814        &self
815            .windows
816            .get(&self.active_window)
817            .expect("active window must exist")
818            .terminal_backing_files
819    }
820
821    /// Get the currently active buffer ID
822    pub fn active_buffer_id(&self) -> BufferId {
823        self.active_buffer()
824    }
825
826    /// Get buffer content as a string (for testing)
827    pub fn get_buffer_content(&self, buffer_id: BufferId) -> Option<String> {
828        self.windows
829            .get(&self.active_window)
830            .map(|w| &w.buffers)
831            .expect("active window present")
832            .get(&buffer_id)
833            .and_then(|state| state.buffer.to_string())
834    }
835
836    /// Get cursor position for a buffer (for testing)
837    pub fn get_cursor_position(&self, buffer_id: BufferId) -> Option<usize> {
838        // Find cursor from any split view state that has this buffer
839        self.windows
840            .get(&self.active_window)
841            .and_then(|w| w.buffers.splits())
842            .map(|(_, vs)| vs)
843            .expect("active window must have a populated split layout")
844            .values()
845            .find_map(|vs| {
846                if vs.keyed_states.contains_key(&buffer_id) {
847                    Some(vs.keyed_states.get(&buffer_id)?.cursors.primary().position)
848                } else {
849                    None
850                }
851            })
852            .or_else(|| {
853                // Fallback: check active cursors
854                self.windows
855                    .get(&self.active_window)
856                    .and_then(|w| w.buffers.splits())
857                    .map(|(_, vs)| vs)
858                    .expect("active window must have a populated split layout")
859                    .values()
860                    .map(|vs| vs.cursors.primary().position)
861                    .next()
862            })
863    }
864
865    /// Render terminal content for all terminal buffers in split areas
866    ///
867    /// Renders all visible terminal buffers from their live terminal state.
868    /// This ensures terminals continue updating even when not focused, as long
869    /// as they remain visible in a split.
870    pub fn render_terminal_splits(
871        &self,
872        frame: &mut ratatui::Frame,
873        split_areas: &[(
874            crate::model::event::LeafId,
875            BufferId,
876            ratatui::layout::Rect,
877            ratatui::layout::Rect,
878            usize,
879            usize,
880        )],
881    ) {
882        for (_split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
883            split_areas
884        {
885            // Only render terminal buffers - skip regular file buffers
886            if let Some(&terminal_id) = self.active_window().terminal_buffers.get(buffer_id) {
887                // Only render from live terminal state if in terminal mode OR if not the active buffer
888                // (when it's the active buffer but not in terminal mode, we're in read-only scrollback mode
889                // and should show the synced buffer content instead)
890                let is_active = *buffer_id == self.active_buffer();
891                if is_active && !self.active_window().terminal_mode {
892                    // Active buffer in read-only mode - let normal buffer rendering handle it
893                    continue;
894                }
895                // Get terminal content and cursor info
896                if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
897                    if let Ok(state) = handle.state.lock() {
898                        let cursor_pos = state.cursor_position();
899                        // Only show cursor for the active terminal in terminal mode
900                        let cursor_visible = state.cursor_visible()
901                            && is_active
902                            && self.active_window().terminal_mode;
903                        let (_, rows) = state.size();
904
905                        // Collect content
906                        let mut content = Vec::with_capacity(rows as usize);
907                        for row in 0..rows {
908                            content.push(state.get_line(row));
909                        }
910
911                        // Clear the content area first
912                        frame.render_widget(ratatui::widgets::Clear, *content_rect);
913
914                        // Render terminal content with theme colors
915                        render::render_terminal_content(
916                            &content,
917                            cursor_pos,
918                            cursor_visible,
919                            *content_rect,
920                            frame.buffer_mut(),
921                            self.theme.read().unwrap().terminal_fg,
922                            self.theme.read().unwrap().terminal_bg,
923                        );
924                    }
925                }
926            }
927        }
928    }
929}
930
931/// Terminal rendering utilities
932pub mod render {
933    use crate::services::terminal::TerminalCell;
934    use ratatui::buffer::Buffer;
935    use ratatui::layout::Rect;
936    use ratatui::style::{Color, Modifier, Style};
937
938    /// Render terminal content to a ratatui buffer
939    pub fn render_terminal_content(
940        content: &[Vec<TerminalCell>],
941        cursor_pos: (u16, u16),
942        cursor_visible: bool,
943        area: Rect,
944        buf: &mut Buffer,
945        default_fg: Color,
946        default_bg: Color,
947    ) {
948        for (row_idx, row) in content.iter().enumerate() {
949            if row_idx as u16 >= area.height {
950                break;
951            }
952
953            let y = area.y + row_idx as u16;
954
955            for (col_idx, cell) in row.iter().enumerate() {
956                if col_idx as u16 >= area.width {
957                    break;
958                }
959
960                let x = area.x + col_idx as u16;
961
962                // Build style from cell attributes, using theme defaults
963                let mut style = Style::default().fg(default_fg).bg(default_bg);
964
965                // Override with cell-specific colors if present
966                if let Some((r, g, b)) = cell.fg {
967                    style = style.fg(Color::Rgb(r, g, b));
968                }
969
970                if let Some((r, g, b)) = cell.bg {
971                    style = style.bg(Color::Rgb(r, g, b));
972                }
973
974                // Apply modifiers
975                if cell.bold {
976                    style = style.add_modifier(Modifier::BOLD);
977                }
978                if cell.italic {
979                    style = style.add_modifier(Modifier::ITALIC);
980                }
981                if cell.underline {
982                    style = style.add_modifier(Modifier::UNDERLINED);
983                }
984                if cell.inverse {
985                    style = style.add_modifier(Modifier::REVERSED);
986                }
987
988                // Check if this is the cursor position
989                if cursor_visible
990                    && row_idx as u16 == cursor_pos.1
991                    && col_idx as u16 == cursor_pos.0
992                {
993                    style = style.add_modifier(Modifier::REVERSED);
994                }
995
996                buf.set_string(x, y, cell.c.to_string(), style);
997            }
998        }
999    }
1000}
1001
1002/// Encode a mouse event in SGR format (modern protocol).
1003/// Format: CSI < Cb ; Cx ; Cy M (press) or CSI < Cb ; Cx ; Cy m (release)
1004fn encode_sgr_mouse(
1005    col: u16,
1006    row: u16,
1007    kind: crate::input::handler::TerminalMouseEventKind,
1008    modifiers: crossterm::event::KeyModifiers,
1009) -> Option<Vec<u8>> {
1010    use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
1011
1012    // SGR uses 1-based coordinates
1013    let cx = col + 1;
1014    let cy = row + 1;
1015
1016    // Build button code
1017    let (button_code, is_release) = match kind {
1018        TerminalMouseEventKind::Down(btn) => {
1019            let code = match btn {
1020                TerminalMouseButton::Left => 0,
1021                TerminalMouseButton::Middle => 1,
1022                TerminalMouseButton::Right => 2,
1023            };
1024            (code, false)
1025        }
1026        TerminalMouseEventKind::Up(btn) => {
1027            let code = match btn {
1028                TerminalMouseButton::Left => 0,
1029                TerminalMouseButton::Middle => 1,
1030                TerminalMouseButton::Right => 2,
1031            };
1032            (code, true)
1033        }
1034        TerminalMouseEventKind::Drag(btn) => {
1035            let code = match btn {
1036                TerminalMouseButton::Left => 32,   // 0 + 32 (motion flag)
1037                TerminalMouseButton::Middle => 33, // 1 + 32
1038                TerminalMouseButton::Right => 34,  // 2 + 32
1039            };
1040            (code, false)
1041        }
1042        TerminalMouseEventKind::Moved => (35, false), // 3 + 32 (no button + motion)
1043        TerminalMouseEventKind::ScrollUp => (64, false),
1044        TerminalMouseEventKind::ScrollDown => (65, false),
1045    };
1046
1047    // Add modifier flags
1048    let mut cb = button_code;
1049    if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
1050        cb += 4;
1051    }
1052    if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
1053        cb += 8;
1054    }
1055    if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1056        cb += 16;
1057    }
1058
1059    // Build escape sequence
1060    let terminator = if is_release { 'm' } else { 'M' };
1061    Some(format!("\x1b[<{};{};{}{}", cb, cx, cy, terminator).into_bytes())
1062}
1063
1064/// Encode a mouse event in X10/normal format (legacy protocol).
1065/// Format: CSI M Cb Cx Cy (with 32 added to all values for ASCII safety)
1066fn encode_x10_mouse(
1067    col: u16,
1068    row: u16,
1069    kind: crate::input::handler::TerminalMouseEventKind,
1070    modifiers: crossterm::event::KeyModifiers,
1071) -> Option<Vec<u8>> {
1072    use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
1073
1074    // X10 uses 1-based coordinates with 32 offset for ASCII safety
1075    // Maximum coordinate is 223 (255 - 32)
1076    let cx = (col.min(222) + 1 + 32) as u8;
1077    let cy = (row.min(222) + 1 + 32) as u8;
1078
1079    // Build button code
1080    let button_code: u8 = match kind {
1081        TerminalMouseEventKind::Down(btn) | TerminalMouseEventKind::Drag(btn) => match btn {
1082            TerminalMouseButton::Left => 0,
1083            TerminalMouseButton::Middle => 1,
1084            TerminalMouseButton::Right => 2,
1085        },
1086        TerminalMouseEventKind::Up(_) => 3, // Release is button 3 in X10
1087        TerminalMouseEventKind::Moved => 3 + 32,
1088        TerminalMouseEventKind::ScrollUp => 64,
1089        TerminalMouseEventKind::ScrollDown => 65,
1090    };
1091
1092    // Add modifier flags and motion flag for drag
1093    let mut cb = button_code;
1094    if matches!(kind, TerminalMouseEventKind::Drag(_)) {
1095        cb += 32; // Motion flag
1096    }
1097    if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
1098        cb += 4;
1099    }
1100    if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
1101        cb += 8;
1102    }
1103    if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
1104        cb += 16;
1105    }
1106
1107    // Add 32 offset for ASCII safety
1108    let cb = cb + 32;
1109
1110    Some(vec![0x1b, b'[', b'M', cb, cx, cy])
1111}