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}