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