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