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