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