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}