Skip to main content

vtcode_ui/tui/core_tui/
alternate_screen.rs

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