Skip to main content

vtcode_tui/core_tui/
alternate_screen.rs

1use std::io::{self, Write};
2
3use crate::utils::tty::TtyExt;
4use anyhow::{Context, Result};
5use ratatui::crossterm::{
6    cursor::MoveToColumn,
7    event::{DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange},
8    execute,
9    terminal::{
10        self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
11        enable_raw_mode,
12    },
13};
14
15/// Terminal state that needs to be preserved when entering alternate screen
16#[derive(Debug)]
17struct TerminalState {
18    raw_mode_enabled: bool,
19    bracketed_paste_enabled: bool,
20    focus_change_enabled: bool,
21}
22
23/// Manages entering and exiting alternate screen with proper state preservation
24///
25/// This struct ensures that terminal state is properly saved before entering
26/// alternate screen and restored when exiting, even in the presence of errors.
27///
28/// # Example
29///
30/// ```no_run
31/// use vtcode_tui::core_tui::alternate_screen::AlternateScreenSession;
32///
33/// // Run a closure in alternate screen with automatic cleanup
34/// let result = AlternateScreenSession::run(|| {
35///     // Your code that runs in alternate screen
36///     println!("Running in alternate screen!");
37///     Ok(())
38/// })?;
39/// ```
40pub struct AlternateScreenSession {
41    /// Terminal state before entering alternate screen
42    original_state: TerminalState,
43    /// Whether we successfully entered alternate screen
44    entered: bool,
45}
46
47impl AlternateScreenSession {
48    /// Enter alternate screen, saving current terminal state
49    ///
50    /// This will:
51    /// 1. Save the current terminal state
52    /// 2. Enter alternate screen
53    /// 3. Enable raw mode
54    /// 4. Enable bracketed paste
55    /// 5. Enable focus change events (if supported)
56    /// 6. Push keyboard enhancement flags (if supported)
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if any terminal operation fails.
61    pub fn enter() -> Result<Self> {
62        let mut stdout = io::stdout();
63
64        // Check if stdout is a TTY before proceeding
65        let is_tty = stdout.is_tty_ext();
66        if !is_tty {
67            tracing::warn!("stdout is not a TTY, alternate screen features may not work");
68        }
69
70        // Save current state
71        let original_state = TerminalState {
72            raw_mode_enabled: false, // We'll enable it fresh
73            bracketed_paste_enabled: false,
74            focus_change_enabled: false,
75        };
76
77        // Enter alternate screen first
78        execute!(stdout, EnterAlternateScreen)
79            .context("failed to enter alternate screen for terminal app")?;
80
81        let mut session = Self {
82            original_state,
83            entered: true,
84        };
85
86        // Enable raw mode
87        enable_raw_mode().context("failed to enable raw mode for terminal app")?;
88        session.original_state.raw_mode_enabled = true;
89
90        // Enable bracketed paste (only if TTY)
91        if is_tty && execute!(stdout, EnableBracketedPaste).is_ok() {
92            session.original_state.bracketed_paste_enabled = true;
93        }
94
95        // Enable focus change events (only if TTY)
96        if is_tty && execute!(stdout, EnableFocusChange).is_ok() {
97            session.original_state.focus_change_enabled = true;
98        }
99
100        Ok(session)
101    }
102
103    /// Exit alternate screen, restoring original terminal state
104    ///
105    /// This will:
106    /// 1. Pop keyboard enhancement flags (if they were pushed)
107    /// 2. Disable focus change events (if they were enabled)
108    /// 3. Disable bracketed paste (if it was enabled)
109    /// 4. Disable raw mode (if it was enabled)
110    /// 5. Leave alternate screen
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if any terminal operation fails. However, this method
115    /// will attempt to restore as much state as possible even if some operations fail.
116    pub fn exit(mut self) -> Result<()> {
117        self.restore_state()?;
118        self.entered = false; // Prevent Drop from trying again
119        Ok(())
120    }
121
122    /// Run a closure in alternate screen with automatic cleanup
123    ///
124    /// This is a convenience method that handles entering and exiting alternate
125    /// screen automatically, ensuring cleanup happens even if the closure panics.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if entering/exiting alternate screen fails, or if the
130    /// closure returns an error.
131    pub fn run<F, T>(f: F) -> Result<T>
132    where
133        F: FnOnce() -> Result<T>,
134    {
135        let session = Self::enter()?;
136        let result = f();
137        session.exit()?;
138        result
139    }
140
141    /// Internal method to restore terminal state
142    fn restore_state(&mut self) -> Result<()> {
143        if !self.entered {
144            return Ok(());
145        }
146
147        // Drain any pending crossterm events BEFORE leaving alternate screen and disabling raw mode
148        // to prevent them from leaking to the shell.
149        while let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(0)) {
150            let _ = crossterm::event::read();
151        }
152
153        let mut stdout = io::stdout();
154
155        // Clear current line to remove artifacts like ^C from rapid presses
156        let _ = execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine));
157
158        let mut errors = Vec::new();
159
160        // Restore in proper order to prevent leakage
161
162        // 1. Leave alternate screen FIRST
163        if let Err(e) = execute!(stdout, LeaveAlternateScreen) {
164            tracing::warn!(%e, "failed to leave alternate screen");
165            errors.push(format!("leave alternate screen: {}", e));
166        }
167
168        // 2. Disable focus change (if enabled and TTY)
169        if self.original_state.focus_change_enabled
170            && let Err(e) = execute!(stdout, DisableFocusChange)
171        {
172            tracing::warn!(%e, "failed to disable focus change");
173            errors.push(format!("disable focus change: {}", e));
174        }
175
176        // 3. Disable bracketed paste (if enabled and TTY)
177        if self.original_state.bracketed_paste_enabled
178            && let Err(e) = execute!(stdout, DisableBracketedPaste)
179        {
180            tracing::warn!(%e, "failed to disable bracketed paste");
181            errors.push(format!("disable bracketed paste: {}", e));
182        }
183
184        // 4. Disable raw mode LAST
185        if self.original_state.raw_mode_enabled
186            && let Err(e) = disable_raw_mode()
187        {
188            tracing::warn!(%e, "failed to disable raw mode");
189            errors.push(format!("disable raw mode: {}", e));
190        }
191
192        // Flush to ensure all changes are applied
193        if let Err(e) = stdout.flush() {
194            tracing::warn!(%e, "failed to flush stdout");
195            errors.push(format!("flush stdout: {}", e));
196        }
197
198        if errors.is_empty() {
199            Ok(())
200        } else {
201            tracing::warn!(
202                errors = ?errors,
203                "some terminal operations failed during restore"
204            );
205            // Don't fail the operation, just warn - terminal is likely already in a bad state
206            Ok(())
207        }
208    }
209}
210
211impl Drop for AlternateScreenSession {
212    fn drop(&mut self) {
213        if self.entered {
214            // Best effort cleanup - ignore errors in Drop
215            let _ = self.restore_state();
216        }
217    }
218}
219
220/// Clear the alternate screen
221///
222/// This is useful when you want to clear the screen before running a terminal app.
223pub fn clear_screen() -> Result<()> {
224    execute!(io::stdout(), Clear(ClearType::All)).context("failed to clear alternate screen")
225}
226
227/// Get current terminal size
228pub fn terminal_size() -> Result<(u16, u16)> {
229    terminal::size().context("failed to get terminal size")
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_enter_exit_cycle() {
238        if !io::stdout().is_tty_ext() {
239            return;
240        }
241
242        // This test verifies that we can enter and exit alternate screen
243        // without panicking. We can't easily verify the actual terminal state
244        // in a unit test, but we can at least ensure the code doesn't crash.
245        let session = AlternateScreenSession::enter();
246        assert!(session.is_ok());
247
248        if let Ok(session) = session {
249            let result = session.exit();
250            assert!(result.is_ok());
251        }
252    }
253
254    #[test]
255    fn test_run_with_closure() {
256        if !io::stdout().is_tty_ext() {
257            return;
258        }
259
260        let result = AlternateScreenSession::run(|| {
261            // Simulate some work in alternate screen
262            Ok(42)
263        });
264
265        assert!(result.is_ok());
266        assert_eq!(result.unwrap(), 42);
267    }
268
269    #[test]
270    fn test_run_with_error() {
271        let result: Result<()> = AlternateScreenSession::run(|| Err(anyhow::anyhow!("test error")));
272
273        assert!(result.is_err());
274    }
275
276    #[test]
277    fn test_drop_cleanup() {
278        // Verify that Drop properly cleans up
279        {
280            let _session = AlternateScreenSession::enter();
281            // Session dropped here
282        }
283        // If we get here without hanging, Drop worked
284    }
285}