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