Skip to main content

vtcode_tui/core_tui/
panic_hook.rs

1//! Panic hook implementation for terminal UI applications
2//! This module provides a panic hook that restores terminal state when a panic occurs,
3//! preventing terminal corruption, and provides enhanced panic formatting for different build types.
4
5use std::io::{self, Write};
6use std::panic;
7use std::sync::Once;
8use std::sync::atomic::{AtomicBool, Ordering};
9
10use better_panic::{Settings as BetterPanicSettings, Verbosity as BetterPanicVerbosity};
11use human_panic::{Metadata as HumanPanicMetadata, handle_dump as human_panic_dump, print_msg};
12use ratatui::crossterm::{
13    cursor::{MoveToColumn, RestorePosition, SetCursorStyle, Show},
14    event::{
15        DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, PopKeyboardEnhancementFlags,
16    },
17    execute,
18    terminal::{Clear, ClearType, LeaveAlternateScreen, disable_raw_mode},
19};
20
21static TUI_INITIALIZED: AtomicBool = AtomicBool::new(false);
22static KEYBOARD_ENHANCEMENTS_PUSHED: AtomicBool = AtomicBool::new(false);
23static RESTORE_DONE: AtomicBool = AtomicBool::new(false);
24static DEBUG_MODE: AtomicBool = AtomicBool::new(cfg!(debug_assertions));
25static COLOR_EYRE_ENABLED: AtomicBool = AtomicBool::new(cfg!(debug_assertions));
26static SHOW_DIAGNOSTICS: AtomicBool = AtomicBool::new(false);
27static PANIC_HOOK_ONCE: Once = Once::new();
28#[cfg(debug_assertions)]
29static COLOR_EYRE_SETUP_ONCE: Once = Once::new();
30#[cfg(debug_assertions)]
31static COLOR_EYRE_PANIC_HOOK: std::sync::OnceLock<color_eyre::config::PanicHook> =
32    std::sync::OnceLock::new();
33static APP_METADATA: std::sync::OnceLock<AppMetadata> = std::sync::OnceLock::new();
34
35#[derive(Clone, Debug)]
36struct AppMetadata {
37    name: &'static str,
38    version: &'static str,
39    authors: &'static str,
40    repository: Option<&'static str>,
41}
42
43impl AppMetadata {
44    fn default_for_tui_crate() -> Self {
45        Self {
46            name: env!("CARGO_PKG_NAME"),
47            version: env!("CARGO_PKG_VERSION"),
48            authors: env!("CARGO_PKG_AUTHORS"),
49            repository: Some(env!("CARGO_PKG_REPOSITORY")).filter(|value| !value.is_empty()),
50        }
51    }
52}
53
54/// Set whether the application is in debug mode
55///
56/// When debug mode is enabled, panics will show a detailed backtrace.
57pub fn set_debug_mode(enabled: bool) {
58    DEBUG_MODE.store(enabled, Ordering::SeqCst);
59}
60
61/// Get whether the application is in debug mode
62pub fn is_debug_mode() -> bool {
63    DEBUG_MODE.load(Ordering::SeqCst)
64}
65
66/// Set whether color-eyre formatting should be used for debug panic/error reporting.
67pub fn set_color_eyre_enabled(enabled: bool) {
68    COLOR_EYRE_ENABLED.store(enabled, Ordering::SeqCst);
69}
70
71/// Get whether color-eyre formatting is enabled for debug panic/error reporting.
72fn is_color_eyre_enabled() -> bool {
73    COLOR_EYRE_ENABLED.load(Ordering::SeqCst)
74}
75
76/// Install color-eyre's eyre hook for richer top-level error rendering in dev/debug mode.
77#[cfg(debug_assertions)]
78fn maybe_prepare_color_eyre_hooks() {
79    if !is_color_eyre_enabled() {
80        return;
81    }
82
83    COLOR_EYRE_SETUP_ONCE.call_once(|| {
84        let hooks = color_eyre::config::HookBuilder::default().try_into_hooks();
85        match hooks {
86            Ok((panic_hook, eyre_hook)) => {
87                let _ = COLOR_EYRE_PANIC_HOOK.set(panic_hook);
88                if let Err(error) = eyre_hook.install() {
89                    eprintln!("warning: failed to install color-eyre hook: {error}");
90                }
91            }
92            Err(error) => {
93                eprintln!("warning: failed to prepare color-eyre hook: {error}");
94            }
95        }
96    });
97}
98
99/// Print an application error using color-eyre when enabled, otherwise fallback formatting.
100pub fn print_error_report(error: anyhow::Error) {
101    if cfg!(debug_assertions) && is_color_eyre_enabled() {
102        #[cfg(debug_assertions)]
103        {
104            maybe_prepare_color_eyre_hooks();
105            let report = color_eyre::eyre::eyre!("{error:#}");
106            eprintln!("{report:?}");
107            return;
108        }
109    }
110
111    eprintln!("Error: {error:?}");
112}
113
114/// Set whether diagnostics (ERROR-level logs, warnings) should be displayed in the TUI.
115/// Driven by `ui.show_diagnostics_in_transcript` in vtcode.toml.
116pub fn set_show_diagnostics(enabled: bool) {
117    SHOW_DIAGNOSTICS.store(enabled, Ordering::SeqCst);
118}
119
120/// Get whether diagnostics should be displayed in the TUI
121pub fn show_diagnostics() -> bool {
122    SHOW_DIAGNOSTICS.load(Ordering::SeqCst)
123}
124
125/// Set application metadata used by release panic reports.
126///
127/// If this is not set, metadata from the `vtcode-tui` crate is used.
128pub fn set_app_metadata(
129    name: &'static str,
130    version: &'static str,
131    authors: &'static str,
132    repository: Option<&'static str>,
133) {
134    let _ = APP_METADATA.set(AppMetadata {
135        name,
136        version,
137        authors,
138        repository: repository.filter(|value| !value.is_empty()),
139    });
140}
141
142fn app_metadata() -> AppMetadata {
143    APP_METADATA
144        .get()
145        .cloned()
146        .unwrap_or_else(AppMetadata::default_for_tui_crate)
147}
148
149/// Initialize the panic hook to restore terminal state on panic and provide better formatting
150///
151/// This function should be called very early in the application lifecycle,
152/// before any TUI operations begin.
153///
154/// Follows Ratatui recipe: <https://ratatui.rs/recipes/apps/better-panic/>
155pub fn init_panic_hook() {
156    PANIC_HOOK_ONCE.call_once(|| {
157        // Keep original hook for concise non-debug panic output.
158        let original_hook = panic::take_hook();
159
160        // Better panic formatting for debug-mode crashes.
161        let better_panic_hook = BetterPanicSettings::new()
162            .verbosity(BetterPanicVerbosity::Full)
163            .most_recent_first(false)
164            .lineno_suffix(true)
165            .create_panic_handler();
166
167        panic::set_hook(Box::new(move |panic_info| {
168            let is_tui = TUI_INITIALIZED.load(Ordering::SeqCst);
169            let is_debug = DEBUG_MODE.load(Ordering::SeqCst);
170
171            // Ratatui recipe: always restore terminal before panic reporting.
172            if is_tui {
173                // Intentionally ignore restoration failures during panic unwind.
174                let _ = restore_tui();
175            }
176
177            if cfg!(debug_assertions) && is_debug {
178                if is_color_eyre_enabled() {
179                    #[cfg(debug_assertions)]
180                    {
181                        maybe_prepare_color_eyre_hooks();
182                        if let Some(panic_hook) = COLOR_EYRE_PANIC_HOOK.get() {
183                            eprintln!("{}", panic_hook.panic_report(panic_info));
184                            return;
185                        }
186                    }
187                }
188
189                better_panic_hook(panic_info);
190                // In debug/dev mode, preserve normal panic semantics (unwind/abort by profile)
191                // rather than forcing immediate process exit from inside the hook.
192                return;
193            }
194
195            {
196                let metadata = app_metadata();
197                let mut report_metadata = HumanPanicMetadata::new(metadata.name, metadata.version)
198                    .authors(format!("authored by {}", metadata.authors));
199
200                if let Some(repository) = metadata.repository {
201                    report_metadata = report_metadata
202                        .support(format!("Open a support request at {}", repository));
203                }
204
205                let file_path = human_panic_dump(&report_metadata, panic_info);
206                if let Err(error) = print_msg(file_path, &report_metadata) {
207                    eprintln!("\nVT Code encountered a critical error and needs to shut down.");
208                    eprintln!("Failed to print crash report details: {}", error);
209                    original_hook(panic_info);
210                }
211            }
212
213            // Keep current behavior: terminate process after unrecoverable panic.
214            std::process::exit(1);
215        }));
216    });
217}
218
219/// Mark that TUI has been initialized so panic hook knows to restore terminal
220pub fn mark_tui_initialized() {
221    TUI_INITIALIZED.store(true, Ordering::SeqCst);
222    RESTORE_DONE.store(false, Ordering::SeqCst);
223}
224
225/// Mark that TUI has been deinitialized to prevent further restoration attempts
226pub fn mark_tui_deinitialized() {
227    TUI_INITIALIZED.store(false, Ordering::SeqCst);
228}
229
230/// Mark that keyboard enhancement flags were pushed so `restore_tui` knows to pop them.
231pub fn mark_keyboard_enhancements_pushed(pushed: bool) {
232    KEYBOARD_ENHANCEMENTS_PUSHED.store(pushed, Ordering::SeqCst);
233}
234
235/// Restore terminal to a usable state after a panic
236///
237/// This function attempts to restore the terminal to its original state
238/// by disabling raw mode and leaving alternate screen if they were active.
239/// It handles all errors internally to ensure cleanup happens even if individual
240/// operations fail.
241///
242/// Follows Ratatui recipe: <https://ratatui.rs/recipes/apps/panic-hooks/>
243pub fn restore_tui() -> io::Result<()> {
244    // Ensure restoration only runs once to prevent interleaved cleanup from
245    // multiple call-sites (panic hook, signal handler, normal shutdown).
246    if RESTORE_DONE.swap(true, Ordering::SeqCst) {
247        return Ok(());
248    }
249
250    mark_tui_deinitialized();
251    let mut first_error: Option<io::Error> = None;
252
253    // 1. Drain any pending crossterm events to prevent them from leaking to the shell
254    // This is a best-effort drain with a zero timeout
255    while let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(0)) {
256        let _ = crossterm::event::read();
257    }
258
259    // Get stderr for executing terminal commands
260    let mut stderr = io::stderr();
261
262    // 2. Clear current line to remove any echoed ^C characters from rapid Ctrl+C presses
263    if let Err(error) = execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine)) {
264        first_error.get_or_insert(error);
265    }
266
267    // 3. Leave alternate screen FIRST (if we were in one)
268    // This is the most critical operation for visual restoration
269    if let Err(error) = execute!(stderr, LeaveAlternateScreen) {
270        first_error.get_or_insert(error);
271    }
272
273    // 4. Disable various terminal modes that might have been enabled by the TUI
274    if let Err(error) = execute!(stderr, DisableBracketedPaste) {
275        first_error.get_or_insert(error);
276    }
277    if let Err(error) = execute!(stderr, DisableFocusChange) {
278        first_error.get_or_insert(error);
279    }
280    if let Err(error) = execute!(stderr, DisableMouseCapture) {
281        first_error.get_or_insert(error);
282    }
283
284    // Only pop keyboard enhancement flags if they were actually pushed.
285    // Sending PopKeyboardEnhancementFlags unconditionally emits an escape
286    // sequence that non-kitty terminals may echo as visible garbage.
287    if KEYBOARD_ENHANCEMENTS_PUSHED.swap(false, Ordering::SeqCst)
288        && let Err(error) = execute!(stderr, PopKeyboardEnhancementFlags)
289    {
290        first_error.get_or_insert(error);
291    }
292
293    crate::core_tui::runner::terminal_io::reset_mouse_pointer_shape();
294
295    // Ensure cursor state is restored
296    if let Err(error) = execute!(
297        stderr,
298        SetCursorStyle::DefaultUserShape,
299        Show,
300        RestorePosition
301    ) {
302        first_error.get_or_insert(error);
303    }
304
305    // 5. Disable raw mode LAST to ensure all cleanup commands are sent properly
306    if let Err(error) = disable_raw_mode() {
307        first_error.get_or_insert(error);
308    }
309
310    // Additional flush to ensure all escape sequences are processed
311    if let Err(error) = stderr.flush() {
312        first_error.get_or_insert(error);
313    }
314
315    match first_error {
316        Some(error) => Err(error),
317        None => Ok(()),
318    }
319}
320
321/// A guard struct that automatically registers and unregisters TUI state
322/// with the panic hook system.
323///
324/// This ensures that terminal restoration only happens when the TUI was actually active.
325pub struct TuiPanicGuard;
326
327impl TuiPanicGuard {
328    /// Create a new guard and mark TUI as initialized
329    ///
330    /// This should be called when a TUI session begins.
331    pub fn new() -> Self {
332        mark_tui_initialized();
333        Self
334    }
335}
336
337impl Default for TuiPanicGuard {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343impl Drop for TuiPanicGuard {
344    fn drop(&mut self) {
345        mark_tui_deinitialized();
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use std::sync::atomic::Ordering;
353
354    #[test]
355    fn test_panic_guard_initialization() {
356        // Reset state for test
357        TUI_INITIALIZED.store(false, Ordering::SeqCst);
358
359        {
360            let _guard = TuiPanicGuard::new();
361            assert!(
362                TUI_INITIALIZED.load(Ordering::SeqCst),
363                "TUI should be marked as initialized"
364            );
365
366            // Drop happens automatically when leaving scope
367        }
368
369        assert!(
370            !TUI_INITIALIZED.load(Ordering::SeqCst),
371            "TUI should be marked as deinitialized after guard drops"
372        );
373    }
374
375    #[test]
376    fn test_restore_terminal_no_panic_when_not_initialized() {
377        // Test that restore does not panic when TUI is not initialized
378        TUI_INITIALIZED.store(false, Ordering::SeqCst);
379
380        // This should not cause issues even if terminal is not in expected state
381        let result = restore_tui();
382        // Should return Ok or Err but not panic
383        assert!(result.is_ok() || result.is_err());
384    }
385
386    #[test]
387    fn test_guard_lifecycle() {
388        TUI_INITIALIZED.store(false, Ordering::SeqCst);
389
390        // Create guard in a separate scope to test drop behavior
391        {
392            let _guard = TuiPanicGuard::new();
393            assert!(
394                TUI_INITIALIZED.load(Ordering::SeqCst),
395                "Guard should mark TUI as initialized"
396            );
397        }
398
399        assert!(
400            !TUI_INITIALIZED.load(Ordering::SeqCst),
401            "Drop should mark TUI as deinitialized"
402        );
403    }
404
405    #[test]
406    fn test_color_eyre_toggle() {
407        set_color_eyre_enabled(false);
408        assert!(!is_color_eyre_enabled());
409
410        set_color_eyre_enabled(true);
411        assert!(is_color_eyre_enabled());
412    }
413}