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